skyloom 1.22.0 → 1.24.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 (52) hide show
  1. package/README.md +10 -1
  2. package/dist/cli/main.js +69 -0
  3. package/dist/cli/main.js.map +1 -1
  4. package/dist/core/commands.d.ts.map +1 -1
  5. package/dist/core/commands.js +10 -0
  6. package/dist/core/commands.js.map +1 -1
  7. package/dist/gateway/channels/feishu.d.ts.map +1 -1
  8. package/dist/gateway/channels/feishu.js +53 -0
  9. package/dist/gateway/channels/feishu.js.map +1 -1
  10. package/dist/gateway/channels/qq.d.ts.map +1 -1
  11. package/dist/gateway/channels/qq.js +45 -0
  12. package/dist/gateway/channels/qq.js.map +1 -1
  13. package/dist/gateway/channels/wecom.d.ts.map +1 -1
  14. package/dist/gateway/channels/wecom.js +41 -0
  15. package/dist/gateway/channels/wecom.js.map +1 -1
  16. package/dist/gateway/gateway.d.ts.map +1 -1
  17. package/dist/gateway/gateway.js +79 -9
  18. package/dist/gateway/gateway.js.map +1 -1
  19. package/dist/gateway/helpers.d.ts +23 -0
  20. package/dist/gateway/helpers.d.ts.map +1 -1
  21. package/dist/gateway/helpers.js +90 -0
  22. package/dist/gateway/helpers.js.map +1 -1
  23. package/dist/gateway/qr.d.ts +8 -0
  24. package/dist/gateway/qr.d.ts.map +1 -0
  25. package/dist/gateway/qr.js +23 -0
  26. package/dist/gateway/qr.js.map +1 -0
  27. package/dist/gateway/setup.d.ts +57 -0
  28. package/dist/gateway/setup.d.ts.map +1 -0
  29. package/dist/gateway/setup.js +127 -0
  30. package/dist/gateway/setup.js.map +1 -0
  31. package/dist/gateway/types.d.ts +39 -0
  32. package/dist/gateway/types.d.ts.map +1 -1
  33. package/dist/gateway/types.js +25 -0
  34. package/dist/gateway/types.js.map +1 -1
  35. package/dist/gateway/vision.d.ts +23 -0
  36. package/dist/gateway/vision.d.ts.map +1 -0
  37. package/dist/gateway/vision.js +77 -0
  38. package/dist/gateway/vision.js.map +1 -0
  39. package/package.json +2 -1
  40. package/src/cli/main.ts +62 -0
  41. package/src/core/commands.ts +10 -0
  42. package/src/gateway/channels/feishu.ts +49 -2
  43. package/src/gateway/channels/qq.ts +43 -2
  44. package/src/gateway/channels/wecom.ts +47 -2
  45. package/src/gateway/gateway.ts +77 -8
  46. package/src/gateway/helpers.ts +60 -0
  47. package/src/gateway/qr.ts +21 -0
  48. package/src/gateway/setup.ts +145 -0
  49. package/src/gateway/types.ts +58 -0
  50. package/src/gateway/vision.ts +78 -0
  51. package/tests/channel_setup.test.ts +88 -0
  52. package/tests/gateway.test.ts +84 -1
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import {
6
+ CHANNEL_SETUP,
7
+ SETUP_CHANNEL_IDS,
8
+ callbackUrl,
9
+ saveChannelConfig,
10
+ missingRequired,
11
+ } from "../src/gateway/setup";
12
+ import { renderQR } from "../src/gateway/qr";
13
+
14
+ describe("channel setup · metadata", () => {
15
+ it("covers the three channels with console URLs and webhook paths", () => {
16
+ expect(SETUP_CHANNEL_IDS.sort()).toEqual(["feishu", "qq", "wecom"]);
17
+ for (const id of SETUP_CHANNEL_IDS) {
18
+ const s = CHANNEL_SETUP[id];
19
+ expect(s.consoleUrl).toMatch(/^https:\/\//);
20
+ expect(s.webhookPath).toBe(`/webhook/${id}`);
21
+ expect(s.fields.length).toBeGreaterThan(0);
22
+ expect(s.steps.length).toBeGreaterThan(0);
23
+ }
24
+ });
25
+
26
+ it("each channel marks its credential fields with env fallbacks", () => {
27
+ const feishu = CHANNEL_SETUP.feishu;
28
+ const appId = feishu.fields.find((f) => f.key === "appId")!;
29
+ expect(appId.required).toBe(true);
30
+ expect(appId.env).toBe("FEISHU_APP_ID");
31
+ const secret = feishu.fields.find((f) => f.key === "appSecret")!;
32
+ expect(secret.secret).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe("channel setup · callbackUrl", () => {
37
+ it("joins base + webhook path, trimming trailing slash", () => {
38
+ expect(callbackUrl("https://bot.example.com", "feishu")).toBe("https://bot.example.com/webhook/feishu");
39
+ expect(callbackUrl("https://bot.example.com/", "wecom")).toBe("https://bot.example.com/webhook/wecom");
40
+ expect(callbackUrl("http://localhost:8848", "qq")).toBe("http://localhost:8848/webhook/qq");
41
+ });
42
+ });
43
+
44
+ describe("channel setup · missingRequired", () => {
45
+ it("reports unfilled required fields, ignores optional", () => {
46
+ expect(missingRequired("feishu", { appId: "a" })).toEqual(["appSecret"]);
47
+ expect(missingRequired("feishu", { appId: "a", appSecret: "s" })).toEqual([]);
48
+ // optional fields (verificationToken/encryptKey) never reported
49
+ expect(missingRequired("feishu", { appId: "a", appSecret: "s" })).not.toContain("encryptKey");
50
+ });
51
+ });
52
+
53
+ describe("channel setup · saveChannelConfig", () => {
54
+ let cfgPath: string;
55
+ beforeEach(() => { cfgPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "sky-chcfg-")), "config.yaml"); });
56
+ afterEach(() => { try { fs.rmSync(path.dirname(cfgPath), { recursive: true, force: true }); } catch {} });
57
+
58
+ it("writes channels.<id> and merges on re-save", () => {
59
+ saveChannelConfig("feishu", { appId: "a", appSecret: "s" }, { configPath: cfgPath });
60
+ const yaml = require("yaml");
61
+ let cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
62
+ expect(cfg.channels.feishu).toMatchObject({ appId: "a", appSecret: "s", enabled: true });
63
+
64
+ // re-save adds a field without dropping the old ones
65
+ saveChannelConfig("feishu", { encryptKey: "k" }, { configPath: cfgPath });
66
+ cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
67
+ expect(cfg.channels.feishu).toMatchObject({ appId: "a", appSecret: "s", encryptKey: "k" });
68
+ });
69
+
70
+ it("preserves other top-level config when writing a channel", () => {
71
+ const yaml = require("yaml");
72
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
73
+ fs.writeFileSync(cfgPath, yaml.stringify({ default_model: "gpt-4o", api_keys: { openai: "sk" } }));
74
+ saveChannelConfig("qq", { appId: "1", secret: "x" }, { configPath: cfgPath });
75
+ const cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
76
+ expect(cfg.default_model).toBe("gpt-4o");
77
+ expect(cfg.api_keys.openai).toBe("sk");
78
+ expect(cfg.channels.qq.appId).toBe("1");
79
+ });
80
+ });
81
+
82
+ describe("channel setup · QR rendering", () => {
83
+ it("renders a non-empty scannable block for a URL", () => {
84
+ const qr = renderQR("https://open.feishu.cn/app");
85
+ expect(qr.length).toBeGreaterThan(50);
86
+ expect(qr).toMatch(/[█▀▄ ]/); // block-drawing characters
87
+ });
88
+ });
@@ -1,7 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import * as crypto from "crypto";
3
3
  import { resolveSecret, TokenCache } from "../src/gateway/helpers";
4
- import { describeMedia } from "../src/gateway/types";
4
+ import { describeMedia, parseReply } from "../src/gateway/types";
5
+ import { isSendableSrc } from "../src/gateway/helpers";
6
+ import { describeImages } from "../src/gateway/vision";
5
7
  import { buildAdapters, SUPPORTED_CHANNELS } from "../src/gateway/registry";
6
8
  import { decryptFeishu, createFeishuAdapter } from "../src/gateway/channels/feishu";
7
9
  import { wecomSignature, decryptWecom, createWecomAdapter } from "../src/gateway/channels/wecom";
@@ -81,6 +83,87 @@ describe("gateway · media", () => {
81
83
  });
82
84
  });
