skyloom 1.20.0 → 1.22.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 (41) hide show
  1. package/README.md +46 -0
  2. package/dist/cli/main.js +3 -0
  3. package/dist/cli/main.js.map +1 -1
  4. package/dist/gateway/channels/feishu.d.ts +19 -0
  5. package/dist/gateway/channels/feishu.d.ts.map +1 -0
  6. package/dist/gateway/channels/feishu.js +290 -0
  7. package/dist/gateway/channels/feishu.js.map +1 -0
  8. package/dist/gateway/channels/qq.d.ts +25 -0
  9. package/dist/gateway/channels/qq.d.ts.map +1 -0
  10. package/dist/gateway/channels/qq.js +187 -0
  11. package/dist/gateway/channels/qq.js.map +1 -0
  12. package/dist/gateway/channels/wecom.d.ts +26 -0
  13. package/dist/gateway/channels/wecom.d.ts.map +1 -0
  14. package/dist/gateway/channels/wecom.js +196 -0
  15. package/dist/gateway/channels/wecom.js.map +1 -0
  16. package/dist/gateway/gateway.d.ts +19 -0
  17. package/dist/gateway/gateway.d.ts.map +1 -0
  18. package/dist/gateway/gateway.js +177 -0
  19. package/dist/gateway/gateway.js.map +1 -0
  20. package/dist/gateway/helpers.d.ts +39 -0
  21. package/dist/gateway/helpers.d.ts.map +1 -0
  22. package/dist/gateway/helpers.js +81 -0
  23. package/dist/gateway/helpers.js.map +1 -0
  24. package/dist/gateway/registry.d.ts +12 -0
  25. package/dist/gateway/registry.d.ts.map +1 -0
  26. package/dist/gateway/registry.js +44 -0
  27. package/dist/gateway/registry.js.map +1 -0
  28. package/dist/gateway/types.d.ts +104 -0
  29. package/dist/gateway/types.d.ts.map +1 -0
  30. package/dist/gateway/types.js +26 -0
  31. package/dist/gateway/types.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/cli/main.ts +3 -0
  34. package/src/gateway/channels/feishu.ts +242 -0
  35. package/src/gateway/channels/qq.ts +151 -0
  36. package/src/gateway/channels/wecom.ts +151 -0
  37. package/src/gateway/gateway.ts +178 -0
  38. package/src/gateway/helpers.ts +82 -0
  39. package/src/gateway/registry.ts +45 -0
  40. package/src/gateway/types.ts +125 -0
  41. package/tests/gateway.test.ts +293 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Channel gateway contract — the minimal, channel-agnostic interface every
