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