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,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
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Channel gateway — runs the registered channel adapters behind one HTTP server
3
+ * and bridges inbound platform messages to Skyloom agents.
4
+ *
5
+ * platform → POST /webhook/<channel> → adapter.handleWebhook (verify+normalize)
6
+ * → route to agent → agent.chatStream → adapter.send (reply)
7
+ *
8
+ * The HTTP layer mirrors web/server.ts. Each channel handles its own signature
9
+ * verification and URL-verification handshake inside handleWebhook, so the
10
+ * gateway core never knows platform specifics. Agent replies are delivered
11
+ * asynchronously (after the webhook is acked) because all three platforms
12
+ * require a fast 200.
13
+ */
14
+
15
+ import { createServer, IncomingMessage, ServerResponse } from 'http';
16
+ import { getLogger } from '../core/logger';
17
+ import { createSystemContext } from '../core/factory';
18
+ import { buildAdapters } from './registry';
19
+ import type { ChannelAdapter, InboundMessage, RawRequest } from './types';
20
+
21
+ const log = getLogger('gateway');
22
+
23
+ /** Collect the full request body. */
24
+ async function readBody(req: IncomingMessage): Promise<Buffer> {
25
+ const chunks: Buffer[] = [];
26
+ for await (const chunk of req) chunks.push(chunk as Buffer);
27
+ return Buffer.concat(chunks);
28
+ }
29
+
30
+ /** Run an agent turn for an inbound message and collect the final text reply. */
31
+ async function runAgent(
32
+ ctx: ReturnType<typeof createSystemContext>,
33
+ adapter: ChannelAdapter,
34
+ msg: InboundMessage,
35
+ ): Promise<string> {
36
+ const cfgChannels = (ctx.config as any).channels || {};
37
+ const agentName = cfgChannels[adapter.id]?.agent || adapter.defaultAgent || 'fair';
38
+ const agent = ctx.agentMap.get(agentName) || ctx.agentMap.get('fair') || [...ctx.agentMap.values()][0];
39
+ if (!agent) throw new Error('no agent available');
40
+
41
+ await agent.init();
42
+ let text = '';
43
+ try {
44
+ for await (const ev of agent.chatStream(msg.text)) {
45
+ if ((ev as any).type === 'content') text += (ev as any).text;
46
+ }
47
+ } catch (e) {
48
+ log.warn('gateway_agent_failed', { channel: adapter.id, error: String(e) });
49
+ return `[出错了] ${String(e)}`;
50
+ }
51
+ return text.trim() || '(无回复)';
52
+ }
53
+
54
+ export interface GatewayOptions {
55
+ port?: number;
56
+ host?: string;
57
+ }
58
+
59
+ export async function startGateway(opts: GatewayOptions = {}): Promise<void> {
60
+ const ctx = createSystemContext();
61
+ const adapters = buildAdapters((ctx.config as any).channels || {}, process.env);
62
+
63
+ if (adapters.size === 0) {
64
+ log.warn('gateway_no_channels', {});
65
+ process.stdout.write(
66
+ '\n ⚠ 没有启用任何渠道。在 ~/.skyloom/config.yaml 配置 channels.feishu / channels.wecom / channels.qq,\n' +
67
+ ' 或设置对应环境变量(如 FEISHU_APP_ID/FEISHU_APP_SECRET)。\n\n',
68
+ );
69
+ return;
70
+ }
71
+
72
+ for (const adapter of adapters.values()) {
73
+ if (adapter.start) {
74
+ try { await adapter.start(); } catch (e) { log.warn('adapter_start_failed', { channel: adapter.id, error: String(e) }); }
75
+ }
76
+ }
77
+
78
+ const port = opts.port ?? Number(process.env.SKYLOOM_GATEWAY_PORT) ?? 8848;
79
+ // Gateways receive inbound webhooks from the platform's servers, so unlike the
80
+ // local web UI they must bind to a reachable interface by default.
81
+ const host = opts.host || process.env.SKYLOOM_GATEWAY_HOST || '0.0.0.0';
82
+
83
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
84
+ const url = new URL(req.url || '/', `http://${req.headers.host}`);
85
+ try {
86
+ if (url.pathname === '/health' && req.method === 'GET') {
87
+ res.writeHead(200, { 'Content-Type': 'application/json' })
88
+ .end(JSON.stringify({ ok: true, channels: [...adapters.keys()] }));
89
+ return;
90
+ }
91
+ const m = url.pathname.match(/^\/webhook\/([a-z0-9_-]+)$/i);
92
+ if (!m) { res.writeHead(404).end('Not found'); return; }
93
+ const adapter = adapters.get(m[1].toLowerCase());
94
+ if (!adapter) { res.writeHead(404).end(`Unknown channel: ${m[1]}`); return; }
95
+
96
+ const raw: RawRequest = {
97
+ method: req.method || 'POST',
98
+ headers: req.headers,
99
+ query: url.searchParams,
100
+ body: await readBody(req),
101
+ };
102
+
103
+ const outcome = await adapter.handleWebhook(raw);
104
+
105
+ // Immediate HTTP response (challenge / ack / signature failure).
106
+ if (outcome.response) {
107
+ res.writeHead(outcome.response.status, {
108
+ 'Content-Type': outcome.response.contentType || 'text/plain; charset=utf-8',
109
+ }).end(outcome.response.body ?? '');
110
+ } else {
111
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
112
+ }
113
+
114
+ // Route to an agent and deliver the reply asynchronously (after the ack).
115
+ if (outcome.message) {
116
+ const msg = outcome.message;
117
+ void (async () => {
118
+ try {
119
+ const reply = await runAgent(ctx, adapter, msg);
120
+ await adapter.send(msg.replyTo, reply);
121
+ } catch (e) {
122
+ log.warn('gateway_dispatch_failed', { channel: adapter.id, error: String(e) });
123
+ }
124
+ })();
125
+ }
126
+ } catch (e) {
127
+ log.warn('gateway_request_error', { error: String(e) });
128
+ if (!res.headersSent) res.writeHead(500).end('error');
129
+ }
130
+ });
131
+
132
+ await new Promise<void>((resolve) => {
133
+ server.listen(port, host, () => {
134
+ const list = [...adapters.values()].map((a) => `${a.name}(/webhook/${a.id})`).join(' · ');
135
+ process.stdout.write(
136
+ `\n 天空织机 · 渠道网关 · http://${host}:${port}\n 已启用: ${list}\n` +
137
+ ` 把对应平台的事件回调 URL 指向 http(s)://<你的域名>:${port}/webhook/<channel>\n\n`,
138
+ );
139
+ log.info('gateway_started', { port, host, channels: [...adapters.keys()] });
140
+ resolve();
141
+ });
142
+ });
143
+
144
+ const shutdown = async () => {
145
+ for (const a of adapters.values()) { try { await a.stop?.(); } catch { /* ignore */ } }
146
+ try { await ctx.closeAll(); } catch { /* ignore */ }
147
+ server.close();
148
+ };
149
+ process.on('SIGINT', () => { void shutdown().then(() => process.exit(0)); });
150
+ process.on('SIGTERM', () => { void shutdown().then(() => process.exit(0)); });
151
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared helpers for channel adapters: secret resolution (config value or env
3
+ * fallback), and a tiny JSON HTTP client. Kept dependency-light (axios is
4
+ * already a project dep) and injectable-free — adapters call these directly.
5
+ */
6
+
7
+ import axios from 'axios';
8
+
9
+ /**
10
+ * Resolve a secret/config value. Accepts a literal string, or an env-ref object
11
+ * `{ source: 'env', id: 'NAME' }` (OpenClaw-compatible), falling back to the
12
+ * given env var name. Returns undefined if unresolved.
13
+ */
14
+ export function resolveSecret(
15
+ value: unknown,
16
+ env: NodeJS.ProcessEnv,
17
+ envFallback?: string,
18
+ ): string | undefined {
19
+ if (typeof value === 'string' && value.trim()) return value.trim();
20
+ if (value && typeof value === 'object') {
21
+ const v = value as any;
22
+ if (v.source === 'env' && typeof v.id === 'string') {
23
+ const got = env[v.id];
24
+ if (got && got.trim()) return got.trim();
25
+ }
26
+ }
27
+ if (envFallback) {
28
+ const got = env[envFallback];
29
+ if (got && got.trim()) return got.trim();
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /** POST JSON, return parsed JSON. Throws on non-2xx. */
35
+ export async function postJson(
36
+ url: string,
37
+ body: any,
38
+ opts?: { headers?: Record<string, string>; timeoutMs?: number },
39
+ ): Promise<any> {
40
+ const res = await axios.post(url, body, {
41
+ headers: { 'Content-Type': 'application/json', ...(opts?.headers || {}) },
42
+ timeout: opts?.timeoutMs ?? 15000,
43
+ validateStatus: (s) => s >= 200 && s < 300,
44
+ });
45
+ return res.data;
46
+ }
47
+
48
+ /** GET JSON, return parsed JSON. Throws on non-2xx. */
49
+ export async function getJson(
50
+ url: string,
51
+ opts?: { headers?: Record<string, string>; timeoutMs?: number },
52
+ ): Promise<any> {
53
+ const res = await axios.get(url, {
54
+ headers: { Accept: 'application/json', ...(opts?.headers || {}) },
55
+ timeout: opts?.timeoutMs ?? 15000,
56
+ validateStatus: (s) => s >= 200 && s < 300,
57
+ });
58
+ return res.data;
59
+ }
60
+
61
+ /**
62
+ * A small token cache: fetch an access token via `fetcher`, cache it until it
63
+ * is near expiry, and refresh transparently. Channels (Feishu/WeCom) all need
64
+ * a short-lived tenant/access token; this avoids re-fetching per message.
65
+ */
66
+ export class TokenCache {
67
+ private token: string | null = null;
68
+ private expiresAt = 0;
69
+ constructor(private fetcher: () => Promise<{ token: string; expiresInSec: number }>) {}
70
+
71
+ async get(): Promise<string> {
72
+ const now = Date.now();
73
+ if (this.token && now < this.expiresAt - 60_000) return this.token;
74
+ const { token, expiresInSec } = await this.fetcher();
75
+ this.token = token;
76
+ this.expiresAt = now + Math.max(60, expiresInSec) * 1000;
77
+ return token;
78
+ }
79
+
80
+ /** Force the next get() to refetch (e.g. after a 401). */
81
+ invalidate(): void { this.token = null; this.expiresAt = 0; }
82
+ }
@@ -0,0 +1,45 @@
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
+
9
+ import { getLogger } from '../core/logger';
10
+ import type { ChannelAdapter, ChannelFactory } from './types';
11
+ import { createFeishuAdapter } from './channels/feishu';
12
+ import { createWecomAdapter } from './channels/wecom';
13
+ import { createQQAdapter } from './channels/qq';
14
+
15
+ const log = getLogger('gateway-registry');
16
+
17
+ const FACTORIES: Record<string, ChannelFactory> = {
18
+ feishu: createFeishuAdapter,
19
+ wecom: createWecomAdapter,
20
+ qq: createQQAdapter,
21
+ };
22
+
23
+ /** Build all enabled, well-configured adapters from the channels config. */
24
+ export function buildAdapters(
25
+ channelsCfg: Record<string, any>,
26
+ env: NodeJS.ProcessEnv,
27
+ ): Map<string, ChannelAdapter> {
28
+ const adapters = new Map<string, ChannelAdapter>();
29
+ for (const [id, factory] of Object.entries(FACTORIES)) {
30
+ const cfg = channelsCfg[id];
31
+ // A channel can be enabled purely via env vars (no config block) — pass an
32
+ // empty object so the factory still tries env fallback. Skip only when the
33
+ // block is explicitly disabled.
34
+ if (cfg && cfg.enabled === false) continue;
35
+ try {
36
+ const adapter = factory(cfg || {}, env);
37
+ if (adapter) adapters.set(id, adapter);
38
+ } catch (e) {
39
+ log.warn('adapter_build_failed', { channel: id, error: String(e) });
40
+ }
41
+ }
42
+ return adapters;
43
+ }
44
+
45
+ export const SUPPORTED_CHANNELS = Object.keys(FACTORIES);
@@ -0,0 +1,91 @@
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
+
13
+ import type { IncomingHttpHeaders } from 'http';
14
+
15
+ /** A normalized inbound message from any channel. */
16
+ export interface InboundMessage {
17
+ /** Channel id, e.g. 'feishu'. */
18
+ channel: string;
19
+ /** Stable conversation/session key (chat id or user id) for memory routing. */
20
+ conversationId: string;
21
+ /** Platform user id of the sender. */
22
+ userId: string;
23
+ /** Plain text the user sent (media/stripped to text where possible). */
24
+ text: string;
25
+ /** Where to send the reply (channel-specific opaque target). */
26
+ replyTo: ReplyTarget;
27
+ /** Raw event for adapters that need more than the normalized fields. */
28
+ raw?: unknown;
29
+ }
30
+
31
+ /** Opaque, channel-specific destination for an outbound reply. */
32
+ export interface ReplyTarget {
33
+ channel: string;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ /**
38
+ * The result of an adapter handling a raw webhook request. Exactly one of:
39
+ * - `response`: reply immediately to the HTTP request (URL verification
40
+ * challenge, ack, or signature failure) and do NOT route to an agent.
41
+ * - `message`: a normalized inbound message to route to an agent. `response`
42
+ * may also be set to ack the webhook synchronously (most platforms require a
43
+ * fast 200) while the agent reply is delivered asynchronously via `send`.
44
+ * - neither: nothing to do (duplicate/ignored event); gateway returns 200.
45
+ */
46
+ export interface WebhookOutcome {
47
+ response?: HttpResponse;
48
+ message?: InboundMessage;
49
+ }
50
+
51
+ export interface HttpResponse {
52
+ status: number;
53
+ body?: string;
54
+ contentType?: string;
55
+ }
56
+
57
+ /** The raw HTTP request passed to an adapter's webhook handler. */
58
+ export interface RawRequest {
59
+ method: string;
60
+ headers: IncomingHttpHeaders;
61
+ query: URLSearchParams;
62
+ body: Buffer;
63
+ }
64
+
65
+ /** A channel integration. Constructed from its config block by a factory. */
66
+ export interface ChannelAdapter {
67
+ /** Channel id (matches the /webhook/<id> route and config key). */
68
+ readonly id: string;
69
+ /** Human label for logs/status. */
70
+ readonly name: string;
71
+ /** Default agent to route this channel's messages to (config override wins). */
72
+ readonly defaultAgent?: string;
73
+
74
+ /** Optional one-time setup (token prefetch, websocket connect, …). */
75
+ start?(): Promise<void>;
76
+ /** Optional teardown on gateway shutdown. */
77
+ stop?(): Promise<void>;
78
+
79
+ /**
80
+ * Handle a raw webhook request: verify signature, answer the platform's
81
+ * URL-verification handshake, decrypt if needed, and either return an
82
+ * immediate HTTP response and/or a normalized message to route.
83
+ */
84
+ handleWebhook(req: RawRequest): Promise<WebhookOutcome>;
85
+
86
+ /** Send a text reply back to the channel. */
87
+ send(target: ReplyTarget, text: string): Promise<void>;
88
+ }
89
+
90
+ /** Factory signature: build an adapter from its config block (or null if disabled/misconfigured). */
91
+ export type ChannelFactory = (cfg: any, env: NodeJS.ProcessEnv) => ChannelAdapter | null;