3
+ * messaging integration (Feishu / WeCom / QQ / …) implements.
4
+ *
5
+ * Distilled from OpenClaw's channel architecture (which spans ~250 files per
6
+ * channel) down to the essence: a channel receives an inbound webhook, the
7
+ * gateway normalizes it to an InboundMessage, routes it to an agent, and the
8
+ * channel sends the reply back out. Signature verification and the platform's
9
+ * URL-verification handshake live behind `handleWebhook`, so the gateway core
10
+ * stays platform-neutral.
11
+ */
12
+ import type { IncomingHttpHeaders } from 'http';
13
+ /** A normalized inbound message from any channel. */
14
+ export interface InboundMessage {
15
+ /** Channel id, e.g. 'feishu'. */
16
+ channel: string;
17
+ /** Stable conversation/session key (chat id or user id) for memory routing. */
18
+ conversationId: string;
19
+ /** Platform user id of the sender. */
20
+ userId: string;
21
+ /** Plain text the user sent (media/stripped to text where possible). */
22
+ text: string;
23
+ /** Where to send the reply (channel-specific opaque target). */
24
+ replyTo: ReplyTarget;
25
+ /** Media attachments on this message (image / audio / file / …). */
26
+ media?: MediaAttachment[];
27
+ /** Raw event for adapters that need more than the normalized fields. */
28
+ raw?: unknown;
29
+ }
30
+ /** A non-text attachment, normalized across channels. */
31
+ export interface MediaAttachment {
32
+ kind: 'image' | 'audio' | 'video' | 'file' | 'sticker' | 'other';
33
+ /** Channel-specific id/key used to fetch the binary (image_key, media_id, url…). */
34
+ ref?: string;
35
+ /** Original filename, when the platform provides one. */
36
+ filename?: string;
37
+ /** MIME type, when known. */
38
+ mimeType?: string;
39
+ /** Direct URL, when the platform provides one. */
40
+ url?: string;
41
+ }
42
+ /** Render a media list into a compact, model-readable description line. */
43
+ export declare function describeMedia(media: MediaAttachment[] | undefined): string;
44
+ /** Opaque, channel-specific destination for an outbound reply. */
45
+ export interface ReplyTarget {
46
+ channel: string;
47
+ [key: string]: unknown;
48
+ }
49
+ /**
50
+ * The result of an adapter handling a raw webhook request. Exactly one of:
51
+ * - `response`: reply immediately to the HTTP request (URL verification
52
+ * challenge, ack, or signature failure) and do NOT route to an agent.
53
+ * - `message`: a normalized inbound message to route to an agent. `response`
54
+ * may also be set to ack the webhook synchronously (most platforms require a
55
+ * fast 200) while the agent reply is delivered asynchronously via `send`.
56
+ * - neither: nothing to do (duplicate/ignored event); gateway returns 200.
57
+ */
58
+ export interface WebhookOutcome {
59
+ response?: HttpResponse;
60
+ message?: InboundMessage;
61
+ }
62
+ export interface HttpResponse {
63
+ status: number;
64
+ body?: string;
65
+ contentType?: string;
66
+ }
67
+ /** The raw HTTP request passed to an adapter's webhook handler. */
68
+ export interface RawRequest {
69
+ method: string;
70
+ headers: IncomingHttpHeaders;
71
+ query: URLSearchParams;
72
+ body: Buffer;
73
+ }
74
+ /** A channel integration. Constructed from its config block by a factory. */
75
+ export interface ChannelAdapter {
76
+ /** Channel id (matches the /webhook/<id> route and config key). */
77
+ readonly id: string;
78
+ /** Human label for logs/status. */
79
+ readonly name: string;
80
+ /** Default agent to route this channel's messages to (config override wins). */
81
+ readonly defaultAgent?: string;
82
+ /** Optional one-time setup (token prefetch, websocket connect, …). */
83
+ start?(): Promise<void>;
84
+ /** Optional teardown on gateway shutdown. */
85
+ stop?(): Promise<void>;
86
+ /**
87
+ * Handle a raw webhook request: verify signature, answer the platform's
88
+ * URL-verification handshake, decrypt if needed, and either return an
89
+ * immediate HTTP response and/or a normalized message to route.
90
+ */
91
+ handleWebhook(req: RawRequest): Promise<WebhookOutcome>;
92
+ /** Send a text reply back to the channel. */
93
+ send(target: ReplyTarget, text: string): Promise<void>;
94
+ /**
95
+ * Optional streaming reply: consume the agent's text chunks and render them
96
+ * progressively (e.g. a Feishu card patched as text accumulates). When an
97
+ * adapter implements this, the gateway prefers it over `send`. Implementations
98
+ * should throttle their own updates and tolerate an empty/aborted stream.
99
+ */
100
+ sendStreaming?(target: ReplyTarget, chunks: AsyncIterable<string>): Promise<void>;
101
+ }
102
+ /** Factory signature: build an adapter from its config block (or null if disabled/misconfigured). */
103
+ export type ChannelFactory = (cfg: any, env: NodeJS.ProcessEnv) => ChannelAdapter | null;
104
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/gateway/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAEhD,qDAAqD;AACrD,MAAM,WAAW,cAAc;IAC7B,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,OAAO,EAAE,WAAW,CAAC;IACrB,oEAAoE;IACpE,KAAK,CAAC,EAAE,eAAe,EAAE,CAAC;IAC1B,wEAAwE;IACxE,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,yDAAyD;AACzD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;IACjE,oFAAoF;IACpF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6BAA6B;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,2EAA2E;AAC3E,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,SAAS,GAAG,MAAM,CAQ1E;AAED,kEAAkE;AAClE,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,mEAAmE;AACnE,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,mBAAmB,CAAC;IAC7B,KAAK,EAAE,eAAe,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,mEAAmE;IACnE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,mCAAmC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAE/B,sEAAsE;IACtE,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,6CAA6C;IAC7C,IAAI,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvB;;;;OAIG;IACH,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAExD,6CAA6C;IAC7C,IAAI,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvD;;;;;OAKG;IACH,aAAa,CAAC,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnF;AAED,qGAAqG;AACrG,MAAM,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,KAAK,cAAc,GAAG,IAAI,CAAC"}
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ /**
3
+ * Channel gateway contract — the minimal, channel-agnostic interface every
4
+ * messaging integration (Feishu / WeCom / QQ / …) implements.
5
+ *
6
+ * Distilled from OpenClaw's channel architecture (which spans ~250 files per
7
+ * channel) down to the essence: a channel receives an inbound webhook, the
8
+ * gateway normalizes it to an InboundMessage, routes it to an agent, and the
9
+ * channel sends the reply back out. Signature verification and the platform's
10
+ * URL-verification handshake live behind `handleWebhook`, so the gateway core
11
+ * stays platform-neutral.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.describeMedia = describeMedia;
15
+ /** Render a media list into a compact, model-readable description line. */
16
+ function describeMedia(media) {
17
+ if (!media || media.length === 0)
18
+ return '';
19
+ const parts = media.map((m) => {
20
+ const label = m.filename || m.ref || m.url || '';
21
+ const tag = label ? `${m.kind}: ${label}` : m.kind;
22
+ return `[${tag}]`;
23
+ });
24
+ return parts.join(' ');
25
+ }
26
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/gateway/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;;AAoCH,sCAQC;AATD,2EAA2E;AAC3E,SAAgB,aAAa,CAAC,KAAoC;IAChE,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAC5C,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QAC5B,MAAM,KAAK,GAAG,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,IAAI,EAAE,CAAC;QACjD,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACnD,OAAO,IAAI,GAAG,GAAG,CAAC;IACpB,CAAC,CAAC,CAAC;IACH,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACzB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skyloom",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "description": "天空织机 Skyloom — 6 weather-themed AI agents: Fog, Rain, Frost, Snow, Dew, Fair",
5
5
  "preferGlobal": true,
