Draft
feat(channels): add WPS channel integration
This commit is contained in:
parent
961b4adc1c
commit
b97cca2dad
178
extensions/wps/README.md
Normal file
178
extensions/wps/README.md
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# WPS365 Channel Plugin
|
||||||
|
|
||||||
|
Clawdbot 的 WPS365 开放平台频道插件,支持通过 WPS 协作机器人接收和发送消息。
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
1. 在 [WPS 开发者后台](https://open.wps.cn/developer/home) 创建应用
|
||||||
|
2. 获取以下凭证:
|
||||||
|
- **App ID** (client_id)
|
||||||
|
- **App Secret** (client_secret)
|
||||||
|
- **Company ID** (企业 ID)
|
||||||
|
3. 配置应用权限:`kso.chat_message.readwrite`(查询和管理会话消息)
|
||||||
|
4. 配置事件订阅(如需接收消息)
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 通过 npm 安装
|
||||||
|
clawdbot extensions install @clawdbot/wps
|
||||||
|
|
||||||
|
# 或从本地安装(开发时)
|
||||||
|
clawdbot extensions install ./extensions/wps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
在 `~/.clawdbot/config.json` 中添加以下配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wps": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "your-app-id",
|
||||||
|
"appSecret": "your-app-secret",
|
||||||
|
"companyId": "your-company-id",
|
||||||
|
"baseUrl": "https://openapi.wps.cn",
|
||||||
|
"webhook": {
|
||||||
|
"path": "/wps/webhook",
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
"dmPolicy": "pairing",
|
||||||
|
"allowFrom": [],
|
||||||
|
"groupPolicy": "allowlist",
|
||||||
|
"groups": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置项说明
|
||||||
|
|
||||||
|
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|--------|------|------|--------|------|
|
||||||
|
| `enabled` | boolean | 否 | `true` | 是否启用 WPS 频道 |
|
||||||
|
| `appId` | string | 是 | - | WPS 应用 ID (client_id) |
|
||||||
|
| `appSecret` | string | 是 | - | WPS 应用密钥 (client_secret) |
|
||||||
|
| `companyId` | string | 是 | - | WPS 企业 ID |
|
||||||
|
| `baseUrl` | string | 否 | `https://openapi.wps.cn` | WPS API 基础 URL |
|
||||||
|
| `enableEncryption` | boolean | 否 | `true` | 是否启用事件加密解密 |
|
||||||
|
| `webhook.path` | string | 否 | `/wps/webhook` | Webhook 接收路径 |
|
||||||
|
| `webhook.port` | number | 否 | `3000` | Webhook 监听端口 |
|
||||||
|
| `dmPolicy` | string | 否 | `pairing` | 私聊策略:`open`/`allowlist`/`pairing` |
|
||||||
|
| `allowFrom` | string[] | 否 | `[]` | 允许的用户 ID 列表 |
|
||||||
|
| `groupPolicy` | string | 否 | `allowlist` | 群聊策略:`open`/`allowlist` |
|
||||||
|
| `groups` | object | 否 | `{}` | 群聊配置 |
|
||||||
|
|
||||||
|
### DM 策略说明
|
||||||
|
|
||||||
|
- **`open`**:接受所有人的消息(不推荐)
|
||||||
|
- **`allowlist`**:仅接受 `allowFrom` 列表中用户的消息
|
||||||
|
- **`pairing`**:新用户需要通过配对码验证(推荐)
|
||||||
|
|
||||||
|
## 事件加密
|
||||||
|
|
||||||
|
WPS 事件订阅默认启用加密。收到的事件会经过以下处理:
|
||||||
|
|
||||||
|
1. **签名验证**: 使用 `HMAC-SHA256(appSecret, "appId:topic:nonce:time:encrypted_data")` 验证签名
|
||||||
|
2. **数据解密**: 使用 `AES-128-CBC` 解密,密钥为 `MD5(appSecret)`,IV 为 `nonce`
|
||||||
|
|
||||||
|
如果需要禁用加密验证(仅用于调试),设置 `enableEncryption: false`。
|
||||||
|
|
||||||
|
## 配置事件订阅
|
||||||
|
|
||||||
|
要接收用户消息,需要在 WPS 开发者后台配置事件订阅:
|
||||||
|
|
||||||
|
1. 进入应用管理 → 事件订阅
|
||||||
|
2. 配置回调地址:`https://your-domain.com/wps/webhook`
|
||||||
|
3. 订阅消息接收事件
|
||||||
|
|
||||||
|
### Webhook URL 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<your-domain>:<port><path>
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:`https://bot.example.com:3000/wps/webhook`
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
启动 Clawdbot Gateway 后,WPS 频道会自动启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot gateway run
|
||||||
|
```
|
||||||
|
|
||||||
|
检查频道状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels status
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发送消息
|
||||||
|
|
||||||
|
通过 CLI 发送消息:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 发送到会话
|
||||||
|
clawdbot message send --channel wps --target <chat_id> --message "Hello!"
|
||||||
|
|
||||||
|
# 发送到用户(需要用户 ID)
|
||||||
|
clawdbot message send --channel wps -t <user_id> -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### 消息发送
|
||||||
|
|
||||||
|
- **单条消息**:`POST /v7/messages/create`
|
||||||
|
- **批量消息**:`POST /v7/messages/batch_create`
|
||||||
|
|
||||||
|
详细文档:
|
||||||
|
- [发送消息](https://open.wps.cn/documents/app-integration-dev/wps365/server/im/message/single-create-msg)
|
||||||
|
- [事件订阅](https://open.wps.cn/documents/app-integration-dev/wps365/server/im/event/receive-msg)
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 凭证未配置
|
||||||
|
|
||||||
|
```
|
||||||
|
WPS credentials not configured (appId, appSecret, and companyId required)
|
||||||
|
```
|
||||||
|
|
||||||
|
确保在配置文件中正确设置了 `appId`、`appSecret` 和 `companyId`。
|
||||||
|
|
||||||
|
### Token 获取失败
|
||||||
|
|
||||||
|
```
|
||||||
|
Failed to get WPS access token: 401
|
||||||
|
```
|
||||||
|
|
||||||
|
检查 `appId` 和 `appSecret` 是否正确,以及应用是否已授权给目标企业。
|
||||||
|
|
||||||
|
### 消息发送失败
|
||||||
|
|
||||||
|
```
|
||||||
|
WPS send error (code xxx): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
常见错误码:
|
||||||
|
- `403`:权限不足,检查应用权限配置
|
||||||
|
- `404`:目标用户或会话不存在
|
||||||
|
- `429`:请求频率超限
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
cd extensions/wps
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 类型检查
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 在主项目中测试
|
||||||
|
cd ../..
|
||||||
|
pnpm clawdbot channels status
|
||||||
|
```
|
||||||
12
extensions/wps/clawdbot.plugin.json
Normal file
12
extensions/wps/clawdbot.plugin.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "wps",
|
||||||
|
"channels": [
|
||||||
|
"wps"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
18
extensions/wps/index.ts
Normal file
18
extensions/wps/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { wpsPlugin } from "./src/channel.js";
|
||||||
|
import { setWpsRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "wps",
|
||||||
|
name: "wps",
|
||||||
|
description: "wps channel plugin (Open Platform)",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setWpsRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: wpsPlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
48
extensions/wps/package.json
Normal file
48
extensions/wps/package.json
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/wps",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot WPS365 Open Platform channel plugin",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "wps",
|
||||||
|
"label": "WPS",
|
||||||
|
"selectionLabel": "WPS365 (Open Platform)",
|
||||||
|
"docsPath": "/channels/wps",
|
||||||
|
"docsLabel": "WPS",
|
||||||
|
"blurb": "WPS365 Open Platform bot integration.",
|
||||||
|
"aliases": [
|
||||||
|
"wps",
|
||||||
|
"wps365"
|
||||||
|
],
|
||||||
|
"order": 70
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/wps",
|
||||||
|
"localPath": "extensions/wps",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"clawdbot": "workspace:*",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"clawdbot": "*",
|
||||||
|
"zod": "^4"
|
||||||
|
}
|
||||||
|
}
|
||||||
309
extensions/wps/src/channel.ts
Normal file
309
extensions/wps/src/channel.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import type { ChannelPlugin, ChannelStatusIssue, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from "clawdbot/plugin-sdk";
|
||||||
|
import { WpsConfigSchema, type WpsConfig } from "./types.js";
|
||||||
|
import { wpsOutbound } from "./send.js";
|
||||||
|
import { resolveWpsCredentials, getAccessToken } from "./token.js";
|
||||||
|
import { getWpsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
type ResolvedWpsAccount = {
|
||||||
|
accountId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
config: WpsConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
id: "wps",
|
||||||
|
label: "WPS",
|
||||||
|
selectionLabel: "WPS365 (Open Platform)",
|
||||||
|
docsPath: "/channels/wps",
|
||||||
|
docsLabel: "WPS",
|
||||||
|
blurb: "WPS365 Open Platform bot integration.",
|
||||||
|
aliases: ["wps", "wps365"],
|
||||||
|
order: 70,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function resolveWpsAccount(cfg: ClawdbotConfig, _accountId?: string): ResolvedWpsAccount {
|
||||||
|
const wpsCfg = cfg.channels?.wps as WpsConfig | undefined;
|
||||||
|
return {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
enabled: wpsCfg?.enabled !== false,
|
||||||
|
configured: Boolean(resolveWpsCredentials(wpsCfg)),
|
||||||
|
config: wpsCfg ?? ({} as WpsConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowEntry(entry: string): string {
|
||||||
|
// WPS IDs are case-sensitive, do not convert to lowercase
|
||||||
|
return entry.trim().replace(/^wps:/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wpsPlugin: ChannelPlugin<ResolvedWpsAccount> = {
|
||||||
|
id: "wps",
|
||||||
|
meta: { ...meta, aliases: [...meta.aliases] },
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
media: false,
|
||||||
|
threads: false,
|
||||||
|
nativeCommands: false,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.wps"] },
|
||||||
|
configSchema: buildChannelConfigSchema(WpsConfigSchema),
|
||||||
|
|
||||||
|
// WPS IDs are obfuscated strings with no specific format, case-sensitive
|
||||||
|
messaging: {
|
||||||
|
// Preserve original case - WPS IDs are case-sensitive
|
||||||
|
normalizeTarget: (raw: string) => raw.trim(),
|
||||||
|
targetResolver: {
|
||||||
|
hint: "WPS target: user_id or chat_id (obfuscated string, case-sensitive)",
|
||||||
|
looksLikeId: (raw: string) => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
// Accept any non-empty string as WPS ID
|
||||||
|
return trimmed.length > 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
config: {
|
||||||
|
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||||
|
resolveAccount: (cfg) => resolveWpsAccount(cfg as ClawdbotConfig),
|
||||||
|
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||||
|
setAccountEnabled: ({ cfg, enabled }) => ({
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
wps: {
|
||||||
|
...(cfg.channels?.wps ?? {}),
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
deleteAccount: ({ cfg }) => {
|
||||||
|
const next = { ...cfg } as ClawdbotConfig;
|
||||||
|
const nextChannels = { ...cfg.channels };
|
||||||
|
delete nextChannels.wps;
|
||||||
|
if (Object.keys(nextChannels).length > 0) {
|
||||||
|
next.channels = nextChannels;
|
||||||
|
} else {
|
||||||
|
delete next.channels;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
isConfigured: (_account, cfg) => Boolean(resolveWpsCredentials((cfg as ClawdbotConfig).channels?.wps as WpsConfig)),
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg }) => ((cfg as ClawdbotConfig).channels?.wps as WpsConfig)?.allowFrom ?? [],
|
||||||
|
formatAllowFrom: ({ allowFrom }) => allowFrom.map((s) => normalizeAllowEntry(String(s))),
|
||||||
|
},
|
||||||
|
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ cfg }) => {
|
||||||
|
const wpsCfg = (cfg as ClawdbotConfig).channels?.wps as WpsConfig | undefined;
|
||||||
|
return {
|
||||||
|
policy: wpsCfg?.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: wpsCfg?.allowFrom ?? [],
|
||||||
|
policyPath: "channels.wps.dmPolicy",
|
||||||
|
allowFromPath: "channels.wps.allowFrom",
|
||||||
|
approveHint: formatPairingApproveHint("wps"),
|
||||||
|
normalizeEntry: normalizeAllowEntry,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
collectWarnings: ({ cfg }) => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const wpsCfg = (cfg as ClawdbotConfig).channels?.wps as WpsConfig | undefined;
|
||||||
|
|
||||||
|
if (wpsCfg?.dmPolicy === "open") {
|
||||||
|
warnings.push(
|
||||||
|
`- WPS DMs are open to anyone. Set channels.wps.dmPolicy="pairing" or "allowlist" for security.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPolicy = wpsCfg?.groupPolicy ?? "allowlist";
|
||||||
|
if (groupPolicy === "open") {
|
||||||
|
warnings.push(
|
||||||
|
`- WPS groups: groupPolicy="open" allows any group to trigger (mention-gated). Set channels.wps.groupPolicy="allowlist" and configure channels.wps.groups.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pairing: {
|
||||||
|
idLabel: "wpsUserId",
|
||||||
|
normalizeAllowEntry,
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
const wpsCfg = (cfg as ClawdbotConfig).channels?.wps as WpsConfig | undefined;
|
||||||
|
const creds = resolveWpsCredentials(wpsCfg);
|
||||||
|
if (!creds) {
|
||||||
|
throw new Error("WPS credentials not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAccessToken(creds);
|
||||||
|
// Use WPS batch_create API to send approval notification to user
|
||||||
|
const url = `${creds.baseUrl.replace(/\/$/, "")}/v7/messages/batch_create`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "text",
|
||||||
|
receivers: [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
receiver_ids: [id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
content: {
|
||||||
|
text: {
|
||||||
|
content: "Your pairing request has been approved. You can now chat with this bot.",
|
||||||
|
type: "plain",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`Failed to send approval notification: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as { code: number; msg: string };
|
||||||
|
if (data.code !== 0) {
|
||||||
|
throw new Error(`WPS API error (code ${data.code}): ${data.msg}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
groups: {
|
||||||
|
resolveRequireMention: ({ cfg, groupId }) => {
|
||||||
|
const wpsCfg = (cfg as ClawdbotConfig).channels?.wps as WpsConfig | undefined;
|
||||||
|
if (!wpsCfg?.groups || !groupId) return true;
|
||||||
|
const groupConfig = wpsCfg.groups[groupId] ?? wpsCfg.groups["*"];
|
||||||
|
return groupConfig?.requireMention ?? true;
|
||||||
|
},
|
||||||
|
resolveToolPolicy: () => {
|
||||||
|
// WPS uses simplified tool policy strings; return undefined to use system defaults
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
outbound: {
|
||||||
|
...wpsOutbound,
|
||||||
|
deliveryMode: "direct",
|
||||||
|
textChunkLimit: 5000, // WPS message limit is 5000 characters
|
||||||
|
chunker: (text, limit) => getWpsRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
|
},
|
||||||
|
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: (accounts) =>
|
||||||
|
accounts.flatMap((entry) => {
|
||||||
|
const issues: ChannelStatusIssue[] = [];
|
||||||
|
const enabled = entry.enabled !== false;
|
||||||
|
const configured = entry.configured === true;
|
||||||
|
|
||||||
|
if (enabled && !configured) {
|
||||||
|
issues.push({
|
||||||
|
channel: "wps",
|
||||||
|
accountId: String(entry.accountId ?? DEFAULT_ACCOUNT_ID),
|
||||||
|
kind: "config",
|
||||||
|
message: "WPS credentials not configured (appId, appSecret, and companyId required).",
|
||||||
|
fix: "Set channels.wps.appId, channels.wps.appSecret, and channels.wps.companyId.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}),
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account }) => {
|
||||||
|
const creds = resolveWpsCredentials(account.config);
|
||||||
|
if (!creds) {
|
||||||
|
return { ok: false, error: "Not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(creds);
|
||||||
|
return { ok: true, token: token.substring(0, 8) + "..." };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String(err) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||||
|
probe,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const { monitorWpsProvider } = await import("./monitor.js");
|
||||||
|
const wpsCfg = (ctx.cfg as ClawdbotConfig).channels?.wps as WpsConfig | undefined;
|
||||||
|
const port = wpsCfg?.webhook?.port ?? 3000;
|
||||||
|
|
||||||
|
ctx.setStatus({
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
running: true,
|
||||||
|
lastStartAt: Date.now(),
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
ctx.log?.info(`[${ctx.accountId}] starting WPS provider (port ${port})`);
|
||||||
|
|
||||||
|
return monitorWpsProvider({
|
||||||
|
cfg: ctx.cfg as ClawdbotConfig,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
channel: "wps",
|
||||||
|
getStatus: async ({ cfg }) => {
|
||||||
|
const wpsCfg = (cfg as ClawdbotConfig).channels?.wps as WpsConfig | undefined;
|
||||||
|
const configured = Boolean(resolveWpsCredentials(wpsCfg));
|
||||||
|
return {
|
||||||
|
channel: "wps",
|
||||||
|
configured,
|
||||||
|
statusLines: [`WPS: ${configured ? "configured" : "needs appId, appSecret, and companyId"}`],
|
||||||
|
selectionHint: configured ? "configured" : "needs credentials",
|
||||||
|
quickstartScore: configured ? 2 : 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
configure: async ({ cfg }) => {
|
||||||
|
// WPS configuration is done via config file; no interactive wizard
|
||||||
|
return { cfg };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
3
extensions/wps/src/index.ts
Normal file
3
extensions/wps/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { monitorWpsProvider } from "./monitor.js";
|
||||||
|
export { wpsOutbound } from "./send.js";
|
||||||
|
export { type WpsConfig, WpsConfigSchema } from "./types.js";
|
||||||
346
extensions/wps/src/monitor.ts
Normal file
346
extensions/wps/src/monitor.ts
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import { resolveWpsCredentials } from "./token.js";
|
||||||
|
import type { WpsConfig } from "./types.js";
|
||||||
|
import { getWpsRuntime } from "./runtime.js";
|
||||||
|
import { createWpsSimpleDispatcher, createWpsReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
|
|
||||||
|
export type MonitorWpsOpts = {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonitorWpsResult = {
|
||||||
|
app: unknown;
|
||||||
|
shutdown: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WPS encrypted event structure
|
||||||
|
* Reference: https://open.wps.cn/documents/app-integration-dev/wps365/server/event-subscription/security-verification
|
||||||
|
*/
|
||||||
|
type WpsEncryptedEvent = {
|
||||||
|
topic: string; // 消息主题
|
||||||
|
operation: string; // 消息变更动作
|
||||||
|
time: number; // 时间戳(秒)
|
||||||
|
nonce: string; // IV 向量
|
||||||
|
signature: string; // 消息签名
|
||||||
|
encrypted_data: string; // 加密数据
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WPS decrypted event payload structure (actual format from webhook)
|
||||||
|
* Reference: https://open.wps.cn/documents/app-integration-dev/wps365/server/im/event/receive-msg
|
||||||
|
*/
|
||||||
|
type WpsEventPayload = {
|
||||||
|
chat?: {
|
||||||
|
id?: string;
|
||||||
|
type?: string; // "p2p" for DM, "group" for group chat
|
||||||
|
};
|
||||||
|
message?: {
|
||||||
|
id?: string;
|
||||||
|
type?: string; // "text", etc.
|
||||||
|
content?: {
|
||||||
|
text?: string | { content?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
sender?: {
|
||||||
|
id?: string;
|
||||||
|
type?: string; // "user"
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
send_time?: number;
|
||||||
|
company_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify WPS event signature using HMAC-SHA256
|
||||||
|
* content = "appId:topic:nonce:time:encrypted_data"
|
||||||
|
* signature = base64url_no_padding(HMAC-SHA256(appSecret, content))
|
||||||
|
*/
|
||||||
|
function verifySignature(params: {
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
topic: string;
|
||||||
|
nonce: string;
|
||||||
|
time: number;
|
||||||
|
encryptedData: string;
|
||||||
|
signature: string;
|
||||||
|
}): boolean {
|
||||||
|
const content = `${params.appId}:${params.topic}:${params.nonce}:${params.time}:${params.encryptedData}`;
|
||||||
|
const hmac = crypto.createHmac("sha256", params.appSecret);
|
||||||
|
hmac.update(content);
|
||||||
|
// URL-safe base64 without padding
|
||||||
|
const expected = hmac.digest("base64url").replace(/=+$/, "");
|
||||||
|
return expected === params.signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt WPS event data using AES-256-CBC
|
||||||
|
* cipher = MD5(appSecret) hex string -> 32 bytes (used as AES-256 key)
|
||||||
|
* iv = nonce (UTF-8, first 16 bytes)
|
||||||
|
* PKCS7 padding is handled by Node.js crypto
|
||||||
|
*/
|
||||||
|
function decryptEventData(encryptedData: string, appSecret: string, nonce: string): string {
|
||||||
|
// cipher = MD5(appSecret) as hex string (32 chars = 32 bytes for AES-256)
|
||||||
|
const cipherHex = crypto.createHash("md5").update(appSecret).digest("hex");
|
||||||
|
const key = Buffer.from(cipherHex, "utf-8");
|
||||||
|
// iv = nonce as UTF-8, take first 16 bytes (AES block size)
|
||||||
|
const iv = Buffer.from(nonce, "utf-8").subarray(0, 16);
|
||||||
|
const encrypted = Buffer.from(encryptedData, "base64");
|
||||||
|
|
||||||
|
// AES-256-CBC because key is 32 bytes (MD5 hex string)
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||||
|
let decrypted = decipher.update(encrypted);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
return decrypted.toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowEntry(entry: string): string {
|
||||||
|
// WPS IDs are case-sensitive, do not convert to lowercase
|
||||||
|
return entry.trim().replace(/^wps:/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowed(senderId: string, allowFrom: string[], dmPolicy: string): boolean {
|
||||||
|
if (dmPolicy === "open") return true;
|
||||||
|
if (!allowFrom || allowFrom.length === 0) return dmPolicy !== "allowlist";
|
||||||
|
|
||||||
|
const normalizedSender = normalizeAllowEntry(senderId);
|
||||||
|
return allowFrom.some((entry) => {
|
||||||
|
if (entry === "*") return true;
|
||||||
|
return normalizeAllowEntry(entry) === normalizedSender;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the request body is an encrypted WPS event
|
||||||
|
*/
|
||||||
|
function isEncryptedEvent(body: unknown): body is WpsEncryptedEvent {
|
||||||
|
if (!body || typeof body !== "object") return false;
|
||||||
|
const b = body as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof b.encrypted_data === "string" &&
|
||||||
|
typeof b.nonce === "string" &&
|
||||||
|
typeof b.signature === "string" &&
|
||||||
|
typeof b.topic === "string" &&
|
||||||
|
typeof b.time === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function monitorWpsProvider(opts: MonitorWpsOpts): Promise<MonitorWpsResult> {
|
||||||
|
const log = opts.runtime?.log ?? console.log;
|
||||||
|
const errorLog = opts.runtime?.error ?? console.error;
|
||||||
|
const cfg = opts.cfg;
|
||||||
|
const wpsCfg = cfg.channels?.wps as WpsConfig | undefined;
|
||||||
|
|
||||||
|
if (!wpsCfg?.enabled) {
|
||||||
|
log("WPS provider disabled");
|
||||||
|
return { app: null, shutdown: async () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = resolveWpsCredentials(wpsCfg);
|
||||||
|
if (!creds) {
|
||||||
|
errorLog("WPS credentials not configured (appId, appSecret, and companyId required)");
|
||||||
|
return { app: null, shutdown: async () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const express = await import("express");
|
||||||
|
const app = express.default();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const port = wpsCfg.webhook?.port ?? 3000;
|
||||||
|
const path = wpsCfg.webhook?.path ?? "/wps/webhook";
|
||||||
|
const dmPolicy = wpsCfg.dmPolicy ?? "pairing";
|
||||||
|
const allowFrom = wpsCfg.allowFrom ?? [];
|
||||||
|
const enableEncryption = wpsCfg.enableEncryption !== false; // Default to true
|
||||||
|
|
||||||
|
app.post(path, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
let eventPayload: WpsEventPayload;
|
||||||
|
|
||||||
|
// Check if this is an encrypted event
|
||||||
|
if (enableEncryption && isEncryptedEvent(req.body)) {
|
||||||
|
const encryptedBody = req.body as WpsEncryptedEvent;
|
||||||
|
|
||||||
|
// 1. Verify signature
|
||||||
|
const isValid = verifySignature({
|
||||||
|
appId: creds.appId,
|
||||||
|
appSecret: creds.appSecret,
|
||||||
|
topic: encryptedBody.topic,
|
||||||
|
nonce: encryptedBody.nonce,
|
||||||
|
time: encryptedBody.time,
|
||||||
|
encryptedData: encryptedBody.encrypted_data,
|
||||||
|
signature: encryptedBody.signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
errorLog("WPS signature verification failed");
|
||||||
|
res.status(403).json({ code: -1, msg: "Invalid signature" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decrypt data
|
||||||
|
try {
|
||||||
|
const decrypted = decryptEventData(
|
||||||
|
encryptedBody.encrypted_data,
|
||||||
|
creds.appSecret,
|
||||||
|
encryptedBody.nonce
|
||||||
|
);
|
||||||
|
eventPayload = JSON.parse(decrypted) as WpsEventPayload;
|
||||||
|
log(`WPS decrypted event: topic=${encryptedBody.topic}, operation=${encryptedBody.operation}`);
|
||||||
|
} catch (err) {
|
||||||
|
errorLog("WPS decryption failed:", err);
|
||||||
|
res.status(400).json({ code: -1, msg: "Decryption failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-encrypted event (or encryption disabled)
|
||||||
|
eventPayload = req.body as WpsEventPayload;
|
||||||
|
}
|
||||||
|
// WPS event subscription callback - handle message payload directly
|
||||||
|
const message = eventPayload.message;
|
||||||
|
const sender = eventPayload.sender;
|
||||||
|
const chat = eventPayload.chat;
|
||||||
|
|
||||||
|
if (!message || !sender) {
|
||||||
|
// Not a valid message payload, might be a health check or other event
|
||||||
|
res.status(200).json({ code: 0, msg: "ok" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from message content (can be string or { content: string })
|
||||||
|
const textContent = message.content?.text;
|
||||||
|
const text = typeof textContent === "string"
|
||||||
|
? textContent
|
||||||
|
: (textContent?.content ?? "");
|
||||||
|
const fromId = sender.id ?? "";
|
||||||
|
const chatId = chat?.id ?? "";
|
||||||
|
const chatType = chat?.type;
|
||||||
|
|
||||||
|
if (!fromId) {
|
||||||
|
errorLog("Unable to identify sender");
|
||||||
|
res.status(200).json({ code: 0, msg: "ok" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderKey = fromId;
|
||||||
|
// "p2p" for DM, "group" for group chat
|
||||||
|
const isDirect = chatType === "single" || chatType === "p2p" || chatType === "direct";
|
||||||
|
// Encode receiver type in channelId: "user:xxx" for DM, "chat:xxx" for group
|
||||||
|
const channelId = isDirect ? `user:${fromId}` : `chat:${chatId}`;
|
||||||
|
|
||||||
|
log(`WPS received message from ${senderKey}: ${text.substring(0, 50)}`);
|
||||||
|
|
||||||
|
// Respond immediately to avoid webhook timeout, then process asynchronously
|
||||||
|
res.status(200).json({ code: 0, msg: "ok" });
|
||||||
|
|
||||||
|
// Process message asynchronously
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (isDirect && !isAllowed(senderKey, allowFrom, dmPolicy)) {
|
||||||
|
log(`Sender ${senderKey} not in allowFrom list (policy: ${dmPolicy})`);
|
||||||
|
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
const core = getWpsRuntime();
|
||||||
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
|
channel: "wps",
|
||||||
|
id: senderKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code && created) {
|
||||||
|
const pairingText = core.channel.pairing.buildPairingReply({
|
||||||
|
channel: "wps",
|
||||||
|
idLine: `Your WPS user id: ${senderKey}`,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
const simpleDispatcher = createWpsSimpleDispatcher({ cfg, channelId });
|
||||||
|
await simpleDispatcher.dispatch({ body: pairingText });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = getWpsRuntime();
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "wps",
|
||||||
|
peer: {
|
||||||
|
kind: isDirect ? "dm" : "group",
|
||||||
|
id: channelId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: text,
|
||||||
|
RawBody: text,
|
||||||
|
CommandBody: text,
|
||||||
|
From: `wps:${senderKey}`,
|
||||||
|
To: `wps:${creds.appId}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: creds.appId,
|
||||||
|
ChatType: isDirect ? "direct" : "group",
|
||||||
|
SenderName: sender.name ?? "WPS User",
|
||||||
|
SenderId: senderKey,
|
||||||
|
Provider: "wps",
|
||||||
|
Surface: "wps",
|
||||||
|
Timestamp: eventPayload.send_time ?? Date.now(),
|
||||||
|
OriginatingChannel: "wps",
|
||||||
|
OriginatingTo: `wps:${creds.appId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } = createWpsReplyDispatcher({
|
||||||
|
cfg,
|
||||||
|
agentId: route.agentId,
|
||||||
|
runtime: opts.runtime,
|
||||||
|
channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
markDispatchIdle();
|
||||||
|
} catch (asyncErr) {
|
||||||
|
errorLog("WPS async message processing error:", asyncErr);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} catch (err) {
|
||||||
|
errorLog("WPS webhook error:", err);
|
||||||
|
res.status(500).json({ code: -1, msg: "Internal Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let server: ReturnType<typeof app.listen> | null = null;
|
||||||
|
|
||||||
|
const startServer = () => {
|
||||||
|
server = app.listen(port, () => {
|
||||||
|
log(`WPS provider listening on port ${port} at ${path}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
if (opts.abortSignal) {
|
||||||
|
opts.abortSignal.addEventListener("abort", () => {
|
||||||
|
if (server) {
|
||||||
|
server.close();
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
shutdown: async () => {
|
||||||
|
if (server) {
|
||||||
|
server.close();
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
92
extensions/wps/src/reply-dispatcher.ts
Normal file
92
extensions/wps/src/reply-dispatcher.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import type { ClawdbotConfig, RuntimeEnv, ReplyPayload } from "clawdbot/plugin-sdk";
|
||||||
|
import { createReplyPrefixContext } from "clawdbot/plugin-sdk";
|
||||||
|
import { wpsOutbound } from "./send.js";
|
||||||
|
import { getWpsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
type PayloadBody = string | { text?: string } | null | undefined;
|
||||||
|
|
||||||
|
/** Simple dispatcher for sending pairing/one-off messages. */
|
||||||
|
export type WpsSimpleDispatcher = {
|
||||||
|
dispatch: (payload: { body?: PayloadBody }) => Promise<{ id: string; ts: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractText(body: PayloadBody): string {
|
||||||
|
if (typeof body === "string") return body;
|
||||||
|
if (body && typeof body === "object" && "text" in body) {
|
||||||
|
return body.text ?? "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple dispatcher for pairing and one-off messages. */
|
||||||
|
export function createWpsSimpleDispatcher(opts: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
channelId: string;
|
||||||
|
}): WpsSimpleDispatcher {
|
||||||
|
return {
|
||||||
|
dispatch: async (payload) => {
|
||||||
|
const text = extractText(payload.body);
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return { id: "skipped", ts: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wpsOutbound.sendText) {
|
||||||
|
throw new Error("wpsOutbound.sendText not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wpsOutbound.sendText({
|
||||||
|
cfg: opts.cfg,
|
||||||
|
to: opts.channelId,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: result.messageId ?? "", ts: result.timestamp ?? Date.now() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full dispatcher for use with dispatchReplyFromConfig. */
|
||||||
|
export function createWpsReplyDispatcher(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentId: string;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
channelId: string;
|
||||||
|
}) {
|
||||||
|
const core = getWpsRuntime();
|
||||||
|
const prefixContext = createReplyPrefixContext({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentId: params.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
|
responsePrefix: prefixContext.responsePrefix,
|
||||||
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||||
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||||
|
deliver: async (payload: ReplyPayload) => {
|
||||||
|
const text = payload.text ?? "";
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
if (!wpsOutbound.sendText) {
|
||||||
|
throw new Error("wpsOutbound.sendText not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
await wpsOutbound.sendText({
|
||||||
|
cfg: params.cfg,
|
||||||
|
to: params.channelId,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
const logError = params.runtime?.error ?? console.error;
|
||||||
|
logError(`wps ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
|
||||||
|
markDispatchIdle,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
extensions/wps/src/runtime.ts
Normal file
14
extensions/wps/src/runtime.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setWpsRuntime(next: PluginRuntime) {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWpsRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Wps runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
174
extensions/wps/src/send.ts
Normal file
174
extensions/wps/src/send.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import type { ChannelOutboundAdapter, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { getAccessToken, resolveWpsCredentials } from "./token.js";
|
||||||
|
import type { WpsConfig } from "./types.js";
|
||||||
|
|
||||||
|
type WpsApiResponse = {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data?: {
|
||||||
|
message_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** WPS IDs are obfuscated strings with no specific format */
|
||||||
|
function isValidWpsTarget(to: string): boolean {
|
||||||
|
const trimmed = to.trim();
|
||||||
|
return trimmed.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wpsOutbound: ChannelOutboundAdapter = {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
|
||||||
|
resolveTarget: ({ to, allowFrom }) => {
|
||||||
|
const trimmed = to?.trim() ?? "";
|
||||||
|
// Accept any non-empty target (WPS IDs are obfuscated strings)
|
||||||
|
if (trimmed && isValidWpsTarget(trimmed)) {
|
||||||
|
return { ok: true, to: trimmed };
|
||||||
|
}
|
||||||
|
// Fallback to first allowFrom entry
|
||||||
|
const first = allowFrom?.[0];
|
||||||
|
if (first) {
|
||||||
|
return { ok: true, to: String(first).trim() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error("WPS target is required (user_id or chat_id)"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
sendText: async ({ cfg, to, text }) => {
|
||||||
|
const wpsCfg = (cfg as ClawdbotConfig).channels?.wps as WpsConfig | undefined;
|
||||||
|
const creds = resolveWpsCredentials(wpsCfg);
|
||||||
|
if (!creds) {
|
||||||
|
throw new Error("WPS credentials not configured (appId, appSecret, and companyId required)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!to?.trim()) {
|
||||||
|
throw new Error("WPS target (to) is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAccessToken(creds);
|
||||||
|
// WPS365 single message API: POST /v7/messages/create
|
||||||
|
const url = `${creds.baseUrl.replace(/\/$/, "")}/v7/messages/create`;
|
||||||
|
|
||||||
|
// Parse receiver type from "to" field: "user:xxx" or "chat:xxx"
|
||||||
|
// Default to "user" if no prefix
|
||||||
|
let receiverType: "user" | "chat" = "user";
|
||||||
|
let receiverId = to;
|
||||||
|
if (to.startsWith("user:")) {
|
||||||
|
receiverType = "user";
|
||||||
|
receiverId = to.slice(5);
|
||||||
|
} else if (to.startsWith("chat:")) {
|
||||||
|
receiverType = "chat";
|
||||||
|
receiverId = to.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WPS message format requires receiver object
|
||||||
|
const requestBody = {
|
||||||
|
type: "text",
|
||||||
|
receiver: {
|
||||||
|
type: receiverType,
|
||||||
|
receiver_id: receiverId,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
text: {
|
||||||
|
content: text,
|
||||||
|
type: "plain",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`WPS API error: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: unknown = await res.json();
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
throw new Error("WPS API returned invalid response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = data as WpsApiResponse;
|
||||||
|
if (response.code !== 0) {
|
||||||
|
throw new Error(`WPS send error (code ${response.code}): ${response.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel: "wps" as const,
|
||||||
|
messageId: response.data?.message_id ?? "",
|
||||||
|
chatId: receiverId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMedia: async () => {
|
||||||
|
// TODO: implement media sending for WPS
|
||||||
|
throw new Error("WPS media sending not yet implemented");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to a specific user (not a chat).
|
||||||
|
* Uses batch_create API with receivers array.
|
||||||
|
*/
|
||||||
|
export async function sendToUser(cfg: ClawdbotConfig, userId: string, text: string) {
|
||||||
|
const wpsCfg = cfg.channels?.wps as WpsConfig | undefined;
|
||||||
|
const creds = resolveWpsCredentials(wpsCfg);
|
||||||
|
if (!creds) {
|
||||||
|
throw new Error("WPS credentials not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAccessToken(creds);
|
||||||
|
const url = `${creds.baseUrl.replace(/\/$/, "")}/v7/messages/batch_create`;
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
type: "text",
|
||||||
|
receivers: [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
receiver_id: userId
|
||||||
|
},
|
||||||
|
],
|
||||||
|
content: {
|
||||||
|
text: {
|
||||||
|
content: text,
|
||||||
|
type: "plain",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`WPS API error: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as WpsApiResponse;
|
||||||
|
if (data.code !== 0) {
|
||||||
|
throw new Error(`WPS send error (code ${data.code}): ${data.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel: "wps" as const,
|
||||||
|
messageId: data.data?.message_id ?? "",
|
||||||
|
chatId: userId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
64
extensions/wps/src/token.ts
Normal file
64
extensions/wps/src/token.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { WpsConfig, WpsCredentials } from "./types.js";
|
||||||
|
|
||||||
|
type TokenCache = {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = new Map<string, TokenCache>();
|
||||||
|
|
||||||
|
export function resolveWpsCredentials(cfg?: WpsConfig): WpsCredentials | null {
|
||||||
|
if (!cfg?.appId || !cfg?.appSecret || !cfg?.companyId) return null;
|
||||||
|
return {
|
||||||
|
appId: cfg.appId,
|
||||||
|
appSecret: cfg.appSecret,
|
||||||
|
companyId: cfg.companyId,
|
||||||
|
baseUrl: cfg.baseUrl ?? "https://openapi.wps.cn",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get access token using OAuth2 client_credentials flow.
|
||||||
|
* WPS API endpoint: POST /oauth2/token
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(creds: WpsCredentials): Promise<string> {
|
||||||
|
const cacheKey = creds.appId;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now() + 60000) {
|
||||||
|
return cached.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${creds.baseUrl.replace(/\/$/, "")}/oauth2/token`;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: creds.appId,
|
||||||
|
client_secret: creds.appSecret,
|
||||||
|
});
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Failed to get WPS access token: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as { code?: number; msg?: string; access_token: string; expires_in: number };
|
||||||
|
|
||||||
|
// WPS API returns code=0 on success
|
||||||
|
if (data.code !== undefined && data.code !== 0) {
|
||||||
|
throw new Error(`WPS OAuth error (code ${data.code}): ${data.msg ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cacheKey, {
|
||||||
|
token: data.access_token,
|
||||||
|
expiresAt: Date.now() + (data.expires_in * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias for backward compatibility
|
||||||
|
export const getTenantAccessToken = getAccessToken;
|
||||||
45
extensions/wps/src/types.ts
Normal file
45
extensions/wps/src/types.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DM policy for WPS channel
|
||||||
|
* - "open": Accept messages from anyone
|
||||||
|
* - "allowlist": Only accept messages from users in allowFrom list
|
||||||
|
* - "pairing": Require pairing code for new users (default)
|
||||||
|
*/
|
||||||
|
export const WpsDmPolicySchema = z.enum(["open", "allowlist", "pairing"]).default("pairing");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group configuration for WPS
|
||||||
|
*/
|
||||||
|
export const WpsGroupConfigSchema = z.object({
|
||||||
|
requireMention: z.boolean().optional().describe("Require @mention to trigger in this group"),
|
||||||
|
toolPolicy: z.enum(["full", "limited", "none"]).optional().describe("Tool access policy for this group"),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export const WpsConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
appId: z.string().describe("WPS App ID (client_id)"),
|
||||||
|
appSecret: z.string().describe("WPS App Secret (client_secret)"),
|
||||||
|
companyId: z.string().describe("WPS Company ID"),
|
||||||
|
baseUrl: z.string().default("https://openapi.wps.cn").describe("WPS API Base URL"),
|
||||||
|
enableEncryption: z.boolean().default(true).describe("Enable event encryption/decryption"),
|
||||||
|
webhook: z.object({
|
||||||
|
path: z.string().default("/wps/webhook"),
|
||||||
|
port: z.number().default(3000),
|
||||||
|
}).optional(),
|
||||||
|
dmPolicy: WpsDmPolicySchema.optional().describe("DM access policy: open, allowlist, or pairing"),
|
||||||
|
allowFrom: z.array(z.string()).optional().describe("List of allowed user IDs"),
|
||||||
|
groups: z.record(z.string(), WpsGroupConfigSchema).optional().describe("Group-specific configurations"),
|
||||||
|
groupPolicy: z.enum(["open", "allowlist"]).optional().describe("Group access policy"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WpsConfig = z.infer<typeof WpsConfigSchema>;
|
||||||
|
export type WpsDmPolicy = z.infer<typeof WpsDmPolicySchema>;
|
||||||
|
export type WpsGroupConfig = z.infer<typeof WpsGroupConfigSchema>;
|
||||||
|
|
||||||
|
export type WpsCredentials = {
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
companyId: string;
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
19
extensions/wps/tsconfig.json
Normal file
19
extensions/wps/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"clawdbot/plugin-sdk": ["../../src/plugin-sdk/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user