skyloom 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +52 -6
  2. package/dist/cli/main.js +3 -0
  3. package/dist/cli/main.js.map +1 -1
  4. package/dist/core/agent.d.ts +4 -1
  5. package/dist/core/agent.d.ts.map +1 -1
  6. package/dist/core/agent.js +83 -64
  7. package/dist/core/agent.js.map +1 -1
  8. package/dist/gateway/channels/feishu.d.ts +19 -0
  9. package/dist/gateway/channels/feishu.d.ts.map +1 -0
  10. package/dist/gateway/channels/feishu.js +186 -0
  11. package/dist/gateway/channels/feishu.js.map +1 -0
  12. package/dist/gateway/channels/qq.d.ts +25 -0
  13. package/dist/gateway/channels/qq.d.ts.map +1 -0
  14. package/dist/gateway/channels/qq.js +177 -0
  15. package/dist/gateway/channels/qq.js.map +1 -0
  16. package/dist/gateway/channels/wecom.d.ts +26 -0
  17. package/dist/gateway/channels/wecom.d.ts.map +1 -0
  18. package/dist/gateway/channels/wecom.js +177 -0
  19. package/dist/gateway/channels/wecom.js.map +1 -0
  20. package/dist/gateway/gateway.d.ts +19 -0
  21. package/dist/gateway/gateway.d.ts.map +1 -0
  22. package/dist/gateway/gateway.js +152 -0
  23. package/dist/gateway/gateway.js.map +1 -0
  24. package/dist/gateway/helpers.d.ts +39 -0
  25. package/dist/gateway/helpers.d.ts.map +1 -0
  26. package/dist/gateway/helpers.js +81 -0
  27. package/dist/gateway/helpers.js.map +1 -0
  28. package/dist/gateway/registry.d.ts +12 -0
  29. package/dist/gateway/registry.d.ts.map +1 -0
  30. package/dist/gateway/registry.js +44 -0
  31. package/dist/gateway/registry.js.map +1 -0
  32. package/dist/gateway/types.d.ts +81 -0
  33. package/dist/gateway/types.d.ts.map +1 -0
  34. package/dist/gateway/types.js +14 -0
  35. package/dist/gateway/types.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/cli/main.ts +3 -0
  38. package/src/core/agent.ts +83 -62
  39. package/src/gateway/channels/feishu.ts +142 -0
  40. package/src/gateway/channels/qq.ts +140 -0
  41. package/src/gateway/channels/wecom.ts +142 -0
  42. package/src/gateway/gateway.ts +151 -0
  43. package/src/gateway/helpers.ts +82 -0
  44. package/src/gateway/registry.ts +45 -0
  45. package/src/gateway/types.ts +91 -0
  46. package/tests/agent.test.ts +45 -19
  47. package/tests/gateway.test.ts +221 -0
package/src/core/agent.ts CHANGED
@@ -90,8 +90,12 @@ export class BaseAgent {
90
90
  protected _skillTools: Map<string, string[]> = new Map();
91
91
  protected _skillConfigOverrides: Map<string, Record<string, any>> = new Map();
92
92
  protected _baseSystemPrompt: string = '';
93
+ /** @deprecated stopping is progress-based now; kept for back-compat only. */
93
94
  protected _maxToolRounds: number = 50;
94
- protected _maxToolRoundsHardCap: number = 200;
95
+ /** Last-resort backstop against runaway; not the normal stop path. */
96
+ protected _maxToolRoundsHardCap: number = 1000;
97
+ /** Stop after this many consecutive rounds with no progress (see chatStreamImpl). */
98
+ protected _maxNoProgressRounds: number = 6;
95
99
  protected _userTurnsSinceExtract: number = 0;
96
100
  protected _pendingExtracts: Set<Promise<any>> = new Set();
97
101
  protected _pendingRequests: Map<string, { resolve: (value: string) => void; reject: (err: Error) => void }> = new Map();
@@ -132,27 +136,28 @@ export class BaseAgent {
132
136
  maxPersistedMessages: mc.maxPersistedMessages || mc.max_persisted_messages,
133
137
  }, this.name);
134
138
 
135
- // Tool-round budget. The LoopGuard is the real safety net (it stops genuine
136
- // loops repeated signatures, all-failing calls, search storms at any
137
- // round count), so this cap exists only as a last-resort ceiling against
138
- // pathological cases the guard misses. Defaults are generous so normal long
139
- // tasks (big refactors across many files) never hit it; configurable via
140
- // config.llm.max_tool_rounds / max_tool_rounds_hard_cap. Setting
141
- // max_tool_rounds to 0 (or negative) means "unlimited" — a very high ceiling
142
- // so a guard-less runaway still can't burn tokens forever.
139
+ // Stopping is PROGRESS-based, not round-count based (OpenClaw-style): a task
140
+ // runs as long as it keeps making progress. The agent stops when it makes no
141
+ // progress for `_maxNoProgressRounds` rounds in a row, or when the LoopGuard
142
+ // detects a genuine loop. `_maxToolRoundsHardCap` is only a last-resort
143
+ // backstop against pathological runaway and is set very high so normal long
144
+ // tasks never reach it. All three are configurable under config.llm.
143
145
  const lc: any = (config as any).llm || {};
