feat(channels): add WPS channel integration
This commit is contained in:
testmsr 2026-01-28 10:36:09 +08:00
parent 961b4adc1c
commit b97cca2dad
13 changed files with 1322 additions and 0 deletions

178
extensions/wps/README.md Normal file
View 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
```

View File

@ -0,0 +1,12 @@
{
"id": "wps",
"channels": [
"wps"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

18
extensions/wps/index.ts Normal file
View 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;

View 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"
}
}

View 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 };
},
},
};

View File

@ -0,0 +1,3 @@
export { monitorWpsProvider } from "./monitor.js";
export { wpsOutbound } from "./send.js";
export { type WpsConfig, WpsConfigSchema } from "./types.js";

View 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;
}
},
};
}

View 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,
};
}

View 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
View 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(),
};
}

View 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;

View 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;
};

View 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"]
}