@botcord/daemon 0.2.58 → 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 (53) 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/diagnostics.d.ts +1 -0
  7. package/dist/diagnostics.js +35 -6
  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/channels/wechat.js +26 -2
  17. package/dist/gateway/dispatcher.d.ts +3 -0
  18. package/dist/gateway/dispatcher.js +190 -30
  19. package/dist/gateway/policy-resolver.d.ts +10 -6
  20. package/dist/gateway/types.d.ts +1 -1
  21. package/dist/gateway-control.d.ts +8 -1
  22. package/dist/gateway-control.js +171 -18
  23. package/dist/index.js +9 -3
  24. package/dist/log.d.ts +9 -0
  25. package/dist/log.js +89 -1
  26. package/dist/provision.js +7 -1
  27. package/package.json +2 -1
  28. package/src/__tests__/cross-room.test.ts +2 -0
  29. package/src/__tests__/diagnostics.test.ts +37 -1
  30. package/src/__tests__/gateway-control.test.ts +84 -0
  31. package/src/__tests__/log.test.ts +28 -1
  32. package/src/__tests__/policy-updated-handler.test.ts +5 -7
  33. package/src/__tests__/third-party-gateway.test.ts +28 -0
  34. package/src/__tests__/wechat-channel.test.ts +47 -0
  35. package/src/config.ts +6 -3
  36. package/src/cross-room.ts +3 -1
  37. package/src/daemon-config-map.ts +3 -0
  38. package/src/daemon.ts +24 -3
  39. package/src/diagnostics.ts +36 -6
  40. package/src/gateway/__tests__/dispatcher.test.ts +62 -4
  41. package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
  42. package/src/gateway/channels/feishu-registration.ts +155 -0
  43. package/src/gateway/channels/feishu.ts +554 -0
  44. package/src/gateway/channels/index.ts +6 -0
  45. package/src/gateway/channels/login-session.ts +10 -2
  46. package/src/gateway/channels/wechat.ts +29 -2
  47. package/src/gateway/dispatcher.ts +216 -29
  48. package/src/gateway/policy-resolver.ts +19 -11
  49. package/src/gateway/types.ts +1 -1
  50. package/src/gateway-control.ts +188 -17
  51. package/src/index.ts +9 -3
  52. package/src/log.ts +100 -1
  53. package/src/provision.ts +13 -1
