@botcord/daemon 0.2.59 → 0.2.61

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 (43) 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/botcord.d.ts +7 -0
  7. package/dist/gateway/channels/botcord.js +3 -1
  8. package/dist/gateway/channels/feishu-registration.d.ts +35 -0
  9. package/dist/gateway/channels/feishu-registration.js +101 -0
  10. package/dist/gateway/channels/feishu.d.ts +16 -0
  11. package/dist/gateway/channels/feishu.js +459 -0
  12. package/dist/gateway/channels/index.d.ts +2 -0
  13. package/dist/gateway/channels/index.js +2 -0
  14. package/dist/gateway/channels/login-session.d.ts +9 -1
  15. package/dist/gateway/channels/login-session.js +1 -1
  16. package/dist/gateway/dispatcher.js +7 -3
  17. package/dist/gateway/policy-resolver.d.ts +10 -6
  18. package/dist/gateway/types.d.ts +2 -1
  19. package/dist/gateway-control.d.ts +8 -1
  20. package/dist/gateway-control.js +171 -18
  21. package/dist/provision.js +7 -1
  22. package/package.json +2 -1
  23. package/src/__tests__/cross-room.test.ts +2 -0
  24. package/src/__tests__/gateway-control.test.ts +84 -0
  25. package/src/__tests__/policy-updated-handler.test.ts +5 -7
  26. package/src/__tests__/third-party-gateway.test.ts +28 -0
  27. package/src/config.ts +6 -3
  28. package/src/cross-room.ts +3 -1
  29. package/src/daemon-config-map.ts +3 -0
  30. package/src/daemon.ts +24 -3
  31. package/src/gateway/__tests__/botcord-channel.test.ts +77 -0
  32. package/src/gateway/__tests__/dispatcher.test.ts +14 -4
  33. package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
  34. package/src/gateway/channels/botcord.ts +10 -1
  35. package/src/gateway/channels/feishu-registration.ts +155 -0
  36. package/src/gateway/channels/feishu.ts +554 -0
  37. package/src/gateway/channels/index.ts +6 -0
  38. package/src/gateway/channels/login-session.ts +10 -2
  39. package/src/gateway/dispatcher.ts +7 -3
  40. package/src/gateway/policy-resolver.ts +19 -11
  41. package/src/gateway/types.ts +2 -1
  42. package/src/gateway-control.ts +188 -17
  43. package/src/provision.ts +13 -1
@@ -27,6 +27,9 @@ function makeClient(overrides: Partial<BotCordChannelClient> = {}): BotCordChann
27
27
  sendMessage: vi
28
28
  .fn()
29
29
  .mockResolvedValue({ hub_msg_id: "m_provider", queued: true, status: "queued" }),
30
+ sendTypedMessage: vi
31
+ .fn()
32
+ .mockResolvedValue({ hub_msg_id: "m_provider_typed", queued: true, status: "queued" }),
30
33
  getHubUrl: vi.fn().mockReturnValue("http://127.0.0.1:1"),
31
34
  ...overrides,
32
35
  };
@@ -138,6 +141,34 @@ describe("createBotCordChannel — send()", () => {
138
141
  expect(client.sendMessage).toHaveBeenCalledWith("rm_dm_1", "hey", {});
139
142
  expect(result.providerMessageId).toBeNull();
140
143
  });
144
+
145
+ it("sends runtime diagnostics as BotCord error envelopes", async () => {
146
+ const client = makeClient();
147
+ const channel = createBotCordChannel({
148
+ id: "botcord-main",
149
+ accountId: "ag_self",
150
+ agentId: "ag_self",
151
+ client,
152
+ });
153
+ const result = await channel.send({
154
+ message: {
155
+ channel: "botcord",
156
+ accountId: "ag_self",
157
+ conversationId: "rm_group_a",
158
+ threadId: "tp_42",
159
+ replyTo: "env_source",
160
+ type: "error",
161
+ text: "Runtime error: boom",
162
+ },
163
+ log: silentLog,
164
+ });
165
+ expect(client.sendTypedMessage).toHaveBeenCalledWith("rm_group_a", "error", "Runtime error: boom", {
166
+ topic: "tp_42",
167
+ replyTo: "env_source",
168
+ });
169
+ expect(client.sendMessage).not.toHaveBeenCalled();
170
+ expect(result.providerMessageId).toBe("m_provider_typed");
171
+ });
141
172
  });
142
173
 
143
174
  // ---------------------------------------------------------------------------
@@ -263,6 +294,52 @@ describe("createBotCordChannel — inbox normalization", () => {
263
294
  }
264
295
  });
265
296
 