6
6
  "type": "commonjs",
package/src/cli/main.ts CHANGED
@@ -35,6 +35,9 @@ program.command("task").argument("[goal]", "task goal")
35
35
  program.command("web").option("-p,--port <p>", "port", "7777")
36
36
  .action((o: { port?: string }) => { import("../web/server").then(m => m.startWebServer(parseInt(o.port || "7777"))); });
37
37
  program.command("mcp").action(() => { import("../core/mcp_server").then(m => m.startMCPServer()); });
38
+ program.command("gateway").description("Run the channel gateway (Feishu / WeCom / QQ)")
39
+ .option("-p,--port <p>", "port", "8848")
40
+ .action((o: { port?: string }) => { import("../gateway/gateway").then(m => m.startGateway({ port: parseInt(o.port || "8848") })); });
38
41
  program.command("config").action(() => { const c = loadConfig(); process.stdout.write(chalk.cyan("\nConfig: ") + USER_CONFIG_DIR + "\n"); for (const [n, a] of Object.entries(c.agents || {})) process.stdout.write(` ${chalk.bold(n)}: ${(a as any).model || "default"}\n`); });
39
42
  program.command("init").action(() => { if (!fs.existsSync(USER_CONFIG_DIR)) fs.mkdirSync(USER_CONFIG_DIR, { recursive: true }); process.stdout.write(chalk.green("✓ ") + USER_CONFIG_DIR + "\n"); });