@@ -78,6 +78,7 @@ export class Dispatcher {
78
78
  resolveHubUrl;
79
79
  transcript;
80
80
  queues = new Map();
81
+ deferredMultimodal = new Map();
81
82
  /**
82
83
  * Last `/hub/typing` ping timestamp per (accountId, conversationId).
83
84
  * Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
@@ -125,6 +126,10 @@ export class Dispatcher {
125
126
  await this.safeAck(envelope);
126
127
  return;
127
128
  }
129
+ const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
130
+ const route = resolveRoute(msg, this.config, managed);
131
+ const mode = resolveQueueMode(route, msg.conversation.kind);
132
+ const queueKey = buildQueueKey(msg);
128
133
  // Pre-skip: empty/whitespace text.
129
134
  const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
130
135
  if (!rawText) {
@@ -135,20 +140,76 @@ export class Dispatcher {
135
140
  // From here on, the inbound is a real conversation event — generate a
136
141
  // turnId and write the inbound transcript record.
137
142
  const turnId = randomUUID();
138
- const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
139
- const route = resolveRoute(msg, this.config, managed);
140
- const mode = resolveQueueMode(route, msg.conversation.kind);
141
- const queueKey = buildQueueKey(msg);
143
+ // Multimodal-only arrivals (files/images without sender-authored text)
144
+ // should not wake the runtime on their own. Ack them, record the inbound
145
+ // event, and prepend them to the next text-bearing turn for this queue.
146
+ if (isMultimodalOnlyMessage(msg)) {
147
+ await this.safeAck(envelope);
148
+ this.emitInbound(turnId, msg);
149
+ this.deferMultimodal(queueKey, { route, msg, channel, turnId, queuedAt: Date.now() });
150
+ this.log.info("dispatcher: deferred multimodal-only inbound", {
151
+ agentId: msg.accountId,
152
+ roomId: msg.conversation.id,
153
+ topicId: msg.conversation.threadId ?? null,
154
+ turnId,
155
+ messageId: msg.id,
156
+ senderId: msg.sender.id,
157
+ senderKind: msg.sender.kind,
158
+ mode,
159
+ queueKey,
160
+ });
161
+ if (this.onInbound) {
162
+ try {
163
+ await this.onInbound(msg);
164
+ }
165
+ catch (err) {
166
+ this.log.warn("dispatcher: onInbound threw — continuing", {
167
+ messageId: msg.id,
168
+ error: err instanceof Error ? err.message : String(err),
169
+ });
170
+ }
171
+ }
172
+ return;
173
+ }
174
+ const deferred = this.takeDeferredMultimodal(queueKey);
175
+ let dispatchMsg = msg;
176
+ let dispatchTurnId = turnId;
177
+ let dispatchRoute = route;
178
+ let dispatchChannel = channel;
179
+ let text = rawText;
180
+ let mergedFromDeferredTurnIds = [];
181
+ if (deferred.length > 0) {
182
+ const merged = this.mergeSerialBuffer([...deferred, { route, msg, channel, turnId }], queueKey);
183
+ if (merged) {
184
+ dispatchMsg = merged.msg;
185
+ dispatchTurnId = merged.turnId;
186
+ dispatchRoute = merged.route;
187
+ dispatchChannel = merged.channel;
188
+ text = merged.text;
189
+ mergedFromDeferredTurnIds = deferred.map((e) => e.turnId);
190
+ for (const entry of deferred) {
191
+ this.transcript.write({
192
+ ts: nowIso(),
193
+ kind: "dropped",
194
+ turnId: entry.turnId,
195
+ agentId: entry.msg.accountId,
196
+ roomId: entry.msg.conversation.id,
197
+ topicId: entry.msg.conversation.threadId ?? null,
198
+ reason: "batch_merged",
199
+ supersededBy: dispatchTurnId,
200
+ });
201
+ }
202
+ }
203
+ }
142
204
  // Compose the final user-turn text only for cancel-previous mode, where
143
205
  // the dispatcher consumes the pre-composed text directly. Serial mode
144
206
  // re-runs the composer at drain time on the merged message (so it sees
145
207
  // the full coalesced batch instead of any single arrival), so calling
146
208
  // the composer here would just be redundant work.
147
- let text = rawText;
148
209
  let composeFailedError;
149
210
  if (mode === "cancel-previous" && this.composeUserTurn) {
150
211
  try {
151
- const composed = this.composeUserTurn(msg);
212
+ const composed = this.composeUserTurn(dispatchMsg);
152
213
  if (typeof composed === "string" && composed.length > 0) {
153
214
  text = composed;
154
215
  }
@@ -156,7 +217,7 @@ export class Dispatcher {
156
217
  catch (err) {
157
218
  composeFailedError = err instanceof Error ? err.message : String(err);
158
219
  this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
159
- messageId: msg.id,
220
+ messageId: dispatchMsg.id,
160
221
  error: composeFailedError,
161
222
  });
162
223
  }
@@ -197,29 +258,29 @@ export class Dispatcher {
197
258
  if (this.attentionGate) {
198
259
  let wake = true;
199
260
  try {
200
- const result = this.attentionGate(msg);
261
+ const result = this.attentionGate(dispatchMsg);
201
262
  wake = result instanceof Promise ? await result : result;
202
263
  }
203
264
  catch (err) {
204
265
  this.log.warn("dispatcher: attentionGate threw — waking", {
205
- messageId: msg.id,
266
+ messageId: dispatchMsg.id,
206
267
  error: err instanceof Error ? err.message : String(err),
207
268
  });
208
269
  wake = true;
209
270
  }
210
271
  if (!wake) {
211
272
  this.log.debug("dispatcher skip turn: attention policy", {
212
- messageId: msg.id,
213
- accountId: msg.accountId,
214
- conversationId: msg.conversation.id,
273
+ messageId: dispatchMsg.id,
274
+ accountId: dispatchMsg.accountId,
275
+ conversationId: dispatchMsg.conversation.id,
215
276
  });
216
277
  this.transcript.write({
217
278
  ts: nowIso(),
218
279
  kind: "attention_skipped",
219
- turnId,
220
- agentId: msg.accountId,
221
- roomId: msg.conversation.id,
222
- topicId: msg.conversation.threadId ?? null,
280
+ turnId: dispatchTurnId,
281
+ agentId: dispatchMsg.accountId,
282
+ roomId: dispatchMsg.conversation.id,
283
+ topicId: dispatchMsg.conversation.threadId ?? null,
223
284
  reason: "attention_gate_false",
224
285
  });
225
286
  return;
@@ -229,19 +290,19 @@ export class Dispatcher {
229
290
  this.transcript.write({
230
291
  ts: nowIso(),
231
292
  kind: "compose_failed",
232
- turnId,
233
- agentId: msg.accountId,
234
- roomId: msg.conversation.id,
235
- topicId: msg.conversation.threadId ?? null,
293
+ turnId: dispatchTurnId,
294
+ agentId: dispatchMsg.accountId,
295
+ roomId: dispatchMsg.conversation.id,
296
+ topicId: dispatchMsg.conversation.threadId ?? null,
236
297
  error: composeFailedError,
237
298
  fallback: "raw_text",
238
299
  });
239
300
  }
240
301
  if (mode === "cancel-previous") {
241
- await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
302
+ await this.runCancelPrevious(queueKey, dispatchRoute, text, dispatchMsg, dispatchChannel, dispatchTurnId, mergedFromDeferredTurnIds);
242
303
  }
243
304
  else {
244
- await this.runSerial(queueKey, route, text, msg, channel, turnId);
305
+ await this.runSerial(queueKey, dispatchRoute, text, dispatchMsg, dispatchChannel, dispatchTurnId, mergedFromDeferredTurnIds);
245
306
  }
246
307
  }
247
308
  /** Snapshot of currently running turns keyed by queue key. */
@@ -283,7 +344,37 @@ export class Dispatcher {
283
344
  }
284
345
  return q;
285
346
  }
