@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
@@ -12,6 +12,7 @@ import { loadConfig, saveConfig, resolveConfiguredAgentIds, } from "./config.js"
12
12
  import { deleteGatewaySecret, loadGatewaySecret, saveGatewaySecret, } from "./gateway/channels/secret-store.js";
13
13
  import { LoginSessionStore, maskTokenPreview, mintLoginId, } from "./gateway/channels/login-session.js";
14
14
  import { DEFAULT_WECHAT_BASE_URL, getBotQrcode, getQrcodeStatus, } from "./gateway/channels/wechat-login.js";
15
+ import { pollFeishuRegistration, startFeishuRegistration, } from "./gateway/channels/feishu-registration.js";
15
16
  import { WECHAT_BASE_INFO, wechatHeaders } from "./gateway/channels/wechat-http.js";
16
17
  import { assertSafeBaseUrl, UnsafeBaseUrlError } from "./gateway/channels/url-guard.js";
17
18
  import { log as daemonLog } from "./log.js";
@@ -24,6 +25,7 @@ export function createGatewayControl(ctx) {
24
25
  const cfgIO = ctx.configIO ?? { load: loadConfig, save: saveConfig };
25
26
  const sessions = ctx.loginSessions ?? new LoginSessionStore();
26
27
  const wechatLogin = ctx.wechatLoginClient ?? { getBotQrcode, getQrcodeStatus };
28
+ const feishuLogin = ctx.feishuLoginClient ?? { startFeishuRegistration, pollFeishuRegistration };
27
29
  // W7: validate fetch availability at construction so a missing global is
28
30
  // diagnosed at startup, not during the first control frame. Tests inject
29
31
  // `ctx.fetchImpl` explicitly and bypass the global lookup entirely.
@@ -79,9 +81,13 @@ export function createGatewayControl(ctx) {
79
81
  };
80
82
  }
81
83
  // Provider-specific secret resolution.
82
- let botToken;
84
+ let secretPayload;
85
+ let tokenPreviewSource;
86
+ let feishuAppId;
87
+ let feishuDomain;
88
+ let feishuUserOpenId;
83
89
  if (params.type === "telegram") {
84
- botToken = params.secret?.botToken;
90
+ const botToken = params.secret?.botToken;
85
91
  if (!botToken) {
86
92
  // Allow updates that only flip enabled/whitelist — only require a
87
93
  // token when none is on disk yet.
@@ -89,7 +95,12 @@ export function createGatewayControl(ctx) {
89
95
  if (!existing?.botToken) {
90
96
  return badParams("upsert_gateway: telegram requires secret.botToken on first install");
91
97
  }
92
- botToken = existing.botToken;
98
+ secretPayload = { botToken: existing.botToken };
99
+ tokenPreviewSource = existing.botToken;
100
+ }
101
+ else {
102
+ secretPayload = { botToken };
103
+ tokenPreviewSource = botToken;
93
104
  }
94
105
  }
95
106
  else if (params.type === "wechat") {
@@ -122,10 +133,48 @@ export function createGatewayControl(ctx) {
122
133
  error: { code: "login_unconfirmed", message: "wechat login session has no bot token yet" },
123
134
  };
124
135
  }
125
- botToken = session.botToken;
136
+ secretPayload = { botToken: session.botToken };
137
+ tokenPreviewSource = session.botToken;
126
138
  // Bind the session to its eventual gateway id for forensic logging.
127
139
  sessions.update(loginId, { gatewayId: params.id });
128
140
  }
