skyloom 1.19.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -6
- package/dist/cli/main.js +3 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts +4 -1
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +83 -64
- package/dist/core/agent.js.map +1 -1
- package/dist/gateway/channels/feishu.d.ts +19 -0
- package/dist/gateway/channels/feishu.d.ts.map +1 -0
- package/dist/gateway/channels/feishu.js +186 -0
- package/dist/gateway/channels/feishu.js.map +1 -0
- package/dist/gateway/channels/qq.d.ts +25 -0
- package/dist/gateway/channels/qq.d.ts.map +1 -0
- package/dist/gateway/channels/qq.js +177 -0
- package/dist/gateway/channels/qq.js.map +1 -0
- package/dist/gateway/channels/wecom.d.ts +26 -0
- package/dist/gateway/channels/wecom.d.ts.map +1 -0
- package/dist/gateway/channels/wecom.js +177 -0
- package/dist/gateway/channels/wecom.js.map +1 -0
- package/dist/gateway/gateway.d.ts +19 -0
- package/dist/gateway/gateway.d.ts.map +1 -0
- package/dist/gateway/gateway.js +152 -0
- package/dist/gateway/gateway.js.map +1 -0
- package/dist/gateway/helpers.d.ts +39 -0
- package/dist/gateway/helpers.d.ts.map +1 -0
- package/dist/gateway/helpers.js +81 -0
- package/dist/gateway/helpers.js.map +1 -0
- package/dist/gateway/registry.d.ts +12 -0
- package/dist/gateway/registry.d.ts.map +1 -0
- package/dist/gateway/registry.js +44 -0
- package/dist/gateway/registry.js.map +1 -0
- package/dist/gateway/types.d.ts +81 -0
- package/dist/gateway/types.d.ts.map +1 -0
- package/dist/gateway/types.js +14 -0
- package/dist/gateway/types.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/main.ts +3 -0
- package/src/core/agent.ts +83 -62
- package/src/gateway/channels/feishu.ts +142 -0
- package/src/gateway/channels/qq.ts +140 -0
- package/src/gateway/channels/wecom.ts +142 -0
- package/src/gateway/gateway.ts +151 -0
- package/src/gateway/helpers.ts +82 -0
- package/src/gateway/registry.ts +45 -0
- package/src/gateway/types.ts +91 -0
- package/tests/agent.test.ts +45 -19
- package/tests/gateway.test.ts +221 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import { resolveSecret, TokenCache } from "../src/gateway/helpers";
|
|
4
|
+
import { buildAdapters, SUPPORTED_CHANNELS } from "../src/gateway/registry";
|
|
5
|
+
import { decryptFeishu, createFeishuAdapter } from "../src/gateway/channels/feishu";
|
|
6
|
+
import { wecomSignature, decryptWecom, createWecomAdapter } from "../src/gateway/channels/wecom";
|
|
7
|
+
import { qqSeed, qqSignValidation, qqVerify, createQQAdapter } from "../src/gateway/channels/qq";
|
|
8
|
+
import type { RawRequest } from "../src/gateway/types";
|
|
9
|
+
|
|
10
|
+
function req(partial: Partial<RawRequest> & { body?: Buffer | string }): RawRequest {
|
|
11
|
+
return {
|
|
12
|
+
method: partial.method || "POST",
|
|
13
|
+
headers: partial.headers || {},
|
|
14
|
+
query: partial.query || new URLSearchParams(),
|
|
15
|
+
body: typeof partial.body === "string" ? Buffer.from(partial.body) : (partial.body || Buffer.alloc(0)),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("gateway · helpers", () => {
|
|
20
|
+
it("resolveSecret: literal, env-ref object, env fallback, undefined", () => {
|
|
21
|
+
expect(resolveSecret("abc", {})).toBe("abc");
|
|
22
|
+
expect(resolveSecret({ source: "env", id: "X" }, { X: "v" })).toBe("v");
|
|
23
|
+
expect(resolveSecret(undefined, { FOO: "bar" }, "FOO")).toBe("bar");
|
|
24
|
+
expect(resolveSecret(undefined, {}, "MISSING")).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("TokenCache fetches once then caches until near expiry", async () => {
|
|
28
|
+
let calls = 0;
|
|
29
|
+
const tc = new TokenCache(async () => { calls++; return { token: `t${calls}`, expiresInSec: 7200 }; });
|
|
30
|
+
expect(await tc.get()).toBe("t1");
|
|
31
|
+
expect(await tc.get()).toBe("t1"); // cached
|
|
32
|
+
expect(calls).toBe(1);
|
|
33
|
+
tc.invalidate();
|
|
34
|
+
expect(await tc.get()).toBe("t2");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("gateway · registry", () => {
|
|
39
|
+
it("lists the three supported channels", () => {
|
|
40
|
+
expect(SUPPORTED_CHANNELS.sort()).toEqual(["feishu", "qq", "wecom"]);
|
|
41
|
+
});
|
|
42
|
+
it("builds only configured channels; skips disabled and unconfigured", () => {
|
|
43
|
+
const adapters = buildAdapters(
|
|
44
|
+
{
|
|
45
|
+
feishu: { appId: "a", appSecret: "s" },
|
|
46
|
+
wecom: { enabled: false, corpId: "c", corpSecret: "s", token: "t", encodingAesKey: "k" },
|
|
47
|
+
// qq: absent + no env → not built
|
|
48
|
+
},
|
|
49
|
+
{},
|
|
50
|
+
);
|
|
51
|
+
expect([...adapters.keys()]).toEqual(["feishu"]);
|
|
52
|
+
});
|
|
53
|
+
it("can enable a channel from env vars alone", () => {
|
|
54
|
+
const adapters = buildAdapters({}, { QQ_BOT_APPID: "123", QQ_BOT_SECRET: "secretsecretsecret" });
|
|
55
|
+
expect(adapters.has("qq")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("gateway · feishu", () => {
|
|
60
|
+
it("AES round-trips (encrypt with the same scheme, then decrypt)", () => {
|
|
61
|
+
const key = "my-encrypt-key";
|
|
62
|
+
const aesKey = crypto.createHash("sha256").update(key).digest();
|
|
63
|
+
const iv = crypto.randomBytes(16);
|
|
64
|
+
const plain = Buffer.from(JSON.stringify({ type: "url_verification", challenge: "xyz" }), "utf8");
|
|
65
|
+
const pad = 16 - (plain.length % 16);
|
|
66
|
+
const padded = Buffer.concat([plain, Buffer.alloc(pad, pad)]);
|
|
67
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
|
|
68
|
+
cipher.setAutoPadding(false);
|
|
69
|
+
const enc = Buffer.concat([iv, cipher.update(padded), cipher.final()]).toString("base64");
|
|
70
|
+
const out = JSON.parse(decryptFeishu(enc, key));
|
|
71
|
+
expect(out.challenge).toBe("xyz");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("answers the url_verification challenge", async () => {
|
|
75
|
+
const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
|
|
76
|
+
const out = await a.handleWebhook(req({ body: JSON.stringify({ type: "url_verification", challenge: "C1" }) }));
|
|
77
|
+
expect(out.response?.status).toBe(200);
|
|
78
|
+
expect(JSON.parse(out.response!.body!).challenge).toBe("C1");
|
|
79
|
+
expect(out.message).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("normalizes an im.message.receive_v1 text event", async () => {
|
|
83
|
+
const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
|
|
84
|
+
const payload = {
|
|
85
|
+
header: { event_id: "e1", event_type: "im.message.receive_v1" },
|
|
86
|
+
event: {
|
|
87
|
+
sender: { sender_id: { open_id: "ou_123" } },
|
|
88
|
+
message: { chat_id: "oc_chat", message_type: "text", content: JSON.stringify({ text: "@_user_1 你好" }) },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const out = await a.handleWebhook(req({ body: JSON.stringify(payload) }));
|
|
92
|
+
expect(out.message?.channel).toBe("feishu");
|
|
93
|
+
expect(out.message?.text).toBe("你好");
|
|
94
|
+
expect(out.message?.replyTo.chatId).toBe("oc_chat");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("dedupes a redelivered event_id", async () => {
|
|
98
|
+
const a = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
|
|
99
|
+
const payload = {
|
|
100
|
+
header: { event_id: "dup", event_type: "im.message.receive_v1" },
|
|
101
|
+
event: { sender: { sender_id: { open_id: "o" } }, message: { chat_id: "c", message_type: "text", content: JSON.stringify({ text: "hi" }) } },
|
|
102
|
+
};
|
|
103
|
+
const first = await a.handleWebhook(req({ body: JSON.stringify(payload) }));
|
|
104
|
+
const second = await a.handleWebhook(req({ body: JSON.stringify(payload) }));
|
|
105
|
+
expect(first.message).toBeDefined();
|
|
106
|
+
expect(second.message).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("rejects a bad verification token", async () => {
|
|
110
|
+
const a = createFeishuAdapter({ appId: "a", appSecret: "s", verificationToken: "good" }, {})!;
|
|
111
|
+
const out = await a.handleWebhook(req({ body: JSON.stringify({ header: { token: "bad", event_type: "im.message.receive_v1" }, event: {} }) }));
|
|
112
|
+
expect(out.response?.status).toBe(403);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("gateway · wecom", () => {
|
|
117
|
+
const aesKey = crypto.randomBytes(32).toString("base64").slice(0, 43); // 43-char EncodingAESKey
|
|
118
|
+
|
|
119
|
+
function encryptWecom(message: string, receiveId: string): string {
|
|
120
|
+
const key = Buffer.from(aesKey + "=", "base64");
|
|
121
|
+
const iv = key.subarray(0, 16);
|
|
122
|
+
const rand = crypto.randomBytes(16);
|
|
123
|
+
const msgBuf = Buffer.from(message, "utf8");
|
|
124
|
+
const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32BE(msgBuf.length, 0);
|
|
125
|
+
const full = Buffer.concat([rand, lenBuf, msgBuf, Buffer.from(receiveId, "utf8")]);
|
|
126
|
+
const pad = 32 - (full.length % 32);
|
|
127
|
+
const padded = Buffer.concat([full, Buffer.alloc(pad, pad)]);
|
|
128
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
129
|
+
cipher.setAutoPadding(false);
|
|
130
|
+
return Buffer.concat([cipher.update(padded), cipher.final()]).toString("base64");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it("signature matches the sorted-sha1 scheme", () => {
|
|
134
|
+
const sig = wecomSignature("tok", "100", "nonce", "enc");
|
|
135
|
+
const expected = crypto.createHash("sha1").update(["tok", "100", "nonce", "enc"].sort().join("")).digest("hex");
|
|
136
|
+
expect(sig).toBe(expected);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("decrypts an AES message round-trip", () => {
|
|
140
|
+
const enc = encryptWecom("hello world", "corp1");
|
|
141
|
+
const { message, receiveId } = decryptWecom(enc, aesKey);
|
|
142
|
+
expect(message).toBe("hello world");
|
|
143
|
+
expect(receiveId).toBe("corp1");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("verifies GET echostr and echoes the decrypted value", async () => {
|
|
147
|
+
const a = createWecomAdapter({ corpId: "corp1", corpSecret: "s", token: "tok", encodingAesKey: aesKey, agentId: 1 }, {})!;
|
|
148
|
+
const echo = encryptWecom("echo-plain", "corp1");
|
|
149
|
+
const q = new URLSearchParams({ msg_signature: wecomSignature("tok", "1", "n", echo), timestamp: "1", nonce: "n", echostr: echo });
|
|
150
|
+
const out = await a.handleWebhook(req({ method: "GET", query: q }));
|
|
151
|
+
expect(out.response?.status).toBe(200);
|
|
152
|
+
expect(out.response?.body).toBe("echo-plain");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects a bad signature on GET", async () => {
|
|
156
|
+
const a = createWecomAdapter({ corpId: "corp1", corpSecret: "s", token: "tok", encodingAesKey: aesKey, agentId: 1 }, {})!;
|
|
157
|
+
const q = new URLSearchParams({ msg_signature: "wrong", timestamp: "1", nonce: "n", echostr: "x" });
|
|
158
|
+
const out = await a.handleWebhook(req({ method: "GET", query: q }));
|
|
159
|
+
expect(out.response?.status).toBe(403);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("normalizes an encrypted text message POST", async () => {
|
|
163
|
+
const a = createWecomAdapter({ corpId: "corp1", corpSecret: "s", token: "tok", encodingAesKey: aesKey, agentId: 1 }, {})!;
|
|
164
|
+
const inner = "<xml><MsgType><![CDATA[text]]></MsgType><FromUserName><![CDATA[user42]]></FromUserName><Content><![CDATA[在吗]]></Content></xml>";
|
|
165
|
+
const enc = encryptWecom(inner, "corp1");
|
|
166
|
+
const body = `<xml><Encrypt><![CDATA[${enc}]]></Encrypt></xml>`;
|
|
167
|
+
const q = new URLSearchParams({ msg_signature: wecomSignature("tok", "1", "n", enc), timestamp: "1", nonce: "n" });
|
|
168
|
+
const out = await a.handleWebhook(req({ method: "POST", query: q, body }));
|
|
169
|
+
expect(out.message?.text).toBe("在吗");
|
|
170
|
+
expect(out.message?.replyTo.toUser).toBe("user42");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("gateway · qq", () => {
|
|
175
|
+
it("seed is the secret repeated to 32 bytes", () => {
|
|
176
|
+
expect(qqSeed("abc").length).toBe(32);
|
|
177
|
+
expect(qqSeed("abc").toString("utf8").startsWith("abcabc")).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("validation signature verifies against the derived public key", () => {
|
|
181
|
+
const secret = "supersecretseedvalue";
|
|
182
|
+
const sig = qqSignValidation(secret, "1700000000", "PLAIN");
|
|
183
|
+
// Verify the signature with the same derivation the adapter uses.
|
|
184
|
+
expect(qqVerify(secret, "1700000000", Buffer.from("PLAIN"), sig)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("answers the op=13 validation handshake", async () => {
|
|
188
|
+
const a = createQQAdapter({ appId: "123", secret: "supersecretseedvalue" }, {})!;
|
|
189
|
+
const out = await a.handleWebhook(req({ body: JSON.stringify({ op: 13, d: { plain_token: "PT", event_ts: "1700000000" } }) }));
|
|
190
|
+
expect(out.response?.status).toBe(200);
|
|
191
|
+
const parsed = JSON.parse(out.response!.body!);
|
|
192
|
+
expect(parsed.plain_token).toBe("PT");
|
|
193
|
+
expect(typeof parsed.signature).toBe("string");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("rejects a push with a bad signature header", async () => {
|
|
197
|
+
const a = createQQAdapter({ appId: "123", secret: "supersecretseedvalue" }, {})!;
|
|
198
|
+
const out = await a.handleWebhook(req({
|
|
199
|
+
headers: { "x-signature-ed25519": "00", "x-signature-timestamp": "1" },
|
|
200
|
+
body: JSON.stringify({ op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { content: "hi" } }),
|
|
201
|
+
}));
|
|
202
|
+
expect(out.response?.status).toBe(403);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("normalizes a signed GROUP_AT_MESSAGE_CREATE", async () => {
|
|
206
|
+
const secret = "supersecretseedvalue";
|
|
207
|
+
const a = createQQAdapter({ appId: "123", secret }, {})!;
|
|
208
|
+
const body = JSON.stringify({ op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", content: "<@!123> 你好", group_openid: "g1", author: { member_openid: "u1" } } });
|
|
209
|
+
const ts = "1700000000";
|
|
210
|
+
const sig = (() => {
|
|
211
|
+
const seed = qqSeed(secret);
|
|
212
|
+
const prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
213
|
+
const priv = crypto.createPrivateKey({ key: Buffer.concat([prefix, seed]), format: "der", type: "pkcs8" });
|
|
214
|
+
return crypto.sign(null, Buffer.concat([Buffer.from(ts), Buffer.from(body)]), priv).toString("hex");
|
|
215
|
+
})();
|
|
216
|
+
const out = await a.handleWebhook(req({ headers: { "x-signature-ed25519": sig, "x-signature-timestamp": ts }, body }));
|
|
217
|
+
expect(out.message?.text).toBe("你好");
|
|
218
|
+
expect(out.message?.replyTo.kind).toBe("group");
|
|
219
|
+
expect(out.message?.replyTo.groupOpenid).toBe("g1");
|
|
220
|
+
});
|
|
221
|
+
});
|