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.
- package/README.md +52 -6
- package/dist/cli/main.js +3 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts +4 -1
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +83 -64
- package/dist/core/agent.js.map +1 -1
- package/dist/gateway/channels/feishu.d.ts +19 -0
- package/dist/gateway/channels/feishu.d.ts.map +1 -0
- package/dist/gateway/channels/feishu.js +186 -0
- package/dist/gateway/channels/feishu.js.map +1 -0
- package/dist/gateway/channels/qq.d.ts +25 -0
- package/dist/gateway/channels/qq.d.ts.map +1 -0
- package/dist/gateway/channels/qq.js +177 -0
- package/dist/gateway/channels/qq.js.map +1 -0
- package/dist/gateway/channels/wecom.d.ts +26 -0
- package/dist/gateway/channels/wecom.d.ts.map +1 -0
- package/dist/gateway/channels/wecom.js +177 -0
- package/dist/gateway/channels/wecom.js.map +1 -0
- package/dist/gateway/gateway.d.ts +19 -0
- package/dist/gateway/gateway.d.ts.map +1 -0
- package/dist/gateway/gateway.js +152 -0
- package/dist/gateway/gateway.js.map +1 -0
- package/dist/gateway/helpers.d.ts +39 -0
- package/dist/gateway/helpers.d.ts.map +1 -0
- package/dist/gateway/helpers.js +81 -0
- package/dist/gateway/helpers.js.map +1 -0
- package/dist/gateway/registry.d.ts +12 -0
- package/dist/gateway/registry.d.ts.map +1 -0
- package/dist/gateway/registry.js +44 -0
- package/dist/gateway/registry.js.map +1 -0
- package/dist/gateway/types.d.ts +81 -0
- package/dist/gateway/types.d.ts.map +1 -0
- package/dist/gateway/types.js +14 -0
- package/dist/gateway/types.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/main.ts +3 -0
- package/src/core/agent.ts +83 -62
- package/src/gateway/channels/feishu.ts +142 -0
- package/src/gateway/channels/qq.ts +140 -0
- package/src/gateway/channels/wecom.ts +142 -0
- package/src/gateway/gateway.ts +151 -0
- package/src/gateway/helpers.ts +82 -0
- package/src/gateway/registry.ts +45 -0
- package/src/gateway/types.ts +91 -0
- package/tests/agent.test.ts +45 -19
- 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
|
-
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// config.llm.
|
|
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
|
|
147
|
-
if (Number.isFinite(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
this._maxToolRoundsHardCap =
|
|
151
|
-
} else
|
|
152
|
-
|
|
153
|
-
this.
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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 >=
|
|
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 = `[
|
|
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
|
+
}
|