141
+ else if (params.type === "feishu") {
142
+ const loginId = params.loginId;
143
+ if (!loginId) {
144
+ return badParams("upsert_gateway: feishu requires loginId");
145
+ }
146
+ const session = sessions.get(loginId);
147
+ if (!session) {
148
+ return {
149
+ ok: false,
150
+ error: { code: "login_expired", message: `feishu login session "${loginId}" not found or expired` },
151
+ };
152
+ }
153
+ if (session.provider !== "feishu") {
154
+ return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
155
+ }
156
+ if (session.accountId !== params.accountId) {
157
+ return {
158
+ ok: false,
159
+ error: {
160
+ code: "login_account_mismatch",
161
+ message: "feishu login session accountId does not match upsert request",
162
+ },
163
+ };
164
+ }
165
+ if (!session.appId || !session.appSecret) {
166
+ return {
167
+ ok: false,
168
+ error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
169
+ };
170
+ }
171
+ secretPayload = { appSecret: session.appSecret };
172
+ tokenPreviewSource = session.appSecret;
173
+ feishuAppId = session.appId;
174
+ feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
175
+ feishuUserOpenId = session.userOpenId;
176
+ sessions.update(loginId, { gatewayId: params.id });
177
+ }
129
178
  else {
130
179
  return badParams(`upsert_gateway: unknown provider "${params.type}"`);
131
180
  }
@@ -141,7 +190,7 @@ export function createGatewayControl(ctx) {
141
190
  : null;
142
191
  // Persist secret first (so a config write that succeeds is never
143
192
  // followed by a missing-secret crash). Atomic rename inside saveSecret.
144
- const secretFile = saveGatewaySecret(params.id, { botToken });
193
+ const secretFile = saveGatewaySecret(params.id, secretPayload);
145
194
  // Update or insert the third-party gateway profile in config.
146
195
  const enabled = params.enabled !== false;
147
196
  const next = upsertProfileInConfig(cfg, {
@@ -154,6 +203,9 @@ export function createGatewayControl(ctx) {
154
203
  allowedSenderIds: params.settings?.allowedSenderIds,
155
204
  allowedChatIds: params.settings?.allowedChatIds,
156
205
  splitAt: params.settings?.splitAt,
206
+ appId: feishuAppId,
207
+ domain: feishuDomain,
208
+ userOpenId: feishuUserOpenId,
157
209
  });
158
210
  cfgIO.save(next);
159
211
  // Hot-plug. removeChannel is a no-op when the id isn't registered, so
@@ -166,7 +218,10 @@ export function createGatewayControl(ctx) {
166
218
  // best-effort
167
219
  }
168
220
  try {
169
- await ctx.gateway.addChannel(buildChannelConfig(params, secretFile));
221
+ await ctx.gateway.addChannel(buildChannelConfig(params, secretFile, {
222
+ ...(feishuAppId ? { appId: feishuAppId } : {}),
223
+ ...(feishuDomain ? { domain: feishuDomain } : {}),
224
+ }));
170
225
  }
171
226
  catch (addErr) {
172
227
  const message = addErr instanceof Error ? addErr.message : String(addErr);
@@ -210,8 +265,12 @@ export function createGatewayControl(ctx) {
210
265
  allowedSenderIds: prevProfile.allowedSenderIds,
211
266
  allowedChatIds: prevProfile.allowedChatIds,
212
267
  splitAt: prevProfile.splitAt,
268
+ domain: prevProfile.domain,
213
269
  },
214
- }, secretFile));
270
+ }, secretFile, {
271
+ ...(prevProfile.appId ? { appId: prevProfile.appId } : {}),
272
+ ...(prevProfile.domain ? { domain: prevProfile.domain } : {}),
273
+ }));
215
274
  }
216
275
  }