297
+ it("acks error InboxMessages without dispatching them", async () => {
298
+ const server = await startAuthOkServer();
299
+ const errorMessage = makeInbox({
300
+ hub_msg_id: "m_error_1",
301
+ text: undefined,
302
+ envelope: {
303
+ type: "error",
304
+ from: "ag_peer",
305
+ payload: { error: { code: "agent_error", message: "Runtime error: boom" } },
306
+ } as InboxMessage["envelope"],
307
+ });
308
+ const client = makeClient({
309
+ pollInbox: vi.fn().mockResolvedValue({ messages: [errorMessage], count: 1, has_more: false }),
310
+ getHubUrl: vi.fn().mockReturnValue(server.url),
311
+ });
312
+ const channel = createBotCordChannel({
313
+ id: "botcord-main",
314
+ accountId: "ag_self",
315
+ agentId: "ag_self",
316
+ client,
317
+ hubBaseUrl: server.url,
318
+ });
319
+ const abort = new AbortController();
320
+ const emits: GatewayInboundEnvelope[] = [];
321
+ const startPromise = channel.start({
322
+ config: stubConfig,
323
+ accountId: "ag_self",
324
+ abortSignal: abort.signal,
325
+ log: silentLog,
326
+ emit: async (env) => {
327
+ emits.push(env);
328
+ },
329
+ setStatus: () => {},
330
+ });
331
+ try {
332
+ await vi.waitFor(() => {
333
+ expect(client.ackMessages).toHaveBeenCalledWith(["m_error_1"]);
334
+ });
335
+ expect(emits).toHaveLength(0);
336
+ } finally {
337
+ abort.abort();
338
+ await startPromise;
339
+ await server.close();
340
+ }
341
+ });
342
+
266
343
  it("marks rm_dm_ and rm_oc_ rooms as direct; rm_oc_ also sets streamable + user-kind", async () => {
267
344
  const { emits, server } = await startWithInbox([
268
345
  makeInbox({
@@ -492,6 +492,7 @@ describe("Dispatcher", () => {
492
492
  await dispatcher.handle(makeEnvelope({ id: "msg_error" }));
493
493
 
494
494
  expect(channel.sends.length).toBe(1);
495
+ expect(channel.sends[0].message.type).toBe("error");
495
496
  expect(channel.sends[0].message.text).toContain("Runtime error");
496
497
  expect(channel.sends[0].message.text).toContain("missing openclawAgent");
497
498
  });
@@ -1256,6 +1257,7 @@ describe("Dispatcher", () => {
1256
1257
 
1257
1258
  await dispatcher.handle(makeEnvelope({ id: "m1" }));
1258
1259
  expect(channel.sends.length).toBe(1);
1260
+ expect(channel.sends[0].message.type).toBe("error");
1259
1261
  expect(channel.sends[0].message.text).toContain("Runtime error");
1260
1262
  expect(channel.sends[0].message.text).toContain("boom");
1261
1263
  expect(store.all().length).toBe(0);
@@ -1903,7 +1905,7 @@ describe("Dispatcher", () => {
1903
1905
  expect(channel.sends.length).toBe(0);
1904
1906
  });
1905
1907
 
1906
- it("non-owner-chat room: timeout reply is suppressed (logged only)", async () => {
1908
+ it("non-owner-chat room: timeout sends a diagnostic reply", async () => {
1907
1909
  vi.useFakeTimers();
1908
1910
  try {
1909
1911
  const runtime = new FakeRuntime({ hang: true });
@@ -1920,13 +1922,17 @@ describe("Dispatcher", () => {
1920
1922
  await vi.advanceTimersByTimeAsync(501);
1921
1923
  await p;
1922
1924
  expect(runtime.calls[0].signal.aborted).toBe(true);
1923
- expect(channel.sends.length).toBe(0);
1925
+ expect(channel.sends.length).toBe(1);
1926
+ expect(channel.sends[0].message.type).toBe("error");
1927
+ expect(channel.sends[0].message.text).toMatch(/Runtime timeout/);
1928
+ expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1929
+ expect(channel.sends[0].message.replyTo).toBe("m_to");
1924
1930
  } finally {
1925
1931
  vi.useRealTimers();
1926
1932
  }
1927
1933
  });
1928
1934
 
1929
- it("non-owner-chat room: runtime error reply is suppressed", async () => {
1935
+ it("non-owner-chat room: runtime error sends a diagnostic reply", async () => {
1930
1936
  const runtime = new FakeRuntime({ throwError: "boom" });
1931
1937
  const { dispatcher, channel } = await scaffold({
1932
1938
  runtimeFactory: () => runtime,
@@ -1937,7 +1943,11 @@ describe("Dispatcher", () => {
1937
1943
  conversation: { id: "rm_g_other", kind: "group" },
1938
1944
  }),
1939
1945
  );
1940
- expect(channel.sends.length).toBe(0);
1946
+ expect(channel.sends.length).toBe(1);
1947
+ expect(channel.sends[0].message.type).toBe("error");
1948
+ expect(channel.sends[0].message.text).toContain("Runtime error: boom");
1949
+ expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1950
+ expect(channel.sends[0].message.replyTo).toBe("m_err");
1941
1951
  });
1942
1952
 
1943
1953
  // ─────────────────────────────────────────────────────────────────────
@@ -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
+ });
@@ -55,6 +55,12 @@ export interface BotCordChannelClient {
55
55
  text: string,
56
56
  options?: { replyTo?: string; topic?: string },
57
57
  ): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
58
+ sendTypedMessage?(
59
+ to: string,
60
+ type: "result" | "error",
61
+ text: string,
62
+ options?: { replyTo?: string; topic?: string },
63
+ ): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
58
64
  getHubUrl(): string;
59
65
  onTokenRefresh?: (token: string, expiresAt: number) => void;
60
66
  }
@@ -804,7 +810,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
804
810
  const options: { replyTo?: string; topic?: string } = {};
805
811
  if (message.replyTo) options.replyTo = message.replyTo;
806
812
  if (message.threadId) options.topic = message.threadId;
807
- const resp = await client.sendMessage(message.conversationId, message.text, options);
813
+ const resp =
814
+ message.type === "error" && client.sendTypedMessage
815
+ ? await client.sendTypedMessage(message.conversationId, "error", message.text, options)
816
+ : await client.sendMessage(message.conversationId, message.text, options);
808
817
  const providerMessageId =
809
818
  (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
810
819
  (resp && typeof (resp as { message_id?: unknown }).message_id === "string"
@@ -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
+ }