@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.
- package/dist/config.d.ts +4 -1
- package/dist/config.js +2 -2
- package/dist/cross-room.js +3 -1
- package/dist/daemon-config-map.js +6 -0
- package/dist/daemon.js +21 -1
- package/dist/gateway/channels/botcord.d.ts +7 -0
- package/dist/gateway/channels/botcord.js +3 -1
- package/dist/gateway/channels/feishu-registration.d.ts +35 -0
- package/dist/gateway/channels/feishu-registration.js +101 -0
- package/dist/gateway/channels/feishu.d.ts +16 -0
- package/dist/gateway/channels/feishu.js +459 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +2 -0
- package/dist/gateway/channels/login-session.d.ts +9 -1
- package/dist/gateway/channels/login-session.js +1 -1
- package/dist/gateway/dispatcher.js +7 -3
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +2 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/config.ts +6 -3
- package/src/cross-room.ts +3 -1
- package/src/daemon-config-map.ts +3 -0
- package/src/daemon.ts +24 -3
- package/src/gateway/__tests__/botcord-channel.test.ts +77 -0
- package/src/gateway/__tests__/dispatcher.test.ts +14 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- package/src/gateway/channels/botcord.ts +10 -1
- package/src/gateway/channels/feishu-registration.ts +155 -0
- package/src/gateway/channels/feishu.ts +554 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/login-session.ts +10 -2
- package/src/gateway/dispatcher.ts +7 -3
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +2 -1
- package/src/gateway-control.ts +188 -17
- package/src/provision.ts +13 -1
package/dist/gateway-control.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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:
|
|
356
|
-
//
|
|
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:
|
|
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:
|
|
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.
|
|
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()
|
|
52
|
-
|
|
53
|
-
|
|
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 "
|
|
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.
|
|
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));
|
package/src/daemon-config-map.ts
CHANGED
|
@@ -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:
|
|
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
|
});
|