217
276
  catch {
@@ -246,7 +305,10 @@ export function createGatewayControl(ctx) {
246
305
  type: params.type,
247
306
  accountId: params.accountId,
248
307
  enabled,
249
- tokenPreview: maskTokenPreview(botToken),
308
+ tokenPreview: maskTokenPreview(tokenPreviewSource),
309
+ ...(feishuAppId ? { appId: feishuAppId } : {}),
310
+ ...(feishuDomain ? { domain: feishuDomain } : {}),
311
+ ...(feishuUserOpenId ? { userOpenId: feishuUserOpenId } : {}),
250
312
  ...(liveStatus ? { status: pickStatus(liveStatus) } : {}),
251
313
  };
252
314
  daemonLog.info("upsert_gateway applied", {
@@ -352,9 +414,8 @@ export function createGatewayControl(ctx) {
352
414
  return { ok: true, result };
353
415
  }
354
416
  }
355
- // WeChat: iLink has no no-side-effect probe today. Fall back to the
356
- // adapter's last poll snapshot. `authorized === true` means the secret
357
- // is loaded and at least one poll succeeded.
417
+ // WeChat/Feishu: fall back to the adapter snapshot. `authorized === true`
418
+ // means the secret is loaded and the provider client started.
358
419
  const snap = ctx.gateway.snapshot().channels[profile.id];
359
420
  const result = snap
360
421
  ? {
@@ -367,7 +428,7 @@ export function createGatewayControl(ctx) {
367
428
  },
368
429
  ...(snap.lastError ? { error: snap.lastError } : {}),
369
430
  }
370
- : { id: profile.id, ok: false, error: "wechat channel not running" };
431
+ : { id: profile.id, ok: false, error: `${profile.type} channel not running` };
371
432
  return { ok: true, result };
372
433
  }
373
434
  // --- gateway_login_start ------------------------------------------------
@@ -378,9 +439,7 @@ export function createGatewayControl(ctx) {
378
439
  if (!params.accountId || typeof params.accountId !== "string") {
379
440
  return badParams("gateway_login_start: accountId is required");
380
441
  }
381
- if (params.provider !== "wechat") {
382
- // Telegram has no qrcode flow; surface a clear error so the dashboard
383
- // can fall through to the token form.
442
+ if (params.provider !== "wechat" && params.provider !== "feishu") {
384
443
  return badParams(`gateway_login_start: provider "${params.provider}" does not require login`);
385
444
  }
386
445
  // W1: SSRF guard — `baseUrl` flows directly into an authenticated fetch.
@@ -392,6 +451,38 @@ export function createGatewayControl(ctx) {
392
451
  return badParams(urlErr.message);
393
452
  throw urlErr;
394
453
  }
454
+ if (params.provider === "feishu") {
455
+ const domain = params.domain === "lark" ? "lark" : "feishu";
456
+ try {
457
+ const r = await feishuLogin.startFeishuRegistration({ domain });
458
+ const loginId = mintLoginId("feishu");
459
+ const session = sessions.create({
460
+ loginId,
461
+ accountId: params.accountId,
462
+ ...(params.gatewayId ? { gatewayId: params.gatewayId } : {}),
463
+ provider: "feishu",
464
+ qrcode: r.deviceCode,
465
+ qrcodeUrl: r.verificationUriComplete,
466
+ domain: r.domain,
467
+ });
468
+ const result = {
469
+ loginId,
470
+ qrcode: r.deviceCode,
471
+ qrcodeUrl: r.verificationUriComplete,
472
+ expiresAt: session.expiresAt,
473
+ };
474
+ daemonLog.info("gateway_login_start", { provider: "feishu", loginId, accountId: params.accountId });
475
+ return { ok: true, result };
476
+ }
477
+ catch (err) {
478
+ const message = err instanceof Error ? err.message : String(err);
479
+ daemonLog.warn("gateway_login_start.feishu failed", { error: message });
480
+ return {
481
+ ok: false,
482
+ error: { code: "provider_unreachable", message },
483
+ };
484
+ }
485
+ }
395
486
  const baseUrl = params.baseUrl ?? DEFAULT_WECHAT_BASE_URL;
396
487
  let qrcode;
397
488
  let qrcodeUrl;
@@ -466,8 +557,55 @@ export function createGatewayControl(ctx) {
466
557
  };
467
558
  return { ok: true, result };
468
559
  }
560
+ if (params.provider === "feishu") {
561
+ if (!session.qrcode) {
562
+ return {
563
+ ok: false,
564
+ error: { code: "no_qrcode", message: "login session has no device code to poll" },
565
+ };
566
+ }
567
+ try {
568
+ const probe = await feishuLogin.pollFeishuRegistration(session.qrcode, {
569
+ domain: session.domain ?? "feishu",
570
+ });
571
+ if (probe.status === "confirmed" && probe.appId && probe.appSecret) {
572
+ const tokenPreview = maskTokenPreview(probe.appSecret);
573
+ sessions.update(params.loginId, {
574
+ appId: probe.appId,
575
+ appSecret: probe.appSecret,
576
+ domain: probe.domain,
577
+ userOpenId: probe.userOpenId,
578
+ tokenPreview,
579
+ });
580
+ const result = {
581
+ status: "confirmed",
582
+ appId: probe.appId,
583
+ domain: probe.domain,
584
+ userOpenId: probe.userOpenId,
585
+ tokenPreview,
586
+ };
587
+ return { ok: true, result };
588
+ }
589
+ const status = probe.status === "denied"
590
+ ? "failed"
591
+ : probe.status === "expired"
592
+ ? "expired"
593
+ : probe.status === "failed"
594
+ ? "failed"
595
+ : "pending";
596
+ const result = { status, domain: probe.domain };
597
+ return { ok: true, result };
598
+ }
599
+ catch (err) {
600
+ const message = err instanceof Error ? err.message : String(err);
601
+ daemonLog.warn("gateway_login_status.feishu failed", { error: message });
602
+ return {
603
+ ok: false,
604
+ error: { code: "provider_unreachable", message },
605
+ };
606
+ }
607
+ }
469
608
  if (params.provider !== "wechat") {
470
- // Future provider hook — today only WeChat poll path exists.
471
609
  return badParams(`gateway_login_status: provider "${params.provider}" not supported`);
472
610
  }
473
611
  if (!session.qrcode) {
@@ -612,7 +750,7 @@ function badParams(message) {
612
750
  return { ok: false, error: { code: "bad_params", message } };
613
751
  }
614
752
  function isProvider(p) {
615
- return p === "telegram" || p === "wechat";
753
+ return p === "telegram" || p === "wechat" || p === "feishu";
616
754
  }
617
755
  function validateUpsertParams(p) {
618
756
  if (!p.id || typeof p.id !== "string")
@@ -631,6 +769,9 @@ function annotateProfile(p, status) {
631
769
  ...(p.label !== undefined ? { label: p.label } : {}),
632
770
  enabled: p.enabled !== false,
633
771
  ...(p.baseUrl !== undefined ? { baseUrl: p.baseUrl } : {}),
772
+ ...(p.appId !== undefined ? { appId: p.appId } : {}),
773
+ ...(p.domain !== undefined ? { domain: p.domain } : {}),
774
+ ...(p.userOpenId !== undefined ? { userOpenId: p.userOpenId } : {}),
634
775
  ...(p.allowedSenderIds !== undefined ? { allowedSenderIds: p.allowedSenderIds } : {}),
635
776
  ...(p.allowedChatIds !== undefined ? { allowedChatIds: p.allowedChatIds } : {}),
636
777
  ...(p.splitAt !== undefined ? { splitAt: p.splitAt } : {}),
@@ -674,6 +815,12 @@ function compactProfile(p) {
674
815
  out.enabled = p.enabled;
675
816
  if (p.baseUrl !== undefined)
676
817
  out.baseUrl = p.baseUrl;
818
+ if (p.appId !== undefined)
819
+ out.appId = p.appId;
820
+ if (p.domain !== undefined)
821
+ out.domain = p.domain;
822
+ if (p.userOpenId !== undefined)
823
+ out.userOpenId = p.userOpenId;
677
824
  if (p.allowedSenderIds !== undefined)
678
825
  out.allowedSenderIds = p.allowedSenderIds;
679
826
  if (p.allowedChatIds !== undefined)
@@ -686,7 +833,7 @@ function compactProfile(p) {
686
833
  out.stateFile = p.stateFile;
687
834
  return out;
688
835
  }
689
- function buildChannelConfig(params, secretFile) {
836
+ function buildChannelConfig(params, secretFile, extra = {}) {
690
837
  const ch = {
691
838
  id: params.id,
692
839
  type: params.type,
@@ -698,6 +845,12 @@ function buildChannelConfig(params, secretFile) {
698
845
  const s = params.settings ?? {};
699
846
  if (s.baseUrl !== undefined)
700
847
  ch.baseUrl = s.baseUrl;
848
+ if (params.type === "feishu") {
849
+ if (extra.appId)
850
+ ch.appId = extra.appId;
851
+ if (extra.domain)
852
+ ch.domain = extra.domain;
853
+ }
701
854
  if (s.allowedSenderIds !== undefined)
702
855
  ch.allowedSenderIds = s.allowedSenderIds;
703
856
  if (s.allowedChatIds !== undefined)
package/dist/provision.js CHANGED
@@ -164,11 +164,17 @@ export function createProvisioner(opts) {
164
164
  const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
165
165
  if (params.policy) {
166
166
  // Embedded policy payload — install directly to avoid a refetch.
167
+ const embeddedPolicy = params.policy;
167
168
  policyResolver.put(agentId, roomId ?? null, {
168
- mode: params.policy.mode,
169
+ mode: embeddedPolicy.mode === "allowed_senders"
170
+ ? "allowed_senders"
171
+ : params.policy.mode,
169
172
  keywords: Array.isArray(params.policy.keywords)
170
173
  ? params.policy.keywords.slice()
171
174
  : [],
175
+ allowedSenderIds: Array.isArray(embeddedPolicy.allowedSenderIds)
176
+ ? embeddedPolicy.allowedSenderIds.filter((id) => typeof id === "string")
177
+ : [],
172
178
  ...(typeof params.policy.muted_until === "number"
173
179
  ? { muted_until: params.policy.muted_until }
174
180
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.59",
3
+ "version": "0.2.61",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "dependencies": {
30
30
  "@botcord/cli": "^0.1.7",
31
31
  "@botcord/protocol-core": "^0.2.4",
32
+ "@larksuiteoapi/node-sdk": "^1.63.1",
32
33
  "ws": "^8.18.0"
33
34
  },
34
35
  "devDependencies": {
@@ -58,6 +58,8 @@ describe("buildCrossRoomDigest", () => {
58
58
  expect(digest).toContain("[BotCord Cross-Room Awareness]");
59
59
  // 7 rooms recorded (rm_0..rm_6), current is rm_0 → 6 others + 1 current = 7 total.
60
60
  expect(digest).toContain("You are currently active in 7 BotCord sessions");
61
+ expect(digest).toContain("latest messages from OTHER rooms, not the current room");
62
+ expect(digest).toContain("Do not treat any sender or message below as the current user");
61
63
  expect(digest).toContain("Room1 (rm_1)");
62
64
  expect(digest).toContain("Room2 (rm_2)");
63
65
  expect(digest).toContain("Room3 (rm_3)");
@@ -240,6 +240,90 @@ describe("gateway_login_start / status", () => {
240
240
  expect(JSON.stringify(statusResult)).not.toContain("wechat-bot-token-1234567890");
241
241
  });
242
242
 
243
+ it("round-trips Feishu registration and upsert stores app secret locally", async () => {
244
+ const gw = makeFakeGateway();
245
+ const { state, io } = makeConfigIO(baseCfg());
246
+ const sessions = new LoginSessionStore();
247
+ const feishuLogin = {
248
+ startFeishuRegistration: vi.fn(async () => ({
249
+ deviceCode: "DEV-CODE",
250
+ verificationUriComplete: "https://accounts.feishu.cn/verify?x=1",
251
+ expiresIn: 600,
252
+ interval: 5,
253
+ domain: "feishu" as const,
254
+ raw: {},
255
+ })),
256
+ pollFeishuRegistration: vi.fn(async () => ({
257
+ status: "confirmed" as const,
258
+ appId: "cli_feishu_123",
259
+ appSecret: "feishu-secret-1234567890",
260
+ userOpenId: "ou_alice",
261
+ domain: "feishu" as const,
262
+ raw: {},
263
+ })),
264
+ };
265
+ const ctrl = createGatewayControl({
266
+ gateway: gw as any,
267
+ configIO: io,
268
+ loginSessions: sessions,
269
+ feishuLoginClient: feishuLogin,
270
+ });
271
+
272
+ const startAck = await ctrl.handleLoginStart({
273
+ provider: "feishu",
274
+ accountId: "ag_alice",
275
+ domain: "feishu",
276
+ });
277
+ expect(startAck.ok).toBe(true);
278
+ const startResult = startAck.result as { loginId: string; qrcodeUrl?: string };
279
+ expect(startResult.loginId).toMatch(/^fsl_/);
280
+ expect(startResult.qrcodeUrl).toBe("https://accounts.feishu.cn/verify?x=1");
281
+
282
+ const statusAck = await ctrl.handleLoginStatus({
283
+ provider: "feishu",
284
+ loginId: startResult.loginId,
285
+ accountId: "ag_alice",
286
+ });
287
+ expect(statusAck.ok).toBe(true);
288
+ const statusResult = statusAck.result as {
289
+ status: string;
290
+ appId?: string;
291
+ userOpenId?: string;
292
+ tokenPreview?: string;
293
+ };
294
+ expect(statusResult).toMatchObject({
295
+ status: "confirmed",
296
+ appId: "cli_feishu_123",
297
+ userOpenId: "ou_alice",
298
+ tokenPreview: "feis...7890",
299
+ });
300
+ expect(JSON.stringify(statusResult)).not.toContain("feishu-secret-1234567890");
301
+
302
+ const gwId = uniqId("fs");
303
+ const upsertAck = await ctrl.handleUpsert({
304
+ id: gwId,
305
+ type: "feishu",
306
+ accountId: "ag_alice",
307
+ enabled: true,
308
+ loginId: startResult.loginId,
309
+ settings: { allowedSenderIds: ["ou_alice"], domain: "feishu" },
310
+ });
311
+ expect(upsertAck.ok).toBe(true);
312
+ expect(state.cfg.thirdPartyGateways?.[0]).toMatchObject({
313
+ id: gwId,
314
+ type: "feishu",
315
+ appId: "cli_feishu_123",
316
+ domain: "feishu",
317
+ userOpenId: "ou_alice",
318
+ });
319
+ expect(gw.addChannel).toHaveBeenLastCalledWith(
320
+ expect.objectContaining({ id: gwId, type: "feishu", appId: "cli_feishu_123" }),
321
+ );
322
+ const secretPath = trackSecret(gwId);
323
+ const secret = JSON.parse(readFileSync(secretPath, "utf8")) as { appSecret?: string };
324
+ expect(secret.appSecret).toBe("feishu-secret-1234567890");
325
+ });
326
+
243
327
  it("discovers recent WeChat senders from a confirmed login session", async () => {
244
328
  const gw = makeFakeGateway();
245
329
  const { io } = makeConfigIO(baseCfg());
@@ -48,16 +48,13 @@ function makeFakeGateway(): unknown {
48
48
  };
49
49
  }
50
50
 
51
- function makeFakeResolver(): PolicyResolverLike & {
52
- invalidate: ReturnType<typeof vi.fn>;
53
- put: ReturnType<typeof vi.fn>;
54
- resolve: ReturnType<typeof vi.fn>;
55
- } {
56
- return {
57
- resolve: vi.fn(async () => ({ mode: "always", keywords: [] })),
51
+ function makeFakeResolver() {
52
+ const resolver = {
53
+ resolve: vi.fn(async () => ({ mode: "always" as const, keywords: [] })),
58
54
  invalidate: vi.fn(),
59
55
  put: vi.fn(),
60
56
  };
57
+ return resolver as PolicyResolverLike & typeof resolver;
61
58
  }
62
59
 
63
60
  describe("policy_updated control-frame handler", () => {
@@ -110,6 +107,7 @@ describe("policy_updated control-frame handler", () => {
110
107
  expect(resolver.put).toHaveBeenCalledWith("ag_a", null, {
111
108
  mode: "keyword",
112
109
  keywords: ["foo", "bar"],
110
+ allowedSenderIds: [],
113
111
  muted_until: 123,
114
112
  });
115
113
  expect(resolver.invalidate).not.toHaveBeenCalled();
@@ -37,6 +37,15 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
37
37
  allowedSenderIds: ["abc@im.wechat"],
38
38
  splitAt: 1800,
39
39
  },
40
+ {
41
+ id: "gw_fs_1",
42
+ type: "feishu",
43
+ accountId: "ag_daemon",
44
+ appId: "cli_xxx",
45
+ domain: "feishu",
46
+ allowedSenderIds: ["ou_alice"],
47
+ allowedChatIds: ["oc_team"],
48
+ },
40
49
  ],
41
50
  });
42
51
  const gw = toGatewayConfig(cfg);
@@ -44,6 +53,7 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
44
53
  { id: "ag_daemon", type: BOTCORD_CHANNEL_TYPE },
45
54
  { id: "gw_tg_1", type: TELEGRAM_CHANNEL_TYPE },
46
55
  { id: "gw_wx_1", type: WECHAT_CHANNEL_TYPE },
56
+ { id: "gw_fs_1", type: "feishu" },
47
57
  ]);
48
58
  const tg = gw.channels[1]!;
49
59
  expect(tg.accountId).toBe("ag_daemon");
@@ -52,6 +62,11 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
52
62
  expect(wx.baseUrl).toBe("https://ilinkai.weixin.qq.com");
53
63
  expect(wx.allowedSenderIds).toEqual(["abc@im.wechat"]);
54
64
  expect(wx.splitAt).toBe(1800);
65
+ const fs = gw.channels[3]!;
66
+ expect(fs.appId).toBe("cli_xxx");
67
+ expect(fs.domain).toBe("feishu");
68
+ expect(fs.allowedSenderIds).toEqual(["ou_alice"]);
69
+ expect(fs.allowedChatIds).toEqual(["oc_team"]);
55
70
  });
56
71
 
57
72
  it("filters out gateways with enabled === false", () => {
@@ -115,6 +130,19 @@ describe("createDaemonChannel", () => {
115
130
  expect(adapter.id).toBe("gw_wx_1");
116
131
  });
117
132
 
133
+ it("dispatches feishu type to the Feishu adapter", () => {
134
+ const chCfg: GatewayChannelConfig = {
135
+ id: "gw_fs_1",
136
+ type: "feishu",
137
+ accountId: "ag_x",
138
+ appId: "cli_xxx",
139
+ domain: "feishu",
140
+ };
141
+ const adapter = createDaemonChannel(chCfg, deps);
142
+ expect(adapter.type).toBe("feishu");
143
+ expect(adapter.id).toBe("gw_fs_1");
144
+ });
145
+
118
146
  it("throws on unknown channel type", () => {
119
147
  const chCfg: GatewayChannelConfig = {
120
148
  id: "gw_x",
package/src/config.ts CHANGED
@@ -100,7 +100,7 @@ export interface OpenclawDiscoveryConfig {
100
100
  }
101
101
 
102
102
  /** Third-party messaging provider supported by the daemon's channel factory. */
103
- export type ThirdPartyGatewayType = "telegram" | "wechat";
103
+ export type ThirdPartyGatewayType = "telegram" | "wechat" | "feishu";
104
104
 
105
105
  /**
106
106
  * One third-party gateway profile bound to a BotCord agent. `id` is the
@@ -122,6 +122,9 @@ export interface ThirdPartyGatewayProfile {
122
122
  allowedChatIds?: string[];
123
123
  splitAt?: number;
124
124
  baseUrl?: string;
125
+ appId?: string;
126
+ domain?: "feishu" | "lark";
127
+ userOpenId?: string;
125
128
  }
126
129
 
127
130
  export interface DaemonConfig {
@@ -445,9 +448,9 @@ export function loadConfig(): DaemonConfig {
445
448
  `daemon config thirdPartyGateways[${i}].id must be a non-empty string (${CONFIG_PATH})`,
446
449
  );
447
450
  }
448
- if (gg.type !== "telegram" && gg.type !== "wechat") {
451
+ if (gg.type !== "telegram" && gg.type !== "wechat" && gg.type !== "feishu") {
449
452
  throw new Error(
450
- `daemon config thirdPartyGateways[${i}].type must be "telegram" or "wechat" (${CONFIG_PATH})`,
453
+ `daemon config thirdPartyGateways[${i}].type must be "telegram", "wechat", or "feishu" (${CONFIG_PATH})`,
451
454
  );
452
455
  }
453
456
  if (typeof gg.accountId !== "string" || gg.accountId.length === 0) {
package/src/cross-room.ts CHANGED
@@ -44,7 +44,9 @@ export function buildCrossRoomDigest(opts: DigestOptions): string | null {
44
44
 
45
45
  const lines: string[] = [
46
46
  "[BotCord Cross-Room Awareness]",
47
- `You are currently active in ${total} BotCord sessions. Recent activity from other rooms:`,
47
+ `You are currently active in ${total} BotCord sessions. The entries below are latest messages from OTHER rooms, not the current room.`,
48
+ "Do not treat any sender or message below as the current user or current conversation.",
49
+ "Recent activity from other rooms:",
48
50
  ];
49
51
  for (const e of slice) {
50
52
  lines.push(formatEntry(e));
@@ -262,6 +262,9 @@ export function toGatewayConfig(
262
262
  if (g.allowedChatIds !== undefined) ch.allowedChatIds = g.allowedChatIds;
263
263
  if (g.splitAt !== undefined) ch.splitAt = g.splitAt;
264
264
  if (g.baseUrl !== undefined) ch.baseUrl = g.baseUrl;
265
+ if (g.appId !== undefined) ch.appId = g.appId;
266
+ if (g.domain !== undefined) ch.domain = g.domain;
267
+ if (g.userOpenId !== undefined) ch.userOpenId = g.userOpenId;
265
268
  channels.push(ch);
266
269
  }
267
270
 
package/src/daemon.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  import {
7
7
  Gateway,
8
8
  createBotCordChannel,
9
+ createFeishuChannel,
9
10
  createTelegramChannel,
10
11
  createWechatChannel,
11
12
  resolveTranscriptEnabled,
@@ -44,7 +45,7 @@ import {
44
45
  } from "./loop-risk.js";
45
46
  import { composeBotCordUserTurn } from "./turn-text.js";
46
47
  import { UserAuthManager } from "./user-auth.js";
47
- import { PolicyResolver } from "./gateway/policy-resolver.js";
48
+ import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
48
49
  import { scanMention } from "./mention-scan.js";
49
50
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
50
51
 
@@ -178,6 +179,23 @@ export function createDaemonChannel(
178
179
  ...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
179
180
  ...(typeof chCfg.stateFile === "string" ? { stateFile: chCfg.stateFile } : {}),
180
181
  });
182
+ case "feishu":
183
+ return createFeishuChannel({
184
+ id: chCfg.id,
185
+ accountId: chCfg.accountId,
186
+ ...(typeof chCfg.appId === "string" ? { appId: chCfg.appId } : {}),
187
+ ...(chCfg.domain === "feishu" || chCfg.domain === "lark"
188
+ ? { domain: chCfg.domain }
189
+ : {}),
190
+ ...(Array.isArray(chCfg.allowedSenderIds)
191
+ ? { allowedSenderIds: chCfg.allowedSenderIds as string[] }
192
+ : {}),
193
+ ...(Array.isArray(chCfg.allowedChatIds)
194
+ ? { allowedChatIds: chCfg.allowedChatIds as string[] }
195
+ : {}),
196
+ ...(typeof chCfg.splitAt === "number" ? { splitAt: chCfg.splitAt } : {}),
197
+ ...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
198
+ });
181
199
  default:
182
200
  throw new Error(`unknown channel type "${chCfg.type}"`);
183
201
  }
@@ -436,15 +454,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
436
454
  // with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
437
455
  // effective policy, then defer to the protocol-core `shouldWake` decision.
438
456
  const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
439
- const policy: AttentionPolicy = await policyResolver.resolve(
457
+ const policy: DaemonAttentionPolicy = await policyResolver.resolve(
440
458
  msg.accountId,
441
459
  msg.conversation.id,
442
460
  );
461
+ if (policy.mode === "allowed_senders") {
462
+ return (policy.allowedSenderIds ?? []).includes(msg.sender.id);
463
+ }
443
464
  const localMention = scanMention(msg.text, {
444
465
  agentId: msg.accountId,
445
466
  displayName: displayNameByAgent.get(msg.accountId),
446
467
  });
447
- return shouldWake(policy, {
468
+ return shouldWake(policy as AttentionPolicy, {
448
469
  mentioned: msg.mentioned === true || localMention,
449
470
  text: msg.text,
450
471
  });