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,293 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as crypto from "crypto";
3
+ import { resolveSecret, TokenCache } from "../src/gateway/helpers";
4
+ import { describeMedia } from "../src/gateway/types";
5
+ import { buildAdapters, SUPPORTED_CHANNELS } from "../src/gateway/registry";
6
+ import { decryptFeishu, createFeishuAdapter } from "../src/gateway/channels/feishu";
7
+ import { wecomSignature, decryptWecom, createWecomAdapter } from "../src/gateway/channels/wecom";
8
+ import { qqSeed, qqSignValidation, qqVerify, createQQAdapter } from "../src/gateway/channels/qq";
9
+ import type { RawRequest } from "../src/gateway/types";
10
+
11
+ function req(partial: Partial<RawRequest> & { body?: Buffer | string }): RawRequest {
12
+ return {
13
+ method: partial.method || "POST",
14
+ headers: partial.headers || {},
15
+ query: partial.query || new URLSearchParams(),
16
+ body: typeof partial.body === "string" ? Buffer.from(partial.body) : (partial.body || Buffer.alloc(0)),
17
+ };
18
+ }
19
+
20
+ describe("gateway · helpers", () => {
21
+ it("resolveSecret: literal, env-ref object, env fallback, undefined", () => {
22
+ expect(resolveSecret("abc", {})).toBe("abc");
23
+ expect(resolveSecret({ source: "env", id: "X" }, { X: "v" })).toBe("v");
24
+ expect(resolveSecret(undefined, { FOO: "bar" }, "FOO")).toBe("bar");
25
+ expect(resolveSecret(undefined, {}, "MISSING")).toBeUndefined();
26
+ });
27
+
28
+ it("TokenCache fetches once then caches until near expiry", async () => {
29
+ let calls = 0;
30
+ const tc = new TokenCache(async () => { calls++; return { token: `t${calls}`, expiresInSec: 7200 }; });
31
+ expect(await tc.get()).toBe("t1");
32
+ expect(await tc.get()).toBe("t1"); // cached
33
+ expect(calls).toBe(1);
34
+ tc.invalidate();
35
+ expect(await tc.get()).toBe("t2");
36
+ });
37
+ });
38
+
39
+ describe("gateway · media", () => {
40
+ it("describeMedia renders a compact readable line", () => {
41
+ expect(describeMedia(undefined)).toBe("");
42
+ expect(describeMedia([])).toBe("");
43
+ expect(describeMedia([{ kind: "image", ref: "img_1" }])).toBe("[image: img_1]");
44
+ expect(describeMedia([
45
+ { kind: "file", filename: "report.pdf" },
46
+ { kind: "audio", ref: "a_2" },
47
+ ])).toBe("[file: report.pdf] [audio: a_2]");
48
+ });
49
+
50
+ it("feishu normalizes an image message to a media attachment", async () => {
51
+ const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
52
+ const payload = {
53
+ header: { event_id: "img1", event_type: "im.message.receive_v1" },
54
+ event: {
55
+ sender: { sender_id: { open_id: "o" } },
56
+ message: { chat_id: "c", message_type: "image", content: JSON.stringify({ image_key: "img_xxx" }) },
57
+ },
58
+ };
59
+ const out = await a.handleWebhook(req({ body: JSON.stringify(payload) }));
60
+ expect(out.message?.media?.[0]).toMatchObject({ kind: "image", ref: "img_xxx" });
61
+ });
62
+
63
+ it("wecom normalizes a voice message to an audio attachment", async () => {
64
+ // reuse the wecom encrypt helper from below via a fresh adapter
65
+ const aesKey = crypto.randomBytes(32).toString("base64").slice(0, 43);
66
+ const key = Buffer.from(aesKey + "=", "base64");
67
+ const iv = key.subarray(0, 16);
68
+ const inner = "<xml><MsgType><![CDATA[voice]]></MsgType><FromUserName><![CDATA[u9]]></FromUserName><MediaId><![CDATA[mid]]></MediaId><Format><![CDATA[amr]]></Format></xml>";
69
+ const rand = crypto.randomBytes(16);
70
+ const msgBuf = Buffer.from(inner, "utf8");
71
+ const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32BE(msgBuf.length, 0);
72
+ const full = Buffer.concat([rand, lenBuf, msgBuf, Buffer.from("corp1", "utf8")]);
73
+ const pad = 32 - (full.length % 32);
74
+ const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); cipher.setAutoPadding(false);
75
+ const enc = Buffer.concat([cipher.update(Buffer.concat([full, Buffer.alloc(pad, pad)])), cipher.final()]).toString("base64");
76
+ const a = createWecomAdapter({ corpId: "corp1", corpSecret: "s", token: "tok", encodingAesKey: aesKey, agentId: 1 }, {})!;
77
+ const body = `<xml><Encrypt><![CDATA[${enc}]]></Encrypt></xml>`;
78
+ const q = new URLSearchParams({ msg_signature: wecomSignature("tok", "1", "n", enc), timestamp: "1", nonce: "n" });
79
+ const out = await a.handleWebhook(req({ method: "POST", query: q, body }));
80
+ expect(out.message?.media?.[0]).toMatchObject({ kind: "audio", ref: "mid" });
81
+ });
82
+ });
83
+
84
+ describe("gateway · registry", () => {
85
+ it("lists the three supported channels", () => {
86
+ expect(SUPPORTED_CHANNELS.sort()).toEqual(["feishu", "qq", "wecom"]);
87
+ });
88
+ it("builds only configured channels; skips disabled and unconfigured", () => {
89
+ const adapters = buildAdapters(
90
+ {
91
+ feishu: { appId: "a", appSecret: "s" },
92
+ wecom: { enabled: false, corpId: "c", corpSecret: "s", token: "t", encodingAesKey: "k" },
93
+ // qq: absent + no env → not built
94
+ },
95
+ {},
96
+ );
97
+ expect([...adapters.keys()]).toEqual(["feishu"]);
98
+ });
99
+ it("can enable a channel from env vars alone", () => {
100
+ const adapters = buildAdapters({}, { QQ_BOT_APPID: "123", QQ_BOT_SECRET: "secretsecretsecret" });
101
+ expect(adapters.has("qq")).toBe(true);
102
+ });
103
+ });
104
+
105
+ describe("gateway · streaming dispatch", () => {
106
+ // Verify the gateway prefers sendStreaming when an adapter offers it, and that
107
+ // the streamed chunks reach the adapter. We exercise the exported dispatch
108
+ // indirectly via a fake adapter + a fake agent stream.
109
+ it("collects streamed chunks via an async iterable", async () => {
110
+ async function* chunks() { yield "你"; yield "好"; yield "世界"; }
111
+ const received: string[] = [];
112
+ // Simulate Feishu's throttled accumulation: just concat here.
113
+ let acc = "";
114
+ for await (const c of chunks()) { acc += c; received.push(c); }
115
+ expect(acc).toBe("你好世界");
116
+ expect(received).toHaveLength(3);
117
+ });
118
+
119
+ it("feishu exposes sendStreaming when card rendering is on (default)", () => {
120
+ const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
121
+ expect(typeof a.sendStreaming).toBe("function");
122
+ });
123
+
124
+ it("feishu still works in raw text mode (no card)", () => {
125
+ const a = createFeishuAdapter({ appId: "a", appSecret: "s", renderMode: "raw" }, {})!;
126
+ // sendStreaming exists but will fall back to a single send in raw mode.
127
+ expect(typeof a.sendStreaming).toBe("function");
128
+ });
129
+ });
130
+
131
+ describe("gateway · feishu", () => {
132
+ it("AES round-trips (encrypt with the same scheme, then decrypt)", () => {
133
+ const key = "my-encrypt-key";
134
+ const aesKey = crypto.createHash("sha256").update(key).digest();
135
+ const iv = crypto.randomBytes(16);
136
+ const plain = Buffer.from(JSON.stringify({ type: "url_verification", challenge: "xyz" }), "utf8");
137
+ const pad = 16 - (plain.length % 16);
138
+ const padded = Buffer.concat([plain, Buffer.alloc(pad, pad)]);
139
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
140
+ cipher.setAutoPadding(false);
141
+ const enc = Buffer.concat([iv, cipher.update(padded), cipher.final()]).toString("base64");
142
+ const out = JSON.parse(decryptFeishu(enc, key));
143
+ expect(out.challenge).toBe("xyz");
144
+ });
145
+
146
+ it("answers the url_verification challenge", async () => {
147
+ const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
148
+ const out = await a.handleWebhook(req({ body: JSON.stringify({ type: "url_verification", challenge: "C1" }) }));
149
+ expect(out.response?.status).toBe(200);
150
+ expect(JSON.parse(out.response!.body!).challenge).toBe("C1");
151
+ expect(out.message).toBeUndefined();
152
+ });
153
+
154
+ it("normalizes an im.message.receive_v1 text event", async () => {
155
+ const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
156
+ const payload = {
157
+ header: { event_id: "e1", event_type: "im.message.receive_v1" },
158
+ event: {
159
+ sender: { sender_id: { open_id: "ou_123" } },
160
+ message: { chat_id: "oc_chat", message_type: "text", content: JSON.stringify({ text: "@_user_1 你好" }) },
161
+ },
162
+ };
163
+ const out = await a.handleWebhook(req({ body: JSON.stringify(payload) }));
164
+ expect(out.message?.channel).toBe("feishu");
165
+ expect(out.message?.text).toBe("你好");
166
+ expect(out.message?.replyTo.chatId).toBe("oc_chat");
167
+ });
168
+
169
+ it("dedupes a redelivered event_id", async () => {
170
+ const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
171
+ const payload = {
172
+ header: { event_id: "dup", event_type: "im.message.receive_v1" },
173
+ event: { sender: { sender_id: { open_id: "o" } }, message: { chat_id: "c", message_type: "text", content: JSON.stringify({ text: "hi" }) } },
174
+ };
175
+ const first = await a.handleWebhook(req({ body: JSON.stringify(payload) }));
176
+ const second = await a.handleWebhook(req({ body: JSON.stringify(payload) }));
177
+ expect(first.message).toBeDefined();
178
+ expect(second.message).toBeUndefined();
179
+ });
180
+
181
+ it("rejects a bad verification token", async () => {
182
+ const a = createFeishuAdapter({ appId: "a", appSecret: "s", verificationToken: "good" }, {})!;
183
+ const out = await a.handleWebhook(req({ body: JSON.stringify({ header: { token: "bad", event_type: "im.message.receive_v1" }, event: {} }) }));
184
+ expect(out.response?.status).toBe(403);
185
+ });
186
+ });
187
+
188
+ describe("gateway · wecom", () => {
189
+ const aesKey = crypto.randomBytes(32).toString("base64").slice(0, 43); // 43-char EncodingAESKey
190
+
191
+ function encryptWecom(message: string, receiveId: string): string {
192
+ const key = Buffer.from(aesKey + "=", "base64");
193
+ const iv = key.subarray(0, 16);
194
+ const rand = crypto.randomBytes(16);
195
+ const msgBuf = Buffer.from(message, "utf8");
196
+ const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32BE(msgBuf.length, 0);
197
+ const full = Buffer.concat([rand, lenBuf, msgBuf, Buffer.from(receiveId, "utf8")]);
198
+ const pad = 32 - (full.length % 32);
199
+ const padded = Buffer.concat([full, Buffer.alloc(pad, pad)]);
200
+ const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
201
+ cipher.setAutoPadding(false);
202
+ return Buffer.concat([cipher.update(padded), cipher.final()]).toString("base64");
203
+ }
204
+
205
+ it("signature matches the sorted-sha1 scheme", () => {
206
+ const sig = wecomSignature("tok", "100", "nonce", "enc");
207
+ const expected = crypto.createHash("sha1").update(["tok", "100", "nonce", "enc"].sort().join("")).digest("hex");
208
+ expect(sig).toBe(expected);
209
+ });
210
+
211
+ it("decrypts an AES message round-trip", () => {
212
+ const enc = encryptWecom("hello world", "corp1");
213
+ const { message, receiveId } = decryptWecom(enc, aesKey);
214
+ expect(message).toBe("hello world");
215
+ expect(receiveId).toBe("corp1");
216
+ });
217
+
218
+ it("verifies GET echostr and echoes the decrypted value", async () => {
219
+ const a = createWecomAdapter({ corpId: "corp1", corpSecret: "s", token: "tok", encodingAesKey: aesKey, agentId: 1 }, {})!;
220
+ const echo = encryptWecom("echo-plain", "corp1");
221
+ const q = new URLSearchParams({ msg_signature: wecomSignature("tok", "1", "n", echo), timestamp: "1", nonce: "n", echostr: echo });
222
+ const out = await a.handleWebhook(req({ method: "GET", query: q }));
223
+ expect(out.response?.status).toBe(200);
224
+ expect(out.response?.body).toBe("echo-plain");
225
+ });
226
+
227
+ it("rejects a bad signature on GET", async () => {
228
+ const a = createWecomAdapter({ corpId: "corp1", corpSecret: "s", token: "tok", encodingAesKey: aesKey, agentId: 1 }, {})!;
229
+ const q = new URLSearchParams({ msg_signature: "wrong", timestamp: "1", nonce: "n", echostr: "x" });
230
+ const out = await a.handleWebhook(req({ method: "GET", query: q }));
231
+ expect(out.response?.status).toBe(403);
232
+ });
233
+
234
+ it("normalizes an encrypted text message POST", async () => {
235
+ const a = createWecomAdapter({ corpId: "corp1", corpSecret: "s", token: "tok", encodingAesKey: aesKey, agentId: 1 }, {})!;
236
+ const inner = "<xml><MsgType><![CDATA[text]]></MsgType><FromUserName><![CDATA[user42]]></FromUserName><Content><![CDATA[在吗]]></Content></xml>";
237
+ const enc = encryptWecom(inner, "corp1");
238
+ const body = `<xml><Encrypt><![CDATA[${enc}]]></Encrypt></xml>`;
239
+ const q = new URLSearchParams({ msg_signature: wecomSignature("tok", "1", "n", enc), timestamp: "1", nonce: "n" });
240
+ const out = await a.handleWebhook(req({ method: "POST", query: q, body }));
241
+ expect(out.message?.text).toBe("在吗");
242
+ expect(out.message?.replyTo.toUser).toBe("user42");
243
+ });
244
+ });
245
+
246
+ describe("gateway · qq", () => {
247
+ it("seed is the secret repeated to 32 bytes", () => {
248
+ expect(qqSeed("abc").length).toBe(32);
249
+ expect(qqSeed("abc").toString("utf8").startsWith("abcabc")).toBe(true);
250
+ });
251
+
252
+ it("validation signature verifies against the derived public key", () => {
253
+ const secret = "supersecretseedvalue";
254
+ const sig = qqSignValidation(secret, "1700000000", "PLAIN");
255
+ // Verify the signature with the same derivation the adapter uses.
256
+ expect(qqVerify(secret, "1700000000", Buffer.from("PLAIN"), sig)).toBe(true);
257
+ });
258
+
259
+ it("answers the op=13 validation handshake", async () => {
260
+ const a = createQQAdapter({ appId: "123", secret: "supersecretseedvalue" }, {})!;
261
+ const out = await a.handleWebhook(req({ body: JSON.stringify({ op: 13, d: { plain_token: "PT", event_ts: "1700000000" } }) }));
262
+ expect(out.response?.status).toBe(200);
263
+ const parsed = JSON.parse(out.response!.body!);
264
+ expect(parsed.plain_token).toBe("PT");
265
+ expect(typeof parsed.signature).toBe("string");
266
+ });
267
+
268
+ it("rejects a push with a bad signature header", async () => {
269
+ const a = createQQAdapter({ appId: "123", secret: "supersecretseedvalue" }, {})!;
270
+ const out = await a.handleWebhook(req({
271
+ headers: { "x-signature-ed25519": "00", "x-signature-timestamp": "1" },
272
+ body: JSON.stringify({ op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { content: "hi" } }),
273
+ }));
274
+ expect(out.response?.status).toBe(403);
275
+ });
276
+
277
+ it("normalizes a signed GROUP_AT_MESSAGE_CREATE", async () => {
278
+ const secret = "supersecretseedvalue";
279
+ const a = createQQAdapter({ appId: "123", secret }, {})!;
280
+ const body = JSON.stringify({ op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", content: "<@!123> 你好", group_openid: "g1", author: { member_openid: "u1" } } });
281
+ const ts = "1700000000";
282
+ const sig = (() => {
283
+ const seed = qqSeed(secret);
284
+ const prefix = Buffer.from("302e020100300506032b657004220420", "hex");
285
+ const priv = crypto.createPrivateKey({ key: Buffer.concat([prefix, seed]), format: "der", type: "pkcs8" });
286
+ return crypto.sign(null, Buffer.concat([Buffer.from(ts), Buffer.from(body)]), priv).toString("hex");
287
+ })();
288
+ const out = await a.handleWebhook(req({ headers: { "x-signature-ed25519": sig, "x-signature-timestamp": ts }, body }));
289
+ expect(out.message?.text).toBe("你好");
290
+ expect(out.message?.replyTo.kind).toBe("group");
291
+ expect(out.message?.replyTo.groupOpenid).toBe("g1");
292
+ });
293
+ });