144
- const soft = Number(lc.max_tool_rounds ?? lc.maxToolRounds);
145
146
  const hard = Number(lc.max_tool_rounds_hard_cap ?? lc.maxToolRoundsHardCap);
146
- const UNLIMITED = 100000;
147
- if (Number.isFinite(soft) && soft <= 0) {
148
- // Unlimited: rely entirely on the LoopGuard + Ctrl-C + context compaction.
149
- this._maxToolRounds = UNLIMITED;
150
- this._maxToolRoundsHardCap = UNLIMITED;
151
- } else {
152
- this._maxToolRounds = Number.isFinite(soft) && soft > 0 ? Math.floor(soft) : 50;
153
- this._maxToolRoundsHardCap = Number.isFinite(hard) && hard > 0
154
- ? Math.max(Math.floor(hard), this._maxToolRounds)
155
- : Math.max(200, this._maxToolRounds);
147
+ const noProg = Number(lc.max_no_progress_rounds ?? lc.maxNoProgressRounds);
148
+ if (Number.isFinite(hard) && hard <= 0) {
149
+ this._maxToolRoundsHardCap = 1_000_000; // effectively unlimited
150
+ } else if (Number.isFinite(hard) && hard > 0) {
151
+ this._maxToolRoundsHardCap = Math.floor(hard);
152
+ } // else keep the generous default (1000)
153
+ if (Number.isFinite(noProg) && noProg > 0) {
154
+ this._maxNoProgressRounds = Math.floor(noProg);
155
+ }
156
+ // Back-compat: max_tool_rounds is no longer a hard stop, but if someone set
157
+ // it we honor it as the hard cap so existing configs still bound the run.
158
+ const legacySoft = Number(lc.max_tool_rounds ?? lc.maxToolRounds);
159
+ if (Number.isFinite(legacySoft) && legacySoft > 0) {
160
+ this._maxToolRoundsHardCap = Math.max(this._maxToolRoundsHardCap, Math.floor(legacySoft));
156
161
  }
157
162
  }
158
163
 
@@ -359,22 +364,9 @@ export class BaseAgent {
359
364
  },
360
365
  });
361
366
 
362
- this.toolRegistry.register({
363
- name: 'extend_rounds',
364
- description: 'Extend the tool-call budget for the current turn.',
365
- parameters: [{
366
- name: 'n',
367
- type: 'number',
368
- description: 'Number of additional rounds to add (default 10)',
369
- required: false,
370
- }],
371
- handler: async (kwargs: Record<string, any>) => {
372
- const n = (kwargs.n as number) || 10;
373
- const old = this._maxToolRounds;
374
- this._maxToolRounds += n;
375
- return `✓ Tool-round limit extended by ${n} (was ${old}, now ${this._maxToolRounds}).`;
376
- },
377
- });
367
+ // Note: the old `extend_rounds` tool was removed — stopping is now
368
+ // progress-based (see chatStreamImpl), so there is no per-turn round budget
369
+ // to extend. A productive task simply keeps running.
378
370
 
379
371
  // ── Self-evolve tool: analyze failures and suggest prompt improvements ──
