diff --git a/extensions/wps/README.md b/extensions/wps/README.md new file mode 100644 index 000000000..8df23a0ac --- /dev/null +++ b/extensions/wps/README.md @@ -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://: +``` + +示例:`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 --message "Hello!" + +# 发送到用户(需要用户 ID) +clawdbot message send --channel wps -t -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 +``` \ No newline at end of file diff --git a/extensions/wps/clawdbot.plugin.json b/extensions/wps/clawdbot.plugin.json new file mode 100644 index 000000000..24016a0b8 --- /dev/null +++ b/extensions/wps/clawdbot.plugin.json @@ -0,0 +1,12 @@ +{ + "id": "wps", + "channels": [ + "wps" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } + } + \ No newline at end of file diff --git a/extensions/wps/index.ts b/extensions/wps/index.ts new file mode 100644 index 000000000..c9c41ff12 --- /dev/null +++ b/extensions/wps/index.ts @@ -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; \ No newline at end of file diff --git a/extensions/wps/package.json b/extensions/wps/package.json new file mode 100644 index 000000000..fc5b2c26f --- /dev/null +++ b/extensions/wps/package.json @@ -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" + } + } \ No newline at end of file diff --git a/extensions/wps/src/channel.ts b/extensions/wps/src/channel.ts new file mode 100644 index 000000000..5951e640d --- /dev/null +++ b/extensions/wps/src/channel.ts @@ -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 = { + 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 }; + }, + }, +}; diff --git a/extensions/wps/src/index.ts b/extensions/wps/src/index.ts new file mode 100644 index 000000000..2b664ef00 --- /dev/null +++ b/extensions/wps/src/index.ts @@ -0,0 +1,3 @@ +export { monitorWpsProvider } from "./monitor.js"; +export { wpsOutbound } from "./send.js"; +export { type WpsConfig, WpsConfigSchema } from "./types.js"; \ No newline at end of file diff --git a/extensions/wps/src/monitor.ts b/extensions/wps/src/monitor.ts new file mode 100644 index 000000000..c78e690b8 --- /dev/null +++ b/extensions/wps/src/monitor.ts @@ -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; +}; + +/** + * 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; + 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 { + 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 | 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; + } + }, + }; +} diff --git a/extensions/wps/src/reply-dispatcher.ts b/extensions/wps/src/reply-dispatcher.ts new file mode 100644 index 000000000..1bf831c14 --- /dev/null +++ b/extensions/wps/src/reply-dispatcher.ts @@ -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, + }; +} \ No newline at end of file diff --git a/extensions/wps/src/runtime.ts b/extensions/wps/src/runtime.ts new file mode 100644 index 000000000..4312a68fc --- /dev/null +++ b/extensions/wps/src/runtime.ts @@ -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; +} \ No newline at end of file diff --git a/extensions/wps/src/send.ts b/extensions/wps/src/send.ts new file mode 100644 index 000000000..75c29060f --- /dev/null +++ b/extensions/wps/src/send.ts @@ -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(), + }; +} diff --git a/extensions/wps/src/token.ts b/extensions/wps/src/token.ts new file mode 100644 index 000000000..efb6f29ac --- /dev/null +++ b/extensions/wps/src/token.ts @@ -0,0 +1,64 @@ +import type { WpsConfig, WpsCredentials } from "./types.js"; + +type TokenCache = { + token: string; + expiresAt: number; +}; + +const cache = new Map(); + +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 { + 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; diff --git a/extensions/wps/src/types.ts b/extensions/wps/src/types.ts new file mode 100644 index 000000000..e7a8e3da1 --- /dev/null +++ b/extensions/wps/src/types.ts @@ -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; +export type WpsDmPolicy = z.infer; +export type WpsGroupConfig = z.infer; + +export type WpsCredentials = { + appId: string; + appSecret: string; + companyId: string; + baseUrl: string; +}; diff --git a/extensions/wps/tsconfig.json b/extensions/wps/tsconfig.json new file mode 100644 index 000000000..7ba12f4cd --- /dev/null +++ b/extensions/wps/tsconfig.json @@ -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"] +}