83
85
 
86
+ describe("gateway · parseReply (outbound media)", () => {
87
+ it("extracts a markdown image and strips it from the text", () => {
88
+ const r = parseReply("看这张图 ![猫](https://x.com/cat.png) 好看吧");
89
+ expect(r.media).toEqual([{ kind: "image", src: "https://x.com/cat.png", alt: "猫" }]);
90
+ expect(r.text).toContain("看这张图");
91
+ expect(r.text).not.toContain("![");
92
+ });
93
+
94
+ it("extracts [[image:...]] and [[file:...|alt]] directives", () => {
95
+ const r = parseReply("结果:\n[[image:/tmp/out.png]]\n[[file:/tmp/report.pdf|季度报告]]");
96
+ expect(r.media).toEqual([
97
+ { kind: "image", src: "/tmp/out.png", alt: undefined },
98
+ { kind: "file", src: "/tmp/report.pdf", alt: "季度报告" },
99
+ ]);
100
+ expect(r.text).toBe("结果:");
101
+ });
102
+
103
+ it("leaves text without media untouched", () => {
104
+ const r = parseReply("就是一段普通文字");
105
+ expect(r.media).toHaveLength(0);
106
+ expect(r.text).toBe("就是一段普通文字");
107
+ });
108
+
109
+ it("handles multiple images in one reply", () => {
110
+ const r = parseReply("![a](http://h/1.png) 和 ![b](http://h/2.png)");
111
+ expect(r.media.map((m) => m.src)).toEqual(["http://h/1.png", "http://h/2.png"]);
112
+ });
113
+ });
114
+
115
+ describe("gateway · isSendableSrc", () => {
116
+ it("accepts http(s) URLs, rejects bare non-existent paths", () => {
117
+ expect(isSendableSrc("https://x.com/a.png")).toBe(true);
118
+ expect(isSendableSrc("http://x.com/a.png")).toBe(true);
119
+ expect(isSendableSrc("/no/such/file/xyz.png")).toBe(false);
120
+ expect(isSendableSrc("not a path")).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe("gateway · sendMedia capability", () => {
125
+ it("all three adapters expose sendMedia", () => {
126
+ const f = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
127
+ const w = createWecomAdapter({ corpId: "c", corpSecret: "s", token: "t", encodingAesKey: "k".repeat(43), agentId: 1 }, {})!;
128
+ const q = createQQAdapter({ appId: "1", secret: "supersecretseedvalue" }, {})!;
129
+ expect(typeof f.sendMedia).toBe("function");
130
+ expect(typeof w.sendMedia).toBe("function");
131
+ expect(typeof q.sendMedia).toBe("function");
132
+ });
133
+
134
+ it("qq sendMedia rejects a non-URL source", async () => {
135
+ const q = createQQAdapter({ appId: "1", secret: "supersecretseedvalue" }, {})!;
136
+ await expect(q.sendMedia!({ channel: "qq", kind: "group", groupOpenid: "g" }, { kind: "image", src: "/local/file.png" }))
137
+ .rejects.toThrow(/http\(s\) URL/);
138
+ });
139
+ });
140
+
141
+ describe("gateway · vision (multimodal read)", () => {
142
+ it("describeImages returns null with no images", async () => {
143
+ expect(await describeImages([], { model: "gpt-4o-mini", env: {} })).toBeNull();
144
+ });
145
+ it("returns null when no API key is available (skips silently)", async () => {
146
+ const img = { data: Buffer.from("x"), filename: "a.png", contentType: "image/png" };
147
+ expect(await describeImages([img], { model: "gpt-4o-mini", env: {} })).toBeNull();
148
+ });
149
+ it("skips Anthropic models (not OpenAI-chat-shaped here)", async () => {
150
+ const img = { data: Buffer.from("x"), filename: "a.png" };
151
+ expect(await describeImages([img], { model: "claude-sonnet-4-6", env: { ANTHROPIC_API_KEY: "k" } })).toBeNull();
152
+ });
153
+ it("all three adapters expose fetchMedia", () => {
154
+ const f = createFeishuAdapter({ appId: "a", appSecret: "s" }, {})!;
155
+ const w = createWecomAdapter({ corpId: "c", corpSecret: "s", token: "t", encodingAesKey: "k".repeat(43), agentId: 1 }, {})!;
156
+ const q = createQQAdapter({ appId: "1", secret: "supersecretseedvalue" }, {})!;
157
+ expect(typeof f.fetchMedia).toBe("function");
158
+ expect(typeof w.fetchMedia).toBe("function");
159
+ expect(typeof q.fetchMedia).toBe("function");
160
+ });
161
+ it("qq fetchMedia returns null without a url", async () => {
162
+ const q = createQQAdapter({ appId: "1", secret: "supersecretseedvalue" }, {})!;
163
+ expect(await q.fetchMedia!({ kind: "image" } as any, {} as any)).toBeNull();
164
+ });
165
+ });
166
+
84
167
  describe("gateway · registry", () => {
85
168
  it("lists the three supported channels", () => {
86
169
  expect(SUPPORTED_CHANNELS.sort()).toEqual(["feishu", "qq", "wecom"]);