@botcord/daemon 0.2.59 → 0.2.60

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 (39) hide show
  1. package/dist/config.d.ts +4 -1
  2. package/dist/config.js +2 -2
  3. package/dist/cross-room.js +3 -1
  4. package/dist/daemon-config-map.js +6 -0
  5. package/dist/daemon.js +21 -1
  6. package/dist/gateway/channels/feishu-registration.d.ts +35 -0
  7. package/dist/gateway/channels/feishu-registration.js +101 -0
  8. package/dist/gateway/channels/feishu.d.ts +16 -0
  9. package/dist/gateway/channels/feishu.js +459 -0
  10. package/dist/gateway/channels/index.d.ts +2 -0
  11. package/dist/gateway/channels/index.js +2 -0
  12. package/dist/gateway/channels/login-session.d.ts +9 -1
  13. package/dist/gateway/channels/login-session.js +1 -1
  14. package/dist/gateway/dispatcher.js +4 -3
  15. package/dist/gateway/policy-resolver.d.ts +10 -6
  16. package/dist/gateway/types.d.ts +1 -1
  17. package/dist/gateway-control.d.ts +8 -1
  18. package/dist/gateway-control.js +171 -18
  19. package/dist/provision.js +7 -1
  20. package/package.json +2 -1
  21. package/src/__tests__/cross-room.test.ts +2 -0
  22. package/src/__tests__/gateway-control.test.ts +84 -0
  23. package/src/__tests__/policy-updated-handler.test.ts +5 -7
  24. package/src/__tests__/third-party-gateway.test.ts +28 -0
  25. package/src/config.ts +6 -3
  26. package/src/cross-room.ts +3 -1
  27. package/src/daemon-config-map.ts +3 -0
  28. package/src/daemon.ts +24 -3
  29. package/src/gateway/__tests__/dispatcher.test.ts +10 -4
  30. package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
  31. package/src/gateway/channels/feishu-registration.ts +155 -0
  32. package/src/gateway/channels/feishu.ts +554 -0
  33. package/src/gateway/channels/index.ts +6 -0
  34. package/src/gateway/channels/login-session.ts +10 -2
  35. package/src/gateway/dispatcher.ts +4 -3
  36. package/src/gateway/policy-resolver.ts +19 -11
  37. package/src/gateway/types.ts +1 -1
  38. package/src/gateway-control.ts +188 -17
  39. package/src/provision.ts +13 -1
@@ -1903,7 +1903,7 @@ describe("Dispatcher", () => {
1903
1903
  expect(channel.sends.length).toBe(0);
1904
1904
  });
1905
1905
 