286
- async runCancelPrevious(queueKey, route, text, msg, channel, turnId) {
347
+ deferMultimodal(queueKey, entry) {
348
+ const list = this.deferredMultimodal.get(queueKey) ?? [];
349
+ list.push(entry);
350
+ while (list.length > MAX_BATCH_BUFFER_ENTRIES) {
351
+ const dropped = list.shift();
352
+ this.log.warn("dispatcher: deferred multimodal buffer overflow — dropped oldest", {
353
+ queueKey,
354
+ droppedMessageId: dropped.msg.id,
355
+ bufferCap: MAX_BATCH_BUFFER_ENTRIES,
356
+ });
357
+ this.transcript.write({
358
+ ts: nowIso(),
359
+ kind: "dropped",
360
+ turnId: dropped.turnId,
361
+ agentId: dropped.msg.accountId,
362
+ roomId: dropped.msg.conversation.id,
363
+ topicId: dropped.msg.conversation.threadId ?? null,
364
+ reason: "queue_overflow",
365
+ supersededBy: null,
366
+ });
367
+ }
368
+ this.deferredMultimodal.set(queueKey, list);
369
+ }
370
+ takeDeferredMultimodal(queueKey) {
371
+ const list = this.deferredMultimodal.get(queueKey);
372
+ if (!list || list.length === 0)
373
+ return [];
374
+ this.deferredMultimodal.delete(queueKey);
375
+ return list;
376
+ }
377
+ async runCancelPrevious(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds = []) {
287
378
  const q = this.getQueue(queueKey);
288
379
  // Bump the generation on every arrival. Older arrivals still awaiting
289
380
  // the prior turn's teardown will observe `myGen !== q.cancelGen` when
@@ -342,7 +433,7 @@ export class Dispatcher {
342
433
  });
343
434
  return;
344
435
  }
345
- await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
436
+ await this.runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds);
346
437
  }
347
438
  /**
348
439
  * Serial mode with coalesce-on-drain semantics:
@@ -362,7 +453,7 @@ export class Dispatcher {
362
453
  * merged message so the runtime sees a single coherent prompt covering all
363
454
  * coalesced messages.
364
455
  */
