skyloom 1.20.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 (41) hide show
  1. package/README.md +42 -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 +186 -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 +177 -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 +177 -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 +152 -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 +81 -0
  29. package/dist/gateway/types.d.ts.map +1 -0
  30. package/dist/gateway/types.js +14 -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 +142 -0
  35. package/src/gateway/channels/qq.ts +140 -0
  36. package/src/gateway/channels/wecom.ts +142 -0
  37. package/src/gateway/gateway.ts +151 -0
  38. package/src/gateway/helpers.ts +82 -0
  39. package/src/gateway/registry.ts +45 -0
  40. package/src/gateway/types.ts +91 -0
  41. package/tests/gateway.test.ts +221 -0
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * Shared helpers for channel adapters: secret resolution (config value or env
4
+ * fallback), and a tiny JSON HTTP client. Kept dependency-light (axios is
5
+ * already a project dep) and injectable-free — adapters call these directly.
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.TokenCache = void 0;
12
+ exports.resolveSecret = resolveSecret;
13
+ exports.postJson = postJson;
14
+ exports.getJson = getJson;
15
+ const axios_1 = __importDefault(require("axios"));
16
+ /**
17
+ * Resolve a secret/config value. Accepts a literal string, or an env-ref object
18
+ * `{ source: 'env', id: 'NAME' }` (OpenClaw-compatible), falling back to the
19
+ * given env var name. Returns undefined if unresolved.
20
+ */
21
+ function resolveSecret(value, env, envFallback) {
22
+ if (typeof value === 'string' && value.trim())
23
+ return value.trim();
24
+ if (value && typeof value === 'object') {
25
+ const v = value;
26
+ if (v.source === 'env' && typeof v.id === 'string') {
27
+ const got = env[v.id];
28
+ if (got && got.trim())
29
+ return got.trim();
30
+ }
31
+ }
32
+ if (envFallback) {
33
+ const got = env[envFallback];
34
+ if (got && got.trim())
35
+ return got.trim();
36
+ }
37
+ return undefined;
38
+ }
39
+ /** POST JSON, return parsed JSON. Throws on non-2xx. */
40
+ async function postJson(url, body, opts) {
41
+ const res = await axios_1.default.post(url, body, {
42
+ headers: { 'Content-Type': 'application/json', ...(opts?.headers || {}) },
43
+ timeout: opts?.timeoutMs ?? 15000,
44
+ validateStatus: (s) => s >= 200 && s < 300,
45
+ });
46
+ return res.data;
47
+ }
48
+ /** GET JSON, return parsed JSON. Throws on non-2xx. */
49
+ async function getJson(url, opts) {
50
+ const res = await axios_1.default.get(url, {
51
+ headers: { Accept: 'application/json', ...(opts?.headers || {}) },
52
+ timeout: opts?.timeoutMs ?? 15000,
53
+ validateStatus: (s) => s >= 200 && s < 300,
54
+ });
55
+ return res.data;
56
+ }
57
+ /**
58
+ * A small token cache: fetch an access token via `fetcher`, cache it until it
59
+ * is near expiry, and refresh transparently. Channels (Feishu/WeCom) all need
60
+ * a short-lived tenant/access token; this avoids re-fetching per message.
61
+ */
62
+ class TokenCache {
63
+ constructor(fetcher) {
64
+ this.fetcher = fetcher;
65
+ this.token = null;
66
+ this.expiresAt = 0;
67
+ }
68
+ async get() {
69
+ const now = Date.now();
70
+ if (this.token && now < this.expiresAt - 60000)
71
+ return this.token;
72
+ const { token, expiresInSec } = await this.fetcher();
73
+ this.token = token;
74
+ this.expiresAt = now + Math.max(60, expiresInSec) * 1000;
75
+ return token;
76
+ }
77
+ /** Force the next get() to refetch (e.g. after a 401). */
78
+ invalidate() { this.token = null; this.expiresAt = 0; }
79
+ }
80
+ exports.TokenCache = TokenCache;
81
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.js","sourceRoot":"","sources":["../../src/gateway/helpers.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Channel registry — maps channel ids to their adapter factories and builds the
3
+ * set of enabled adapters from the `channels` config block.
4
+ *
5
+ * A channel is enabled when its config block exists and is not `enabled: false`
6
+ * and the factory can resolve its required credentials (from config or env).
7
+ */
8
+ import type { ChannelAdapter } from './types';
9
+ /** Build all enabled, well-configured adapters from the channels config. */
10
+ export declare function buildAdapters(channelsCfg: Record<string, any>, env: NodeJS.ProcessEnv): Map<string, ChannelAdapter>;
11
+ export declare const SUPPORTED_CHANNELS: string[];
12
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/gateway/registry.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAkB,MAAM,SAAS,CAAC;AAa9D,4EAA4E;AAC5E,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAChC,GAAG,EAAE,MAAM,CAAC,UAAU,GACrB,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAgB7B;AAED,eAAO,MAAM,kBAAkB,UAAyB,CAAC"}
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ /**
3
+ * Channel registry — maps channel ids to their adapter factories and builds the
4
+ * set of enabled adapters from the `channels` config block.
5
+ *
6
+ * A channel is enabled when its config block exists and is not `enabled: false`
7
+ * and the factory can resolve its required credentials (from config or env).
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.SUPPORTED_CHANNELS = void 0;
11
+ exports.buildAdapters = buildAdapters;
12
+ const logger_1 = require("../core/logger");
13
+ const feishu_1 = require("./channels/feishu");
14
+ const wecom_1 = require("./channels/wecom");
15
+ const qq_1 = require("./channels/qq");
16
+ const log = (0, logger_1.getLogger)('gateway-registry');
17
+ const FACTORIES = {
18
+ feishu: feishu_1.createFeishuAdapter,
19
+ wecom: wecom_1.createWecomAdapter,
20
+ qq: qq_1.createQQAdapter,
21
+ };
22
+ /** Build all enabled, well-configured adapters from the channels config. */
23
+ function buildAdapters(channelsCfg, env) {
24
+ const adapters = new Map();
25
+ for (const [id, factory] of Object.entries(FACTORIES)) {
26
+ const cfg = channelsCfg[id];
27
+ // A channel can be enabled purely via env vars (no config block) — pass an
28
+ // empty object so the factory still tries env fallback. Skip only when the
29
+ // block is explicitly disabled.
30
+ if (cfg && cfg.enabled === false)
31
+ continue;
32
+ try {
33
+ const adapter = factory(cfg || {}, env);
34
+ if (adapter)
35
+ adapters.set(id, adapter);
36
+ }
37
+ catch (e) {
38
+ log.warn('adapter_build_failed', { channel: id, error: String(e) });
39
+ }
40
+ }
41
+ return adapters;
42
+ }
43
+ exports.SUPPORTED_CHANNELS = Object.keys(FACTORIES);
44
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/gateway/registry.ts"],"names":[],"mappings":";AAAA;;;;;;GAMG;;;AAiBH,sCAmBC;AAlCD,2CAA2C;AAE3C,8CAAwD;AACxD,4CAAsD;AACtD,sCAAgD;AAEhD,MAAM,GAAG,GAAG,IAAA,kBAAS,EAAC,kBAAkB,CAAC,CAAC;AAE1C,MAAM,SAAS,GAAmC;IAChD,MAAM,EAAE,4BAAmB;IAC3B,KAAK,EAAE,0BAAkB;IACzB,EAAE,EAAE,oBAAe;CACpB,CAAC;AAEF,4EAA4E;AAC5E,SAAgB,aAAa,CAC3B,WAAgC,EAChC,GAAsB;IAEtB,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA0B,CAAC;IACnD,KAAK,MAAM,CAAC,EAAE,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;QACtD,MAAM,GAAG,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;QAC5B,2EAA2E;QAC3E,2EAA2E;QAC3E,gCAAgC;QAChC,IAAI,GAAG,IAAI,GAAG,CAAC,OAAO,KAAK,KAAK;YAAE,SAAS;QAC3C,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,IAAI,EAAE,EAAE,GAAG,CAAC,CAAC;YACxC,IAAI,OAAO;gBAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;QACzC,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,GAAG,CAAC,IAAI,CAAC,sBAAsB,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtE,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAEY,QAAA,kBAAkB,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC"}
@@ -0,0 +1,81 @@
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
+ /** Raw event for adapters that need more than the normalized fields. */
26
+ raw?: unknown;
27
+ }
28
+ /** Opaque, channel-specific destination for an outbound reply. */
29
+ export interface ReplyTarget {
30
+ channel: string;
31
+ [key: string]: unknown;
32
+ }
33
+ /**
34
+ * The result of an adapter handling a raw webhook request. Exactly one of:
35
+ * - `response`: reply immediately to the HTTP request (URL verification
36
+ * challenge, ack, or signature failure) and do NOT route to an agent.
37
+ * - `message`: a normalized inbound message to route to an agent. `response`
38
+ * may also be set to ack the webhook synchronously (most platforms require a
39
+ * fast 200) while the agent reply is delivered asynchronously via `send`.
40
+ * - neither: nothing to do (duplicate/ignored event); gateway returns 200.
41
+ */
42
+ export interface WebhookOutcome {
43
+ response?: HttpResponse;
44
+ message?: InboundMessage;
45
+ }
46
+ export interface HttpResponse {
47
+ status: number;
48
+ body?: string;
49
+ contentType?: string;
50
+ }
51
+ /** The raw HTTP request passed to an adapter's webhook handler. */
52
+ export interface RawRequest {
53
+ method: string;
54
+ headers: IncomingHttpHeaders;
55
+ query: URLSearchParams;
56
+ body: Buffer;
57
+ }
58
+ /** A channel integration. Constructed from its config block by a factory. */
59
+ export interface ChannelAdapter {
60
+ /** Channel id (matches the /webhook/<id> route and config key). */
61
+ readonly id: string;
62
+ /** Human label for logs/status. */
63
+ readonly name: string;
64
+ /** Default agent to route this channel's messages to (config override wins). */
65
+ readonly defaultAgent?: string;
66
+ /** Optional one-time setup (token prefetch, websocket connect, …). */
67
+ start?(): Promise<void>;
68
+ /** Optional teardown on gateway shutdown. */
69
+ stop?(): Promise<void>;
70
+ /**
71
+ * Handle a raw webhook request: verify signature, answer the platform's
72
+ * URL-verification handshake, decrypt if needed, and either return an
73
+ * immediate HTTP response and/or a normalized message to route.
74
+ */
75
+ handleWebhook(req: RawRequest): Promise<WebhookOutcome>;
76
+ /** Send a text reply back to the channel. */
77
+ send(target: ReplyTarget, text: string): Promise<void>;
78
+ }
79
+ /** Factory signature: build an adapter from its config block (or null if disabled/misconfigured). */
80
+ export type ChannelFactory = (cfg: any, env: NodeJS.ProcessEnv) => ChannelAdapter | null;
81
+ //# 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,wEAAwE;IACxE,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;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;CACxD;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,14 @@
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
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/gateway/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skyloom",
3
- "version": "1.20.0",
3
+ "version": "1.21.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,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
+ }