40
43
  program.command("apikey").description("Manage API keys (persisted to ~/.skyloom/config.yaml)")
@@ -0,0 +1,242 @@
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, MediaAttachment, 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
+ // 'card' replies render as an interactive card (supports streaming patches);
47
+ // 'raw' forces plain text; 'auto' (default) uses a card so streaming works.
48
+ const renderMode: 'auto' | 'raw' | 'card' = cfg.renderMode || 'auto';
49
+ const useCard = renderMode === 'card' || renderMode === 'auto';
50
+
51
+ const tokenCache = new TokenCache(async () => {
52
+ const data = await postJson(`${base}/open-apis/auth/v3/tenant_access_token/internal`, {
53
+ app_id: appId, app_secret: appSecret,
54
+ });
55
+ if (data.code !== 0) throw new Error(`feishu token error ${data.code}: ${data.msg}`);
56
+ return { token: data.tenant_access_token, expiresInSec: data.expire ?? 7200 };
57
+ });
58
+
59
+ const authHeader = async () => ({ Authorization: `Bearer ${await tokenCache.get()}` });
60
+ const onTokenError = (code: number) => { if (code === 99991663 || code === 99991661) tokenCache.invalidate(); };
61
+
62
+ /** A minimal interactive card carrying a single markdown body. */
63
+ const cardContent = (text: string): string => JSON.stringify({
64
+ config: { wide_screen_mode: true, update_multi: true },
65
+ elements: [{ tag: 'markdown', content: text || ' ' }],
66
+ });
67
+
68
+ /** Create a card message in a chat; returns its message_id for later patches. */
69
+ const createCard = async (chatId: string, text: string): Promise<string | null> => {
70
+ const data = await postJson(
71
+ `${base}/open-apis/im/v1/messages?receive_id_type=chat_id`,
72
+ { receive_id: chatId, msg_type: 'interactive', content: cardContent(text) },
73
+ { headers: await authHeader() },
74
+ );
75
+ if (data.code !== 0) { onTokenError(data.code); throw new Error(`feishu card create ${data.code}: ${data.msg}`); }
76
+ return data.data?.message_id || null;
77
+ };
78
+
79
+ /** Patch an existing card message with new content. */
80
+ const patchCard = async (messageId: string, text: string): Promise<void> => {
81
+ const data = await postJson(
82
+ `${base}/open-apis/im/v1/messages/${messageId}`,
83
+ { content: cardContent(text) },
84
+ { headers: await authHeader() },
85
+ ).catch((e) => ({ code: -1, msg: String(e) }));
86
+ if (data && data.code !== 0) onTokenError(data.code);
87
+ };
88
+
89
+ // De-dupe redelivered events (Feishu retries on slow ack).
90
+ const seen = new Set<string>();
91
+ const remember = (id: string): boolean => {
92
+ if (!id) return false;
93
+ if (seen.has(id)) return true;
94
+ seen.add(id);
95
+ if (seen.size > 2000) seen.clear();
96
+ return false;
97
+ };
98
+
99
+ return {
100
+ id: 'feishu',
101
+ name: 'Feishu/Lark',
102
+ defaultAgent: cfg.agent || 'fair',
103
+
104
+ async handleWebhook(req: RawRequest): Promise<WebhookOutcome> {
105
+ let payload: any;
106
+ try { payload = JSON.parse(req.body.toString('utf8') || '{}'); } catch { return { response: { status: 400, body: 'bad json' } }; }
107
+
108
+ // Encrypted transport: { encrypt: "..." } → decrypt to the real payload.
109
+ if (payload.encrypt) {
110
+ if (!encryptKey) return { response: { status: 400, body: 'encrypt key not configured' } };
111
+ try { payload = JSON.parse(decryptFeishu(payload.encrypt, encryptKey)); }
112
+ catch (e) { log.warn('feishu_decrypt_failed', { error: String(e) }); return { response: { status: 400, body: 'decrypt failed' } }; }
113
+ }
114
+
115
+ // URL verification handshake.
116
+ if (payload.type === 'url_verification') {
117
+ if (verificationToken && payload.token && payload.token !== verificationToken) {
118
+ return { response: { status: 403, body: 'bad token' } };
119
+ }
120
+ return { response: { status: 200, contentType: 'application/json', body: JSON.stringify({ challenge: payload.challenge }) } };
121
+ }
122
+
123
+ // Verification token check (v2 puts it in header.token).
124
+ const token = payload.header?.token ?? payload.token;
125
+ if (verificationToken && token && token !== verificationToken) {
126
+ return { response: { status: 403, body: 'bad token' } };
127
+ }
128
+
129
+ const eventId = payload.header?.event_id;
130
+ if (remember(eventId)) return {}; // duplicate redelivery
131
+
132
+ const eventType = payload.header?.event_type ?? payload.event?.type;
133
+ if (eventType !== 'im.message.receive_v1') return {}; // only handle message receipts
134
+
135
+ const message = payload.event?.message;
136
+ if (!message) return {};
137
+ const chatId = message.chat_id as string;
138
+ const msgType = message.message_type as string;
139
+ let text = '';
140
+ const media: MediaAttachment[] = [];
141
+ let content: any = {};
142
+ try { content = JSON.parse(message.content || '{}'); } catch { /* ignore */ }
143
+ switch (msgType) {
144
+ case 'text':
145
+ text = (content.text || '').replace(/@_user_\d+/g, '').trim(); // strip @mentions
146
+ break;
147
+ case 'image':
148
+ media.push({ kind: 'image', ref: content.image_key });
149
+ break;
150
+ case 'audio':
151
+ media.push({ kind: 'audio', ref: content.file_key });
152
+ break;
153
+ case 'media': // short video
154
+ media.push({ kind: 'video', ref: content.file_key, filename: content.file_name });
155
+ break;
156
+ case 'file':
157
+ media.push({ kind: 'file', ref: content.file_key, filename: content.file_name });
158
+ break;
159
+ case 'sticker':
160
+ media.push({ kind: 'sticker', ref: content.file_key });
161
+ break;
162
+ case 'post': { // rich text: pull plain text + embedded images
163
+ const blocks = content?.content;
164
+ if (Array.isArray(blocks)) {
165
+ for (const row of blocks) {
166
+ for (const el of row || []) {
167
+ if (el?.tag === 'text' && el.text) text += el.text;
168
+ else if (el?.tag === 'a' && el.text) text += el.text;
169
+ else if (el?.tag === 'img' && el.image_key) media.push({ kind: 'image', ref: el.image_key });
170
+ }
171
+ text += '\n';
172
+ }
173
+ }
174
+ text = text.trim();
175
+ break;
176
+ }
177
+ default:
178
+ text = `[${msgType} 消息]`;
179
+ }
180
+ const senderId = payload.event?.sender?.sender_id?.open_id || payload.event?.sender?.sender_id?.user_id || 'unknown';
181
+
182
+ return {
183
+ message: {
184
+ channel: 'feishu',
185
+ conversationId: chatId || senderId,
186
+ userId: senderId,
187
+ text,
188
+ media: media.length ? media : undefined,
189
+ replyTo: { channel: 'feishu', chatId },
190
+ raw: payload,
191
+ },
192
+ };
193
+ },
194
+
195
+ async send(target: ReplyTarget, text: string): Promise<void> {
196
+ const chatId = target.chatId as string;
197
+ if (!chatId) return;
198
+ if (useCard) { await createCard(chatId, text); return; }
199
+ const data = await postJson(
200
+ `${base}/open-apis/im/v1/messages?receive_id_type=chat_id`,
201
+ { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) },
202
+ { headers: await authHeader() },
203
+ );
204
+ if (data.code !== 0) { onTokenError(data.code); throw new Error(`feishu send error ${data.code}: ${data.msg}`); }
205
+ },
206
+
207
+ // Streaming reply: post a placeholder card, then patch it as text arrives —
208
+ // throttled (≥600ms apart) to stay well under Feishu's update rate limit.
209
+ async sendStreaming(target: ReplyTarget, chunks: AsyncIterable<string>): Promise<void> {
210
+ const chatId = target.chatId as string;
211
+ if (!chatId) return;
212
+ if (!useCard) { // plain-text mode can't patch; collect then send once
213
+ let all = '';
214
+ for await (const c of chunks) all += c;
215
+ await this.send(target, all.trim() || '(无回复)');
216
+ return;
217
+ }
218
+ let messageId: string | null = null;
219
+ let acc = '';
220
+ let lastPatch = 0;
221
+ let dirty = false;
222
+ const MIN_INTERVAL = 600;
223
+ try {
224
+ messageId = await createCard(chatId, '思考中…');
225
+ } catch (e) { log.warn('feishu_card_create_failed', { error: String(e) }); return; }
226
+ if (!messageId) return;
227
+
228
+ for await (const chunk of chunks) {
229
+ acc += chunk;
230
+ dirty = true;
231
+ const now = Date.now();
232
+ if (now - lastPatch >= MIN_INTERVAL) {
233
+ lastPatch = now;
234
+ dirty = false;
235
+ await patchCard(messageId, acc);
236
+ }
237
+ }
238
+ // Final flush so the last tokens always land.
239
+ if (dirty || acc) await patchCard(messageId, acc.trim() || '(无回复)');
240
+ },
241
+ };
242
+ }
@@ -0,0 +1,151 @@
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, MediaAttachment, 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
+ // QQ delivers images/files as an attachments array on the event.
106
+ const media: MediaAttachment[] = [];
107
+ for (const att of (Array.isArray(d.attachments) ? d.attachments : [])) {
108
+ const ct = String(att?.content_type || '');
109
+ const kind: MediaAttachment['kind'] = ct.startsWith('image') ? 'image'
110
+ : ct.startsWith('audio') || ct.startsWith('voice') ? 'audio'
111
+ : ct.startsWith('video') ? 'video' : 'file';
112
+ media.push({ kind, ref: att?.id, filename: att?.filename, mimeType: att?.content_type, url: att?.url });
113
+ }
114
+
115
+ if (!replyTo || (!content && media.length === 0)) return { response: { status: 200, body: '' } };
116
+
117
+ const userId = d.author?.user_openid || d.author?.id || d.author?.member_openid || 'unknown';
118
+ return {
119
+ response: { status: 200, body: '' },
120
+ message: {
121
+ channel: 'qq',
122
+ conversationId: (replyTo.groupOpenid as string) || (replyTo.channelId as string) || (userId as string),
123
+ userId,
124
+ text: content,
125
+ media: media.length ? media : undefined,
126
+ replyTo,
127
+ raw: payload,
128
+ },
129
+ };
130
+ },
131
+
132
+ async send(target: ReplyTarget, text: string): Promise<void> {
133
+ const headers = { ...(await authHeaders()), 'Content-Type': 'application/json' };
134
+ const msgId = target.msgId as string | undefined;
135
+ const payload: any = { msg_type: 0, content: text };
136
+ if (msgId) payload.msg_id = msgId; // passive reply within the allowed window
137
+
138
+ let url: string;
139
+ if (target.kind === 'group') url = `https://api.sgroup.qq.com/v2/groups/${target.groupOpenid}/messages`;
140
+ else if (target.kind === 'c2c') url = `https://api.sgroup.qq.com/v2/users/${target.userOpenid}/messages`;
141
+ else url = `https://api.sgroup.qq.com/channels/${target.channelId}/messages`;
142
+
143
+ try {
144
+ await postJson(url, payload, { headers });
145
+ } catch (e: any) {
146
+ if (e?.response?.status === 401) tokenCache.invalidate();
147
+ throw new Error(`qq send error: ${e?.response?.status || ''} ${String(e?.message || e).slice(0, 120)}`);
148
+ }
149
+ },
150
+ };
151
+ }