365
- async runSerial(queueKey, route, _text, msg, channel, turnId) {
456
+ async runSerial(queueKey, route, _text, msg, channel, turnId, mergedFromTurnIds = []) {
366
457
  const q = this.getQueue(queueKey);
367
458
  q.serialBuffer.push({ route, msg, channel, turnId });
368
459
  while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
@@ -409,8 +500,10 @@ export class Dispatcher {
409
500
  });
410
501
  }
411
502
  }
412
- const mergedFromTurnIds = drained.length > 1 ? drained.slice(0, -1).map((e) => e.turnId) : [];
413
- await this.runTurn(queueKey, merged.route, merged.text, merged.msg, merged.channel, merged.turnId, mergedFromTurnIds);
503
+ const mergedTurnIds = drained.length > 1
504
+ ? [...mergedFromTurnIds, ...drained.slice(0, -1).map((e) => e.turnId)]
505
+ : mergedFromTurnIds;
506
+ await this.runTurn(queueKey, merged.route, merged.text, merged.msg, merged.channel, merged.turnId, mergedTurnIds);
414
507
  }
415
508
  }
416
509
  finally {
@@ -478,8 +571,13 @@ export class Dispatcher {
478
571
  const latestRaw = latest.msg.raw ?? {};
479
572
  const mergedRaw = { ...latestRaw, batch: items };
480
573
  const anyMentioned = entries.some((e) => e.msg.mentioned === true);
574
+ const mergedText = entries
575
+ .map((e) => (typeof e.msg.text === "string" ? e.msg.text.trim() : ""))
576
+ .filter((s) => s.length > 0)
577
+ .join("\n");
481
578
  const mergedMsg = {
482
579
  ...latest.msg,
580
+ ...(mergedText ? { text: mergedText } : {}),
483
581
  mentioned: anyMentioned,
484
582
  raw: mergedRaw,
485
583
  };
@@ -905,6 +1003,7 @@ export class Dispatcher {
905
1003
  // own loop-risk accounting downstream.
906
1004
  const isOwnerChat = isOwnerChatRoom(msg);
907
1005
  const canDeliverRuntimeText = isOwnerChat || !isBotCordChannel(channel);
1006
+ const canDeliverRuntimeDiagnostics = canDeliverRuntimeText || isBotCordChannel(channel);
908
1007
  if (slot.timedOut) {
909
1008
  this.transcript.write({
910
1009
  ts: nowIso(),
@@ -917,7 +1016,7 @@ export class Dispatcher {
917
1016
  error: `runtime timeout after ${this.turnTimeoutMs}ms`,
918
1017
  durationMs: Date.now() - slot.dispatchedAt,
919
1018
  });
920
- if (canDeliverRuntimeText) {
1019
+ if (canDeliverRuntimeDiagnostics) {
921
1020
  await this.sendReply(channel, {
922
1021
  channel: msg.channel,
923
1022
  accountId: msg.accountId,
@@ -962,7 +1061,7 @@ export class Dispatcher {
962
1061
  error: errMsg,
963
1062
  durationMs: Date.now() - slot.dispatchedAt,
964
1063
  });
965
- if (canDeliverRuntimeText) {
1064
+ if (canDeliverRuntimeDiagnostics) {
966
1065
  await this.sendReply(channel, {
967
1066
  channel: msg.channel,
968
1067
  accountId: msg.accountId,
@@ -1052,7 +1151,7 @@ export class Dispatcher {
1052
1151
  runtime: route.runtime,
1053
1152
  error: result.error,
1054
1153
  });
1055
- if (canDeliverRuntimeText) {
1154
+ if (canDeliverRuntimeDiagnostics) {
1056
1155
  const sendResult = await this.sendReply(channel, {
1057
1156
  channel: msg.channel,
1058
1157
  accountId: msg.accountId,
@@ -1296,6 +1395,67 @@ function isOwnerChatRoom(msg) {
1296
1395
  function isBotCordChannel(channel) {
1297
1396
  return channel.type === "botcord" || channel.id === "botcord";
1298
1397
  }
1398
+ function isMultimodalOnlyMessage(msg) {
1399
+ if (!hasMultimodalContent(msg.raw))
1400
+ return false;
1401
+ return !hasAuthoredText(msg.raw);
1402
+ }
1403
+ function hasAuthoredText(raw) {
1404
+ if (!raw || typeof raw !== "object")
1405
+ return false;
1406
+ const obj = raw;
1407
+ const batch = obj.batch;
1408
+ if (Array.isArray(batch))
1409
+ return batch.some((item) => hasAuthoredText(item));
1410
+ if (typeof obj.text === "string" && obj.text.trim().length > 0) {
1411
+ // BotCord's /hub/inbox `text` may be synthesized from attachment metadata
1412
+ // when payload text is empty, so prefer envelope payload below when present.
1413
+ if (!obj.envelope || typeof obj.envelope !== "object")
1414
+ return true;
1415
+ }
1416
+ const envelope = obj.envelope;
1417
+ const payload = envelope?.payload;
1418
+ if (payload) {
1419
+ for (const key of ["text", "body", "message"]) {
1420
+ const value = payload[key];
1421
+ if (typeof value === "string" && value.trim().length > 0)
1422
+ return true;
1423
+ }
1424
+ return false;
1425
+ }
1426
+ const itemList = obj.item_list;
1427
+ if (Array.isArray(itemList)) {
1428
+ return itemList.some((item) => {
1429
+ if (!item || typeof item !== "object")
1430
+ return false;
1431
+ const textItem = item.text_item;
1432
+ return typeof textItem?.text === "string" && textItem.text.trim().length > 0;
1433
+ });
1434
+ }
1435
+ return typeof obj.text === "string" && obj.text.trim().length > 0;
1436
+ }
1437
+ function hasMultimodalContent(raw) {
1438
+ if (!raw || typeof raw !== "object")
1439
+ return false;
1440
+ const obj = raw;
1441
+ const batch = obj.batch;
1442
+ if (Array.isArray(batch))
1443
+ return batch.some((item) => hasMultimodalContent(item));
1444
+ const envelope = obj.envelope;
1445
+ const payload = envelope?.payload;
1446
+ const attachments = payload?.attachments;
1447
+ if (Array.isArray(attachments) && attachments.length > 0)
1448
+ return true;
1449
+ const itemList = obj.item_list;
1450
+ if (Array.isArray(itemList)) {
1451
+ return itemList.some((item) => {
1452
+ if (!item || typeof item !== "object")
1453
+ return false;
1454
+ return item.type !== 1;
1455
+ });
1456
+ }
1457
+ return false;
1458
+ }
1299
1459
  function resolveQueueMode(route, kind) {
1300
1460
  if (route.queueMode)
1301
1461
  return route.queueMode;
@@ -22,25 +22,29 @@
22
22
  * the agent is revoked or the cache must rebuild from scratch.
23
23
  */
24
24
  import type { AttentionPolicy } from "@botcord/protocol-core";
25
+ export type DaemonAttentionPolicy = Omit<AttentionPolicy, "mode"> & {
26
+ mode: AttentionPolicy["mode"] | "allowed_senders";
27
+ allowedSenderIds?: string[];
28
+ };
25
29
  /** Public surface — kept narrow so the dispatcher can mock easily in tests. */
26
30
  export interface PolicyResolverLike {
27
- resolve(agentId: string, roomId: string | null): Promise<AttentionPolicy>;
31
+ resolve(agentId: string, roomId: string | null): Promise<DaemonAttentionPolicy>;
28
32
  invalidate(agentId: string, roomId?: string): void;
29
33
  /**
30
34
  * Install (or replace) the cached policy entry for an agent / room. Used
31
35
  * by the `policy_updated` control-frame handler to apply embedded policy
32
36
  * payloads without forcing a refetch.
33
37
  */
34
- put(agentId: string, roomId: string | null, policy: AttentionPolicy): void;
38
+ put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void;
35
39
  }
36
40
  export interface PolicyResolverOptions {
37
41
  /** Fetcher for the per-agent default. Returning `undefined` means "no policy known"; the resolver falls back to `mode=always`. */
38
- fetchGlobal: (agentId: string) => Promise<AttentionPolicy | undefined>;
42
+ fetchGlobal: (agentId: string) => Promise<DaemonAttentionPolicy | undefined>;
39
43
  /**
40
44
  * Optional per-room fetcher. PR2 supplies this; PR3 leaves it
41
45
  * unimplemented and the resolver collapses to the global policy.
42
46
  */
43
- fetchEffective?: (agentId: string, roomId: string) => Promise<AttentionPolicy | undefined>;
47
+ fetchEffective?: (agentId: string, roomId: string) => Promise<DaemonAttentionPolicy | undefined>;
44
48
  /** Cache TTL in milliseconds. Defaults to 5 minutes. */
45
49
  ttlMs?: number;
46
50
  }
@@ -50,8 +54,8 @@ export declare class PolicyResolver implements PolicyResolverLike {
50
54
  private readonly ttlMs;
51
55
  private readonly cache;
52
56
  constructor(opts: PolicyResolverOptions);
53
- resolve(agentId: string, roomId: string | null): Promise<AttentionPolicy>;
57
+ resolve(agentId: string, roomId: string | null): Promise<DaemonAttentionPolicy>;
54
58
  private safeFetch;
55
59
  invalidate(agentId: string, roomId?: string): void;
56
- put(agentId: string, roomId: string | null, policy: AttentionPolicy): void;
60
+ put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void;
57
61
  }
@@ -171,7 +171,7 @@ export interface ChannelStatusSnapshot {
171
171
  lastStopAt?: number;
172
172
  lastError?: string | null;
173
173
  /** Third-party provider id when this channel is not the built-in BotCord. */
174
- provider?: "wechat" | "telegram";
174
+ provider?: "wechat" | "telegram" | "feishu";
175
175
  /** Last time the adapter polled the upstream provider (ms epoch). */
176
176
  lastPollAt?: number;
177
177
  /** Last time the adapter accepted an inbound message (ms epoch). */
@@ -13,9 +13,10 @@ import type { Gateway } from "./gateway/index.js";
13
13
  import { type DaemonConfig } from "./config.js";
14
14
  import { LoginSessionStore } from "./gateway/channels/login-session.js";
15
15
  import { getBotQrcode, getQrcodeStatus } from "./gateway/channels/wechat-login.js";
16
+ import { pollFeishuRegistration, startFeishuRegistration } from "./gateway/channels/feishu-registration.js";
16
17
  import type { FetchLike } from "./gateway/channels/http-types.js";
17
18
  type AckBody = Omit<ControlAck, "id">;
18
- type GatewayProvider = "telegram" | "wechat";
19
+ type GatewayProvider = "telegram" | "wechat" | "feishu";
19
20
  interface UpsertGatewayParams {
20
21
  id: string;
21
22
  type: GatewayProvider;
@@ -28,6 +29,7 @@ interface UpsertGatewayParams {
28
29
  };
29
30
  settings?: {
30
31
  baseUrl?: string;
32
+ domain?: "feishu" | "lark";
31
33
  allowedSenderIds?: string[];
32
34
  allowedChatIds?: string[];
33
35
  splitAt?: number;
@@ -45,6 +47,7 @@ interface GatewayLoginStartParams {
45
47
  accountId: string;
46
48
  gatewayId?: string;
47
49
  baseUrl?: string;
50
+ domain?: "feishu" | "lark";
48
51
  }
49
52
  interface GatewayLoginStatusParams {
50
53
  provider: GatewayProvider;
@@ -75,6 +78,10 @@ export interface GatewayControlContext {
75
78
  getBotQrcode: typeof getBotQrcode;
76
79
  getQrcodeStatus: typeof getQrcodeStatus;
77
80
  };
81
+ feishuLoginClient?: {
82
+ startFeishuRegistration: typeof startFeishuRegistration;
83
+ pollFeishuRegistration: typeof pollFeishuRegistration;
84
+ };
78
85
  /** Override the global fetch — used by `test_gateway` for Telegram getMe. */
79
86
  fetchImpl?: FetchLike;
80
87
  }