1906
- it("non-owner-chat room: timeout reply is suppressed (logged only)", async () => {
1906
+ it("non-owner-chat room: timeout sends a diagnostic reply", async () => {
1907
1907
  vi.useFakeTimers();
1908
1908
  try {
1909
1909
  const runtime = new FakeRuntime({ hang: true });
@@ -1920,13 +1920,16 @@ describe("Dispatcher", () => {
1920
1920
  await vi.advanceTimersByTimeAsync(501);
1921
1921
  await p;
1922
1922
  expect(runtime.calls[0].signal.aborted).toBe(true);
1923
- expect(channel.sends.length).toBe(0);
1923
+ expect(channel.sends.length).toBe(1);
1924
+ expect(channel.sends[0].message.text).toMatch(/Runtime timeout/);
1925
+ expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1926
+ expect(channel.sends[0].message.replyTo).toBe("m_to");
1924
1927
  } finally {
1925
1928
  vi.useRealTimers();
1926
1929
  }
1927
1930
  });
1928
1931
 
1929
- it("non-owner-chat room: runtime error reply is suppressed", async () => {
1932
+ it("non-owner-chat room: runtime error sends a diagnostic reply", async () => {
1930
1933
  const runtime = new FakeRuntime({ throwError: "boom" });
1931
1934
  const { dispatcher, channel } = await scaffold({
1932
1935
  runtimeFactory: () => runtime,
@@ -1937,7 +1940,10 @@ describe("Dispatcher", () => {
1937
1940
  conversation: { id: "rm_g_other", kind: "group" },
1938
1941
  }),
1939
1942
  );
1940
- expect(channel.sends.length).toBe(0);
1943
+ expect(channel.sends.length).toBe(1);
1944
+ expect(channel.sends[0].message.text).toContain("Runtime error: boom");
1945
+ expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1946
+ expect(channel.sends[0].message.replyTo).toBe("m_err");
1941
1947
  });
1942
1948
 
1943
1949
  // ─────────────────────────────────────────────────────────────────────
@@ -0,0 +1,306 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { createFeishuChannel } from "../channels/feishu.js";
6
+ import type {
7
+ ChannelStartContext,
8
+ GatewayInboundEnvelope,
9
+ GatewayLogger,
10
+ } from "../types.js";
11
+
12
+ const larkMock = vi.hoisted(() => ({
13
+ requests: [] as unknown[],
14
+ responses: [] as unknown[],
15
+ handlers: {} as Record<string, (data: unknown) => unknown>,
16
+ wsStartError: null as Error | null,
17
+ wsClosed: false,
18
+ }));
19
+
20
+ vi.mock("@larksuiteoapi/node-sdk", () => ({
21
+ AppType: { SelfBuild: "SelfBuild" },
22
+ Domain: { Feishu: "feishu", Lark: "lark" },
23
+ LoggerLevel: { info: "info" },
24
+ Client: vi.fn().mockImplementation(function Client() {
25
+ return {
26
+ request: vi.fn(async (args: unknown) => {
27
+ larkMock.requests.push(args);
28
+ const next = larkMock.responses.shift();
29
+ if (next instanceof Error) throw next;
30
+ return next ?? { code: 0, data: {} };
31
+ }),
32
+ };
33
+ }),
34
+ EventDispatcher: vi.fn().mockImplementation(function EventDispatcher() {
35
+ return {
36
+ register: vi.fn((handlers: Record<string, (data: unknown) => unknown>) => {
37
+ Object.assign(larkMock.handlers, handlers);
38
+ }),
39
+ };
40
+ }),
41
+ WSClient: vi.fn().mockImplementation(function WSClient() {
42
+ return {
43
+ start: vi.fn(() => {
44
+ if (larkMock.wsStartError) return Promise.reject(larkMock.wsStartError);
45
+ return Promise.resolve();
46
+ }),
47
+ close: vi.fn(() => {
48
+ larkMock.wsClosed = true;
49
+ }),
50
+ };
51
+ }),
52
+ }));
53
+
54
+ const SILENT_LOG: GatewayLogger = {
55
+ info: () => {},
56
+ warn: () => {},
57
+ error: () => {},
58
+ debug: () => {},
59
+ };
60
+
61
+ const stubConfig = {
62
+ channels: [],
63
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp" },
64
+ };
65
+
66
+ function makeStartCtx(abort: AbortController): {
67
+ ctx: ChannelStartContext;
68
+ envelopes: GatewayInboundEnvelope[];
69
+ statuses: Array<Record<string, unknown>>;
70
+ } {
71
+ const envelopes: GatewayInboundEnvelope[] = [];
72
+ const statuses: Array<Record<string, unknown>> = [];
73
+ return {
74
+ envelopes,
75
+ statuses,
76
+ ctx: {
77
+ config: stubConfig,
78
+ accountId: "ag_self",
79
+ abortSignal: abort.signal,
80
+ log: SILENT_LOG,
81
+ emit: async (env) => {
82
+ envelopes.push(env);
83
+ },
84
+ setStatus: (patch) => {
85
+ statuses.push({ ...patch });
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ let tmp: string;
92
+
93
+ beforeEach(() => {
94
+ tmp = mkdtempSync(path.join(tmpdir(), "feishu-channel-"));
95
+ larkMock.requests = [];
96
+ larkMock.responses = [];
97
+ larkMock.handlers = {};
98
+ larkMock.wsStartError = null;
99
+ larkMock.wsClosed = false;
100
+ });
101
+
102
+ afterEach(() => {
103
+ rmSync(tmp, { recursive: true, force: true });
104
+ });
105
+
106
+ describe("createFeishuChannel", () => {
107
+ it("normalizes non-text events and deduplicates repeated message ids", async () => {
108
+ larkMock.responses.push({
109
+ code: 0,
110
+ data: { pingBotInfo: { botID: "ou_bot", botName: "Bot" } },
111
+ });
112
+ const adapter = createFeishuChannel({
113
+ id: "gw_fs",
114
+ accountId: "ag_self",
115
+ appId: "cli_a",
116
+ appSecret: "sec",
117
+ allowedSenderIds: ["ou_alice"],
118
+ allowedChatIds: ["oc_chat"],
119
+ stateFile: path.join(tmp, "state.json"),
120
+ stateDebounceMs: 0,
121
+ });
122
+ const abort = new AbortController();
123
+ const { ctx, envelopes } = makeStartCtx(abort);
124
+ const started = adapter.start(ctx);
125
+ await vi.waitUntil(() => typeof larkMock.handlers["im.message.receive_v1"] === "function");
126
+
127
+ const event = {
128
+ sender: { sender_id: { open_id: "ou_alice" } },
129
+ message: {
130
+ message_id: "om_img_1",
131
+ chat_id: "oc_chat",
132
+ chat_type: "group",
133
+ message_type: "image",
134
+ content: JSON.stringify({ image_key: "img_v2_x" }),
135
+ },
136
+ };
137
+ await larkMock.handlers["im.message.receive_v1"]!(event);
138
+ await larkMock.handlers["im.message.receive_v1"]!(event);
139
+ abort.abort();
140
+ await started;
141
+
142
+ expect(envelopes).toHaveLength(1);
143
+ expect(envelopes[0]!.message.text).toBe("[image: img_v2_x]");
144
+ expect(envelopes[0]!.message.conversation.id).toBe("feishu:chat:oc_chat");
145
+ });
146
+
147
+ it("sends text replies through Feishu reply API with thread mode", async () => {
148
+ larkMock.responses.push({ code: 0, data: { message_id: "om_reply_1" } });
149
+ const adapter = createFeishuChannel({
150
+ id: "gw_fs",
151
+ accountId: "ag_self",
152
+ appId: "cli_a",
153
+ appSecret: "sec",
154
+ });
155
+
156
+ const result = await adapter.send({
157
+ log: SILENT_LOG,
158
+ message: {
159
+ channel: "gw_fs",
160
+ accountId: "ag_self",
161
+ conversationId: "feishu:chat:oc_chat",
162
+ threadId: "om_root",
163
+ replyTo: "om_parent",
164
+ text: "hello",
165
+ },
166
+ });
167
+
168
+ expect(result.providerMessageId).toBe("om_reply_1");
169
+ expect(larkMock.requests).toHaveLength(1);
170
+ const req = larkMock.requests[0] as { url: string; data: Record<string, unknown> };
171
+ expect(req.url).toBe("/open-apis/im/v1/messages/om_parent/reply");
172
+ expect(req.data.reply_in_thread).toBe(true);
173
+ expect(req.data.msg_type).toBe("text");
174
+ });
175
+
176
+ it("uploads image attachments and sends them as Feishu image replies", async () => {
177
+ larkMock.responses.push(
178
+ { code: 0, data: { image_key: "img_v2_uploaded" } },
179
+ { code: 0, data: { message_id: "om_image_reply" } },
180
+ );
181
+ const adapter = createFeishuChannel({
182
+ id: "gw_fs",
183
+ accountId: "ag_self",
184
+ appId: "cli_a",
185
+ appSecret: "sec",
186
+ });
187
+
188
+ const result = await adapter.send({
189
+ log: SILENT_LOG,
190
+ message: {
191
+ channel: "gw_fs",
192
+ accountId: "ag_self",
193
+ conversationId: "feishu:chat:oc_chat",
194
+ text: "",
195
+ replyTo: "om_parent",
196
+ attachments: [
197
+ {
198
+ data: new Uint8Array([1, 2, 3]),
199
+ filename: "shot.png",
200
+ contentType: "image/png",
201
+ },
202
+ ],
203
+ },
204
+ });
205
+
206
+ expect(result.providerMessageId).toBe("om_image_reply");
207
+ expect(larkMock.requests).toHaveLength(2);
208
+ const upload = larkMock.requests[0] as { url: string; data: Record<string, unknown> };
209
+ expect(upload.url).toBe("/open-apis/im/v1/images");
210
+ expect(upload.data.image_type).toBe("message");
211
+ const send = larkMock.requests[1] as { url: string; data: Record<string, unknown> };
212
+ expect(send.url).toBe("/open-apis/im/v1/messages/om_parent/reply");
213
+ expect(send.data.msg_type).toBe("image");
214
+ expect(JSON.parse(send.data.content as string)).toEqual({ image_key: "img_v2_uploaded" });
215
+ });
216
+
217
+ it("uploads file attachments and sends them as Feishu file messages", async () => {
218
+ larkMock.responses.push(
219
+ { code: 0, data: { file_key: "file_v2_uploaded" } },
220
+ { code: 0, data: { message_id: "om_file_msg" } },
221
+ );
222
+ const adapter = createFeishuChannel({
223
+ id: "gw_fs",
224
+ accountId: "ag_self",
225
+ appId: "cli_a",
226
+ appSecret: "sec",
227
+ });
228
+
229
+ const result = await adapter.send({
230
+ log: SILENT_LOG,
231
+ message: {
232
+ channel: "gw_fs",
233
+ accountId: "ag_self",
234
+ conversationId: "feishu:chat:oc_chat",
235
+ text: "",
236
+ attachments: [
237
+ {
238
+ data: new Uint8Array([4, 5, 6]),
239
+ filename: "report.pdf",
240
+ contentType: "application/pdf",
241
+ },
242
+ ],
243
+ },
244
+ });
245
+
246
+ expect(result.providerMessageId).toBe("om_file_msg");
247
+ expect(larkMock.requests).toHaveLength(2);
248
+ const upload = larkMock.requests[0] as { url: string; data: Record<string, unknown> };
249
+ expect(upload.url).toBe("/open-apis/im/v1/files");
250
+ expect(upload.data.file_type).toBe("pdf");
251
+ expect(upload.data.file_name).toBe("report.pdf");
252
+ const send = larkMock.requests[1] as { url: string; data: Record<string, unknown> };
253
+ expect(send.url).toBe("/open-apis/im/v1/messages");
254
+ expect(send.data.msg_type).toBe("file");
255
+ expect(JSON.parse(send.data.content as string)).toEqual({ file_key: "file_v2_uploaded" });
256
+ });
257
+
258
+ it("exposes typing as a safe no-op because Feishu has no bot typing API", async () => {
259
+ const debug = vi.fn();
260
+ const adapter = createFeishuChannel({
261
+ id: "gw_fs",
262
+ accountId: "ag_self",
263
+ appId: "cli_a",
264
+ appSecret: "sec",
265
+ });
266
+
267
+ await adapter.typing?.({
268
+ traceId: "feishu:om_1",
269
+ accountId: "ag_self",
270
+ conversationId: "feishu:chat:oc_chat",
271
+ log: { ...SILENT_LOG, debug },
272
+ });
273
+
274
+ expect(larkMock.requests).toHaveLength(0);
275
+ expect(debug).toHaveBeenCalledWith(
276
+ "feishu typing ignored: no native bot typing API",
277
+ expect.objectContaining({ channel: "gw_fs" }),
278
+ );
279
+ });
280
+
281
+ it("surfaces websocket start failures in channel status", async () => {
282
+ larkMock.responses.push({
283
+ code: 0,
284
+ data: { pingBotInfo: { botID: "ou_bot", botName: "Bot" } },
285
+ });
286
+ larkMock.wsStartError = new Error("ws denied");
287
+ const adapter = createFeishuChannel({
288
+ id: "gw_fs",
289
+ accountId: "ag_self",
290
+ appId: "cli_a",
291
+ appSecret: "sec",
292
+ allowedSenderIds: ["ou_alice"],
293
+ stateFile: path.join(tmp, "state.json"),
294
+ stateDebounceMs: 0,
295
+ });
296
+ const abort = new AbortController();
297
+ const { ctx, statuses } = makeStartCtx(abort);
298
+ const started = adapter.start(ctx);
299
+ await vi.waitUntil(() => statuses.some((s) => s.lastError === "ws denied"));
300
+ abort.abort();
301
+ await started;
302
+
303
+ expect(adapter.status()?.lastError).toBe("ws denied");
304
+ expect(adapter.status()?.authorized).toBe(false);
305
+ });
306
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Feishu/Lark PersonalAgent app registration helpers.
3
+ *
4
+ * Mirrors the flow used by `@larksuite/openclaw-lark-tools`:
5
+ * POST /oauth/v1/app/registration action=init
6
+ * POST /oauth/v1/app/registration action=begin
7
+ * POST /oauth/v1/app/registration action=poll
8
+ */
9
+
10
+ import type { FetchLike } from "./http-types.js";
11
+
12
+ export type FeishuDomain = "feishu" | "lark";
13
+
14
+ const FEISHU_ACCOUNTS_BASE = "https://accounts.feishu.cn";
15
+ const LARK_ACCOUNTS_BASE = "https://accounts.larksuite.com";
16
+
17
+ export interface FeishuRegistrationOptions {
18
+ domain?: FeishuDomain;
19
+ fetchImpl?: FetchLike;
20
+ }
21
+
22
+ export interface FeishuRegistrationStart {
23
+ deviceCode: string;
24
+ verificationUriComplete: string;
25
+ verificationUri?: string;
26
+ expiresIn: number;
27
+ interval: number;
28
+ domain: FeishuDomain;
29
+ raw: Record<string, unknown>;
30
+ }
31
+
32
+ export interface FeishuRegistrationPoll {
33
+ status: "pending" | "confirmed" | "expired" | "denied" | "failed";
34
+ appId?: string;
35
+ appSecret?: string;
36
+ userOpenId?: string;
37
+ domain: FeishuDomain;
38
+ interval?: number;
39
+ error?: string;
40
+ raw: Record<string, unknown>;
41
+ }
42
+
43
+ function baseForDomain(domain: FeishuDomain): string {
44
+ return domain === "lark" ? LARK_ACCOUNTS_BASE : FEISHU_ACCOUNTS_BASE;
45
+ }
46
+
47
+ function fetcher(opts: FeishuRegistrationOptions): FetchLike {
48
+ return opts.fetchImpl ?? ((globalThis.fetch as unknown) as FetchLike);
49
+ }
50
+
51
+ async function postRegistration(
52
+ action: string,
53
+ params: Record<string, string>,
54
+ opts: FeishuRegistrationOptions,
55
+ ): Promise<Record<string, unknown>> {
56
+ const domain = opts.domain ?? "feishu";
57
+ const res = await fetcher(opts)(`${baseForDomain(domain)}/oauth/v1/app/registration`, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
60
+ body: new URLSearchParams({ action, ...params }).toString(),
61
+ });
62
+ const raw = await res.text();
63
+ if (!raw) return {};
64
+ try {
65
+ return JSON.parse(raw) as Record<string, unknown>;
66
+ } catch {
67
+ throw new Error(`feishu registration ${action}: non-json response`);
68
+ }
69
+ }
70
+
71
+ export async function startFeishuRegistration(
72
+ opts: FeishuRegistrationOptions = {},
73
+ ): Promise<FeishuRegistrationStart> {
74
+ const domain = opts.domain ?? "feishu";
75
+ const init = await postRegistration("init", {}, { ...opts, domain });
76
+ const methods = Array.isArray(init.supported_auth_methods)
77
+ ? init.supported_auth_methods.map(String)
78
+ : [];
79
+ if (methods.length > 0 && !methods.includes("client_secret")) {
80
+ throw new Error("feishu registration: client_secret auth is not supported");
81
+ }
82
+ const begin = await postRegistration(
83
+ "begin",
84
+ {
85
+ archetype: "PersonalAgent",
86
+ auth_method: "client_secret",
87
+ request_user_info: "open_id",
88
+ },
89
+ { ...opts, domain },
90
+ );
91
+ const deviceCode = typeof begin.device_code === "string" ? begin.device_code : "";
92
+ const verificationUriComplete =
93
+ typeof begin.verification_uri_complete === "string"
94
+ ? begin.verification_uri_complete
95
+ : "";
96
+ if (!deviceCode || !verificationUriComplete) {
97
+ throw new Error("feishu registration: missing device_code or verification_uri_complete");
98
+ }
99
+ return {
100
+ deviceCode,
101
+ verificationUriComplete,
102
+ ...(typeof begin.verification_uri === "string"
103
+ ? { verificationUri: begin.verification_uri }
104
+ : {}),
105
+ expiresIn: typeof begin.expire_in === "number" ? begin.expire_in : 600,
106
+ interval: typeof begin.interval === "number" ? begin.interval : 5,
107
+ domain,
108
+ raw: begin,
109
+ };
110
+ }
111
+
112
+ export async function pollFeishuRegistration(
113
+ deviceCode: string,
114
+ opts: FeishuRegistrationOptions = {},
115
+ ): Promise<FeishuRegistrationPoll> {
116
+ const domain = opts.domain ?? "feishu";
117
+ const data = await postRegistration(
118
+ "poll",
119
+ { device_code: deviceCode },
120
+ { ...opts, domain },
121
+ );
122
+ const tenantBrand =
123
+ typeof (data.user_info as Record<string, unknown> | undefined)?.tenant_brand === "string"
124
+ ? String((data.user_info as Record<string, unknown>).tenant_brand)
125
+ : "";
126
+ const resolvedDomain: FeishuDomain = tenantBrand === "lark" ? "lark" : domain;
127
+ const appId = typeof data.client_id === "string" ? data.client_id : undefined;
128
+ const appSecret =
129
+ typeof data.client_secret === "string" ? data.client_secret : undefined;
130
+ if (appId && appSecret) {
131
+ const userInfo = data.user_info as Record<string, unknown> | undefined;
132
+ return {
133
+ status: "confirmed",
134
+ appId,
135
+ appSecret,
136
+ userOpenId: typeof userInfo?.open_id === "string" ? userInfo.open_id : undefined,
137
+ domain: resolvedDomain,
138
+ raw: data,
139
+ };
140
+ }
141
+ const error = typeof data.error === "string" ? data.error : "";
142
+ if (!error || error === "authorization_pending") {
143
+ return { status: "pending", domain: resolvedDomain, raw: data };
144
+ }
145
+ if (error === "slow_down") {
146
+ return { status: "pending", domain: resolvedDomain, interval: 10, raw: data };
147
+ }
148
+ if (error === "access_denied") {
149
+ return { status: "denied", domain: resolvedDomain, error, raw: data };
150
+ }
151
+ if (error === "expired_token" || error === "invalid_grant") {
152
+ return { status: "expired", domain: resolvedDomain, error, raw: data };
153
+ }
154
+ return { status: "failed", domain: resolvedDomain, error: error || "unknown", raw: data };
155
+ }