380
372
  this.toolRegistry.register({
@@ -967,8 +959,13 @@ export class BaseAgent {
967
959
 
968
960
  try {
969
961
  let fullContent = '';
970
- let roundLimit = this._maxToolRounds;
971
962
  let roundCount = 0;
963
+ // Progress-based stopping (OpenClaw-style): we don't cap by round count —
964
+ // a long task that keeps making progress runs as long as it needs. We
965
+ // stop when the agent makes no progress for several rounds in a row (pure
966
+ // spinning), or when the LoopGuard detects a genuine loop. The hard round
967
+ // cap is only a last-resort backstop against pathological runaway.
968
+ let consecutiveNoProgress = 0;
972
969
 
973
970
  while (true) {
974
971
  // User interrupt between rounds (Ctrl-C): stop before another LLM call.
@@ -984,16 +981,10 @@ export class BaseAgent {
984
981
  yield { type: 'done' };
985
982
  return;
986
983
  }
987
- if (roundCount >= roundLimit) {
988
- if (roundLimit >= this._maxToolRoundsHardCap) break;
989
- const extendBy = Math.min(15, this._maxToolRoundsHardCap - roundLimit);
990
- roundLimit += extendBy;
991
- this._maxToolRounds = roundLimit;
992
- this.memory.addMessage('system', `[Auto-extended tool-round limit by ${extendBy} to ${roundLimit}. Continue working.]`);
993
- continue;
994
- }
984
+ // Last-resort backstop: only trips in pathological cases the progress
985
+ // detector and LoopGuard both miss. Normal long tasks never reach it.
986
+ if (roundCount >= this._maxToolRoundsHardCap) break;
995
987
  roundCount++;
996
- roundLimit = Math.max(roundLimit, this._maxToolRounds);
997
988
 
998
989
  const messages = await this.messagesWithRecall();
999
990
  const toolNames = resolveToolNames();
@@ -1124,9 +1115,38 @@ export class BaseAgent {
1124
1115
  yield { type: 'done' };
1125
1116
  return;
1126
1117
  }
1118
+
1119
+ // ── Progress-based stopping ──
1120
+ // A round made progress if at least one tool call SUCCEEDED (state
1121
+ // advanced) or the model produced new text this round. Pure spinning —
1122
+ // no successful tool, no text — increments the no-progress counter; any
1123
+ // progress resets it. Stop only after several no-progress rounds in a
1124
+ // row, so a long, productive task is never cut off by a round count.
1125
+ const madeProgress =
1126
+ execResults.some(r => r.success && r.toolName !== 'task_done') ||
1127
+ roundContent.trim().length > 0;
1128
+ if (madeProgress) {
1129
+ consecutiveNoProgress = 0;
1130
+ } else {
1131
+ consecutiveNoProgress++;
1132
+ if (consecutiveNoProgress === this._maxNoProgressRounds - 1) {
1133
+ this.memory.addMessage('system',
1134
+ '[No progress] Your recent rounds produced no successful tool result and no text. Either take a concrete next action, output the final answer, or call task_done. One more empty round will end the turn.');
1135
+ }
1136
+ if (consecutiveNoProgress >= this._maxNoProgressRounds) {
1137
+ if (!assistantStored && fullContent.trim()) {
1138
+ this.memory.addMessage('assistant', fullContent);
1139
+ assistantStored = true;
1140
+ }
1141
+ await this.setState(AgentState.IDLE);
1142
+ yield { type: 'content', text: `\n\n[stalled] ${consecutiveNoProgress} rounds without progress — stopping.` };
1143
+ yield { type: 'done' };
1144
+ return;
1145
+ }
1146
+ }
1127
1147
  }
1128
1148
 
1129
- // Max iterations reached
1149
+ // Hard-cap backstop reached (pathological runaway only).
1130
1150
  if (!assistantStored) this.popLastUserMessage();
1131
1151
  await this.setState(AgentState.IDLE);
1132
1152
  if (!fullContent.trim() && delegations.length > 0) {
@@ -1136,7 +1156,7 @@ export class BaseAgent {
1136
1156
  }
1137
1157
  yield {
1138
1158
  type: 'truncated',
1139
- reason: `safety ceiling of ${this._maxToolRoundsHardCap} tool rounds reached — the task may be unfinished. Send "continue" to resume, or raise llm.max_tool_rounds in config (0 = unlimited).`,
1159
+ reason: `safety ceiling of ${this._maxToolRoundsHardCap} tool rounds reached — the task may be unfinished. Send "continue" to resume, or raise llm.max_tool_rounds_hard_cap in config.`,
1140
1160
  };
1141
1161
  yield { type: 'done' };
1142
1162
  } catch (e: any) {
@@ -1380,11 +1400,9 @@ export class BaseAgent {
1380
1400
  }
1381
1401
 
1382
1402
  protected async llmLoop(options?: {
1383
- maxIterations?: number;
1384
1403
  onStatus?: ((status: string) => void) | null;
1385
1404
  ephemeral?: boolean;
1386
1405
  }): Promise<LLMResponse> {
1387
- const maxIterations = options?.maxIterations ?? this._maxToolRounds;
1388
1406
  const ephemeral = options?.ephemeral ?? false;
1389
1407
  const onStatus = options?.onStatus ?? null;
1390
1408
 
@@ -1403,19 +1421,14 @@ export class BaseAgent {
1403
1421
  );
1404
1422
 
1405
1423
  try {
1406
- let limit = maxIterations;
1424
+ // Progress-based stopping (mirrors chatStreamImpl): no round-count limit
1425
+ // run while making progress, stop after _maxNoProgressRounds empty rounds.
1426
+ // The hard cap is only a last-resort backstop against runaway.
1407
1427
  let rounds = 0;
1428
+ let consecutiveNoProgress = 0;
1408
1429
  while (true) {
1409
- if (rounds >= limit) {
1410
- if (limit >= this._maxToolRoundsHardCap) break;
1411
- const extendBy = Math.min(15, this._maxToolRoundsHardCap - limit);
1412
- limit += extendBy;
1413
- this._maxToolRounds = limit;
1414
- this.memory.addMessage('system', `[Auto-extended limit by ${extendBy} to ${limit}.]`);
1415
- continue;
1416
- }
1430
+ if (rounds >= this._maxToolRoundsHardCap) break;
1417
1431
  rounds++;
1418
- limit = Math.max(limit, this._maxToolRounds);
1419
1432
 
1420
1433
  const messages = await this.messagesWithRecall();
1421
1434
  if (onStatus) onStatus('thinking...');
@@ -1441,13 +1454,21 @@ export class BaseAgent {
1441
1454
  });
1442
1455
 
1443
1456
  // ── Execute all tools via shared pipeline ──
1444
- await this.executeToolCalls(response.toolCalls, { dedupCacheable: true, onStatus: onStatus ?? undefined, ephemeral });
1457
+ const execResults = await this.executeToolCalls(response.toolCalls, { dedupCacheable: true, onStatus: onStatus ?? undefined, ephemeral });
1445
1458
  await this.setState(AgentState.THINKING);
1459
+
1460
+ // Progress check: any successful tool or produced text resets the
1461
+ // counter; otherwise count an empty round and stop after the threshold.
1462
+ const madeProgress =
1463
+ execResults.some(r => r.success && r.toolName !== 'task_done') ||
1464
+ (response.content || '').trim().length > 0;
1465
+ consecutiveNoProgress = madeProgress ? 0 : consecutiveNoProgress + 1;
1466
+ if (consecutiveNoProgress >= this._maxNoProgressRounds) break;
1446
1467
  }
1447
1468
 
1448
1469
  response.truncated = true;
1449
1470
  if (!response.content) {
1450
- response.content = `[truncated] max tool rounds (${maxIterations}) reached.`;
1471
+ response.content = `[stopped] no progress for ${this._maxNoProgressRounds} rounds (or backstop reached).`;
1451
1472
  }
1452
1473
  return response;
1453
1474
  } catch (e) {
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Feishu / Lark channel adapter.
3
+ *
4
+ * Inbound: the event-subscription webhook (v2 schema). Handles the
5
+ * url_verification challenge, optional AES-encrypted payloads, and the optional
6
+ * verification-token check, then normalizes im.message.receive_v1 events.
7
+ *
8
+ * Outbound: obtains a tenant_access_token (cached) and replies via the
9
+ * im/v1/messages API (text by default).
10
+ *
11
+ * Config (channels.feishu): { appId, appSecret, encryptKey?, verificationToken?,
12
+ * domain?: 'feishu'|'lark', agent? }. Env fallback: FEISHU_APP_ID,
13
+ * FEISHU_APP_SECRET, FEISHU_ENCRYPT_KEY, FEISHU_VERIFICATION_TOKEN.
14
+ */
15
+
16
+ import * as crypto from 'crypto';
17
+ import { getLogger } from '../../core/logger';
18
+ import { resolveSecret, postJson, TokenCache } from '../helpers';
19
+ import type { ChannelAdapter, RawRequest, ReplyTarget, WebhookOutcome } from '../types';
20
+
21
+ const log = getLogger('channel-feishu');
22
+
23
+ /** Decrypt a Feishu AES-256-CBC encrypted event body. */
24
+ export function decryptFeishu(encrypt: string, encryptKey: string): string {
25
+ const key = crypto.createHash('sha256').update(encryptKey).digest();
26
+ const data = Buffer.from(encrypt, 'base64');
27
+ const iv = data.subarray(0, 16);
28
+ const ciphertext = data.subarray(16);
29
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
30
+ decipher.setAutoPadding(false);
31
+ let out = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
32
+ // PKCS#7 unpad
33
+ const pad = out[out.length - 1];
34
+ if (pad > 0 && pad <= 16) out = out.subarray(0, out.length - pad);
35
+ return out.toString('utf8');
36
+ }
37
+
38
+ export function createFeishuAdapter(cfg: any, env: NodeJS.ProcessEnv): ChannelAdapter | null {
39
+ const appId = resolveSecret(cfg.appId, env, 'FEISHU_APP_ID');
40
+ const appSecret = resolveSecret(cfg.appSecret, env, 'FEISHU_APP_SECRET');
41
+ if (!appId || !appSecret) return null; // not configured
42
+
43
+ const encryptKey = resolveSecret(cfg.encryptKey, env, 'FEISHU_ENCRYPT_KEY');
44
+ const verificationToken = resolveSecret(cfg.verificationToken, env, 'FEISHU_VERIFICATION_TOKEN');
45
+ const base = cfg.domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
46
+
47
+ const tokenCache = new TokenCache(async () => {
48
+ const data = await postJson(`${base}/open-apis/auth/v3/tenant_access_token/internal`, {
49
+ app_id: appId, app_secret: appSecret,
50
+ });
51
+ if (data.code !== 0) throw new Error(`feishu token error ${data.code}: ${data.msg}`);
52
+ return { token: data.tenant_access_token, expiresInSec: data.expire ?? 7200 };
53
+ });
54
+
55
+ // De-dupe redelivered events (Feishu retries on slow ack).
56
+ const seen = new Set<string>();
57
+ const remember = (id: string): boolean => {
58
+ if (!id) return false;
59
+ if (seen.has(id)) return true;
60
+ seen.add(id);
61
+ if (seen.size > 2000) seen.clear();
62
+ return false;
63
+ };
64
+
65
+ return {
66
+ id: 'feishu',
67
+ name: 'Feishu/Lark',
68
+ defaultAgent: cfg.agent || 'fair',
69
+
70
+ async handleWebhook(req: RawRequest): Promise<WebhookOutcome> {
71
+ let payload: any;
72
+ try { payload = JSON.parse(req.body.toString('utf8') || '{}'); } catch { return { response: { status: 400, body: 'bad json' } }; }
73
+
74
+ // Encrypted transport: { encrypt: "..." } → decrypt to the real payload.
75
+ if (payload.encrypt) {
76
+ if (!encryptKey) return { response: { status: 400, body: 'encrypt key not configured' } };
77
+ try { payload = JSON.parse(decryptFeishu(payload.encrypt, encryptKey)); }
78
+ catch (e) { log.warn('feishu_decrypt_failed', { error: String(e) }); return { response: { status: 400, body: 'decrypt failed' } }; }
79
+ }
80
+
81
+ // URL verification handshake.
82
+ if (payload.type === 'url_verification') {
83
+ if (verificationToken && payload.token && payload.token !== verificationToken) {
84
+ return { response: { status: 403, body: 'bad token' } };
85
+ }
86
+ return { response: { status: 200, contentType: 'application/json', body: JSON.stringify({ challenge: payload.challenge }) } };
87
+ }
88
+
89
+ // Verification token check (v2 puts it in header.token).
90
+ const token = payload.header?.token ?? payload.token;
91
+ if (verificationToken && token && token !== verificationToken) {
92
+ return { response: { status: 403, body: 'bad token' } };
93
+ }
94
+
95
+ const eventId = payload.header?.event_id;
96
+ if (remember(eventId)) return {}; // duplicate redelivery
97
+
98
+ const eventType = payload.header?.event_type ?? payload.event?.type;
99
+ if (eventType !== 'im.message.receive_v1') return {}; // only handle message receipts
100
+
101
+ const message = payload.event?.message;
102
+ if (!message) return {};
103
+ const chatId = message.chat_id as string;
104
+ const msgType = message.message_type as string;
105
+ let text = '';
106
+ if (msgType === 'text') {
107
+ try { text = JSON.parse(message.content || '{}').text || ''; } catch { text = ''; }
108
+ // Strip @mentions like "@_user_1 ".
109
+ text = text.replace(/@_user_\d+/g, '').trim();
110
+ } else {
111
+ text = `[${msgType} 消息]`;
112
+ }
113
+ const senderId = payload.event?.sender?.sender_id?.open_id || payload.event?.sender?.sender_id?.user_id || 'unknown';
114
+
115
+ return {
116
+ message: {
117
+ channel: 'feishu',
118
+ conversationId: chatId || senderId,
119
+ userId: senderId,
120
+ text,
121
+ replyTo: { channel: 'feishu', chatId },
122
+ raw: payload,
123
+ },
124
+ };
125
+ },
126
+
127
+ async send(target: ReplyTarget, text: string): Promise<void> {
128
+ const chatId = target.chatId as string;
129
+ if (!chatId) return;
130
+ const token = await tokenCache.get();
131
+ const data = await postJson(
132
+ `${base}/open-apis/im/v1/messages?receive_id_type=chat_id`,
133
+ { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) },
134
+ { headers: { Authorization: `Bearer ${token}` } },
135
+ );
136
+ if (data.code !== 0) {
137
+ if (data.code === 99991663 || data.code === 99991661) tokenCache.invalidate(); // token expired
138
+ throw new Error(`feishu send error ${data.code}: ${data.msg}`);
139
+ }
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * QQ official-bot channel adapter (webhook mode, QQ 频道/群 机器人).
3
+ *
4
+ * Auth/crypto: QQ uses Ed25519. The signing seed is the bot secret repeated to
5
+ * 32 bytes. Two webhook concerns:
6
+ * - validation (op 13): the platform sends { d: { plain_token, event_ts } };
7
+ * we reply { plain_token, signature } where signature = ed25519(event_ts +
8
+ * plain_token).
9
+ * - event signature: each push carries X-Signature-Ed25519 (hex) and
10
+ * X-Signature-Timestamp; verify ed25519 over (timestamp + body).
11
+ *
12
+ * Inbound message events: GROUP_AT_MESSAGE_CREATE / C2C_MESSAGE_CREATE /
13
+ * AT_MESSAGE_CREATE. Outbound: getAppAccessToken (cached) then the v2 messages
14
+ * API (passive reply via msg_id). Config (channels.qq): { appId, secret,
15
+ * agent? }. Env fallback: QQ_BOT_APPID, QQ_BOT_SECRET.
16
+ */
17
+
18
+ import * as crypto from 'crypto';
19
+ import { getLogger } from '../../core/logger';
20
+ import { resolveSecret, postJson, TokenCache } from '../helpers';
21
+ import type { ChannelAdapter, RawRequest, ReplyTarget, WebhookOutcome } from '../types';
22
+
23
+ const log = getLogger('channel-qq');
24
+
25
+ const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
26
+
27
+ /** Repeat the bot secret to a 32-byte Ed25519 seed (QQ's scheme). */
28
+ export function qqSeed(secret: string): Buffer {
29
+ let s = secret;
30
+ while (s.length < 32) s = s + s;
31
+ return Buffer.from(s.slice(0, 32), 'utf8');
32
+ }
33
+
34
+ function privKeyFromSeed(seed: Buffer): crypto.KeyObject {
35
+ return crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), format: 'der', type: 'pkcs8' });
36
+ }
37
+
38
+ /** Sign `event_ts + plain_token` for the validation handshake; returns hex. */
39
+ export function qqSignValidation(secret: string, eventTs: string, plainToken: string): string {
40
+ const priv = privKeyFromSeed(qqSeed(secret));
41
+ return crypto.sign(null, Buffer.from(eventTs + plainToken, 'utf8'), priv).toString('hex');
42
+ }
43
+
44
+ /** Verify an event push signature (hex) over `timestamp + body`. */
45
+ export function qqVerify(secret: string, timestamp: string, body: Buffer, signatureHex: string): boolean {
46
+ try {
47
+ const pub = crypto.createPublicKey(privKeyFromSeed(qqSeed(secret)));
48
+ const msg = Buffer.concat([Buffer.from(timestamp, 'utf8'), body]);
49
+ return crypto.verify(null, msg, pub, Buffer.from(signatureHex, 'hex'));
50
+ } catch (e) {
51
+ log.warn('qq_verify_error', { error: String(e) });
52
+ return false;
53
+ }
54
+ }
55
+
56
+ export function createQQAdapter(cfg: any, env: NodeJS.ProcessEnv): ChannelAdapter | null {
57
+ const appId = resolveSecret(cfg.appId != null ? String(cfg.appId) : undefined, env, 'QQ_BOT_APPID');
58
+ const secret = resolveSecret(cfg.secret, env, 'QQ_BOT_SECRET');
59
+ if (!appId || !secret) return null;
60
+
61
+ const tokenCache = new TokenCache(async () => {
62
+ const data = await postJson('https://bots.qq.com/app/getAppAccessToken', {
63
+ appId, clientSecret: secret,
64
+ });
65
+ if (!data.access_token) throw new Error(`qq token error: ${JSON.stringify(data).slice(0, 120)}`);
66
+ return { token: data.access_token, expiresInSec: Number(data.expires_in) || 7200 };
67
+ });
68
+
69
+ const authHeaders = async () => ({ Authorization: `QQBot ${await tokenCache.get()}`, 'X-Union-Appid': appId });
70
+
71
+ return {
72
+ id: 'qq',
73
+ name: 'QQ Bot',
74
+ defaultAgent: cfg.agent || 'fair',
75
+
76
+ async handleWebhook(req: RawRequest): Promise<WebhookOutcome> {
77
+ let payload: any;
78
+ try { payload = JSON.parse(req.body.toString('utf8') || '{}'); } catch { return { response: { status: 400, body: 'bad json' } }; }
79
+
80
+ // Validation handshake (op 13) — no signature header on this one.
81
+ if (payload.op === 13 && payload.d?.plain_token && payload.d?.event_ts) {
82
+ const signature = qqSignValidation(secret, String(payload.d.event_ts), String(payload.d.plain_token));
83
+ return { response: { status: 200, contentType: 'application/json', body: JSON.stringify({ plain_token: payload.d.plain_token, signature }) } };
84
+ }
85
+
86
+ // Verify the event push signature.
87
+ const sig = (req.headers['x-signature-ed25519'] as string) || '';
88
+ const ts = (req.headers['x-signature-timestamp'] as string) || '';
89
+ if (sig && ts && !qqVerify(secret, ts, req.body, sig)) {
90
+ return { response: { status: 403, body: 'bad signature' } };
91
+ }
92
+
93
+ if (payload.op !== 0) return { response: { status: 200, body: '' } }; // not a dispatch
94
+
95
+ const t = payload.t as string;
96
+ const d = payload.d || {};
97
+ const content = String(d.content || '').replace(/<@!?\d+>/g, '').trim();
98
+ const msgId = d.id as string;
99
+
100
+ let replyTo: ReplyTarget | null = null;
101
+ if (t === 'GROUP_AT_MESSAGE_CREATE') replyTo = { channel: 'qq', kind: 'group', groupOpenid: d.group_openid, msgId };
102
+ else if (t === 'C2C_MESSAGE_CREATE') replyTo = { channel: 'qq', kind: 'c2c', userOpenid: d.author?.user_openid, msgId };
103
+ else if (t === 'AT_MESSAGE_CREATE' || t === 'MESSAGE_CREATE') replyTo = { channel: 'qq', kind: 'channel', channelId: d.channel_id, msgId };
104
+
105
+ if (!replyTo || !content) return { response: { status: 200, body: '' } };
106
+
107
+ const userId = d.author?.user_openid || d.author?.id || d.author?.member_openid || 'unknown';
108
+ return {
109
+ response: { status: 200, body: '' },
110
+ message: {
111
+ channel: 'qq',
112
+ conversationId: (replyTo.groupOpenid as string) || (replyTo.channelId as string) || (userId as string),
113
+ userId,
114
+ text: content,
115
+ replyTo,
116
+ raw: payload,
117
+ },
118
+ };
119
+ },
120
+
121
+ async send(target: ReplyTarget, text: string): Promise<void> {
122
+ const headers = { ...(await authHeaders()), 'Content-Type': 'application/json' };
123
+ const msgId = target.msgId as string | undefined;
124
+ const payload: any = { msg_type: 0, content: text };
125
+ if (msgId) payload.msg_id = msgId; // passive reply within the allowed window
126
+
127
+ let url: string;
128
+ if (target.kind === 'group') url = `https://api.sgroup.qq.com/v2/groups/${target.groupOpenid}/messages`;
129
+ else if (target.kind === 'c2c') url = `https://api.sgroup.qq.com/v2/users/${target.userOpenid}/messages`;
130
+ else url = `https://api.sgroup.qq.com/channels/${target.channelId}/messages`;
131
+
132
+ try {
133
+ await postJson(url, payload, { headers });
134
+ } catch (e: any) {
135
+ if (e?.response?.status === 401) tokenCache.invalidate();
136
+ throw new Error(`qq send error: ${e?.response?.status || ''} ${String(e?.message || e).slice(0, 120)}`);
137
+ }
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * WeCom (企业微信) channel adapter.
3
+ *
4
+ * Uses the official "receive messages" callback with the standard WeWork
5
+ * crypto: msg_signature = sha1(sort(token, timestamp, nonce, echostr|encrypt)),
6
+ * and AES-256-CBC (PKCS7) where the key is base64(EncodingAESKey + "=") and the
7
+ * plaintext is [16B random][4B big-endian msg-len][msg][receiveid].
8
+ *
9
+ * Inbound is XML. GET verifies the callback URL (echo the decrypted echostr);
10
+ * POST carries the encrypted message. We extract text from <Content>.
11
+ *
12
+ * Outbound uses the application message API (message/send) with the agent's
13
+ * gettoken. Config (channels.wecom): { corpId, corpSecret, token, encodingAesKey,
14
+ * agentId, agent? }. Env fallback: WECOM_CORP_ID, WECOM_CORP_SECRET,
15
+ * WECOM_TOKEN, WECOM_AES_KEY, WECOM_AGENT_ID.
16
+ */
17
+
18
+ import * as crypto from 'crypto';
19
+ import { getLogger } from '../../core/logger';
20
+ import { resolveSecret, postJson, getJson, TokenCache } from '../helpers';
21
+ import type { ChannelAdapter, RawRequest, ReplyTarget, WebhookOutcome } from '../types';
22
+
23
+ const log = getLogger('channel-wecom');
24
+
25
+ /** WeWork msg_signature: sha1 over the sorted concatenation. */
26
+ export function wecomSignature(token: string, timestamp: string, nonce: string, encrypt: string): string {
27
+ const arr = [token, timestamp, nonce, encrypt].sort();
28
+ return crypto.createHash('sha1').update(arr.join('')).digest('hex');
29
+ }
30
+
31
+ /** Decrypt a WeWork AES message. Returns { message, receiveId }. */
32
+ export function decryptWecom(encrypt: string, encodingAesKey: string): { message: string; receiveId: string } {
33
+ const key = Buffer.from(encodingAesKey + '=', 'base64'); // 32 bytes
34
+ const iv = key.subarray(0, 16);
35
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
36
+ decipher.setAutoPadding(false);
37
+ let decrypted = Buffer.concat([decipher.update(Buffer.from(encrypt, 'base64')), decipher.final()]);
38
+ // PKCS7 unpad
39
+ const pad = decrypted[decrypted.length - 1];
40
+ if (pad > 0 && pad <= 32) decrypted = decrypted.subarray(0, decrypted.length - pad);
41
+ // [16B random][4B msg len BE][msg][receiveid]
42
+ const content = decrypted.subarray(16);
43
+ const msgLen = content.readUInt32BE(0);
44
+ const message = content.subarray(4, 4 + msgLen).toString('utf8');
45
+ const receiveId = content.subarray(4 + msgLen).toString('utf8');
46
+ return { message, receiveId };
47
+ }
48
+
49
+ function xmlField(xml: string, tag: string): string {
50
+ const cdata = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`));
51
+ if (cdata) return cdata[1];
52
+ const plain = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
53
+ return plain ? plain[1] : '';
54
+ }
55
+
56
+ export function createWecomAdapter(cfg: any, env: NodeJS.ProcessEnv): ChannelAdapter | null {
57
+ const corpId = resolveSecret(cfg.corpId, env, 'WECOM_CORP_ID');
58
+ const corpSecret = resolveSecret(cfg.corpSecret, env, 'WECOM_CORP_SECRET');
59
+ const token = resolveSecret(cfg.token, env, 'WECOM_TOKEN');
60
+ const aesKey = resolveSecret(cfg.encodingAesKey, env, 'WECOM_AES_KEY');
61
+ const agentId = resolveSecret(cfg.agentId != null ? String(cfg.agentId) : undefined, env, 'WECOM_AGENT_ID');
62
+ if (!corpId || !corpSecret || !token || !aesKey) return null;
63
+
64
+ const tokenCache = new TokenCache(async () => {
65
+ const data = await getJson(
66
+ `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(corpId)}&corpsecret=${encodeURIComponent(corpSecret)}`,
67
+ );
68
+ if (data.errcode !== 0) throw new Error(`wecom token error ${data.errcode}: ${data.errmsg}`);
69
+ return { token: data.access_token, expiresInSec: data.expires_in ?? 7200 };
70
+ });
71
+
72
+ const verify = (req: RawRequest, encrypt: string): boolean => {
73
+ const sig = req.query.get('msg_signature') || '';
74
+ const ts = req.query.get('timestamp') || '';
75
+ const nonce = req.query.get('nonce') || '';
76
+ return sig === wecomSignature(token, ts, nonce, encrypt);
77
+ };
78
+
79
+ return {
80
+ id: 'wecom',
81
+ name: 'WeCom (企业微信)',
82
+ defaultAgent: cfg.agent || 'fair',
83
+
84
+ async handleWebhook(req: RawRequest): Promise<WebhookOutcome> {
85
+ // URL verification: GET with echostr.
86
+ if (req.method === 'GET') {
87
+ const echostr = req.query.get('echostr') || '';
88
+ if (!verify(req, echostr)) return { response: { status: 403, body: 'bad signature' } };
89
+ try {
90
+ const { message } = decryptWecom(echostr, aesKey);
91
+ return { response: { status: 200, body: message } };
92
+ } catch (e) {
93
+ log.warn('wecom_echostr_decrypt_failed', { error: String(e) });
94
+ return { response: { status: 400, body: 'decrypt failed' } };
95
+ }
96
+ }
97
+
98
+ // Message callback: POST with <Encrypt> XML.
99
+ const xml = req.body.toString('utf8');
100
+ const encrypt = xmlField(xml, 'Encrypt');
101
+ if (!encrypt) return { response: { status: 400, body: 'no encrypt' } };
102
+ if (!verify(req, encrypt)) return { response: { status: 403, body: 'bad signature' } };
103
+
104
+ let inner: string;
105
+ try { inner = decryptWecom(encrypt, aesKey).message; }
106
+ catch (e) { log.warn('wecom_decrypt_failed', { error: String(e) }); return { response: { status: 400, body: 'decrypt failed' } }; }
107
+
108
+ const msgType = xmlField(inner, 'MsgType');
109
+ const fromUser = xmlField(inner, 'FromUserName');
110
+ let text = '';
111
+ if (msgType === 'text') text = xmlField(inner, 'Content').trim();
112
+ else text = `[${msgType} 消息]`;
113
+
114
+ // Ack the callback immediately (empty 200); reply is pushed via the API.
115
+ return {
116
+ response: { status: 200, body: '' },
117
+ message: text ? {
118
+ channel: 'wecom',
119
+ conversationId: fromUser,
120
+ userId: fromUser,
121
+ text,
122
+ replyTo: { channel: 'wecom', toUser: fromUser },
123
+ raw: inner,
124
+ } : undefined,
125
+ };
126
+ },
127
+
128
+ async send(target: ReplyTarget, text: string): Promise<void> {
129
+ const toUser = target.toUser as string;
130
+ if (!toUser || !agentId) return;
131
+ const accessToken = await tokenCache.get();
132
+ const data = await postJson(
133
+ `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`,
134
+ { touser: toUser, msgtype: 'text', agentid: Number(agentId), text: { content: text } },
135
+ );
136
+ if (data.errcode !== 0) {
137
+ if (data.errcode === 42001 || data.errcode === 40014) tokenCache.invalidate();
138
+ throw new Error(`wecom send error ${data.errcode}: ${data.errmsg}`);
139
+ }
140
+ },
141
+ };
142
+ }