@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.
- 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/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +35 -6
- 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/channels/wechat.js +26 -2
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +190 -30
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +1 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/index.js +9 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/diagnostics.test.ts +37 -1
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/__tests__/wechat-channel.test.ts +47 -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/diagnostics.ts +36 -6
- package/src/gateway/__tests__/dispatcher.test.ts +62 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- 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/channels/wechat.ts +29 -2
- package/src/gateway/dispatcher.ts +216 -29
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +1 -1
- package/src/gateway-control.ts +188 -17
- package/src/index.ts +9 -3
- package/src/log.ts +100 -1
- 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/index.js
CHANGED
|
@@ -82,9 +82,12 @@ Commands:
|
|
|
82
82
|
route list
|
|
83
83
|
route remove --room <rm_xxx>|--prefix <rm_xxx>
|
|
84
84
|
config Print resolved config
|
|
85
|
-
doctor [--json] [--bundle]
|
|
85
|
+
doctor [--json] [--bundle] [--full-log] Scan local runtimes (${ADAPTER_LIST});
|
|
86
86
|
--bundle also writes a zip under
|
|
87
|
-
~/.botcord/diagnostics
|
|
87
|
+
~/.botcord/diagnostics/. Bundles
|
|
88
|
+
daemon.log plus the latest 5 rotated
|
|
89
|
+
logs by default; --full-log bundles
|
|
90
|
+
all retained rotated logs.
|
|
88
91
|
memory get [--agent <ag_xxx>] [--json] Show current working memory
|
|
89
92
|
memory set [--agent <ag_xxx>] --goal <text>
|
|
90
93
|
Pin/update the agent's work goal
|
|
@@ -109,6 +112,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
109
112
|
"follow",
|
|
110
113
|
"json",
|
|
111
114
|
"bundle",
|
|
115
|
+
"full-log",
|
|
112
116
|
"help",
|
|
113
117
|
"h",
|
|
114
118
|
"mentioned",
|
|
@@ -1216,7 +1220,9 @@ const fsFileReader = {
|
|
|
1216
1220
|
};
|
|
1217
1221
|
async function cmdDoctor(args) {
|
|
1218
1222
|
if (args.flags.bundle === true) {
|
|
1219
|
-
const bundle = await createDiagnosticBundle(
|
|
1223
|
+
const bundle = await createDiagnosticBundle({
|
|
1224
|
+
includeAllLogs: args.flags["full-log"] === true,
|
|
1225
|
+
});
|
|
1220
1226
|
if (args.flags.json === true) {
|
|
1221
1227
|
console.log(JSON.stringify({ bundle }, null, 2));
|
|
1222
1228
|
return;
|
package/dist/log.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
type Level = "info" | "warn" | "error" | "debug";
|
|
2
|
+
export interface LogFileEntry {
|
|
3
|
+
path: string;
|
|
4
|
+
name: string;
|
|
5
|
+
sizeBytes: number;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
active: boolean;
|
|
8
|
+
}
|
|
2
9
|
export declare function formatLogLine(level: Level, msg: string, fields: Record<string, unknown> | undefined, date?: Date): string;
|
|
10
|
+
export declare function listDaemonLogFiles(logFile?: string): LogFileEntry[];
|
|
11
|
+
export declare function rotateLogIfNeeded(logFile?: string, nextBytes?: number, maxBytes?: number, keep?: number): void;
|
|
3
12
|
export declare const log: {
|
|
4
13
|
info: (msg: string, fields?: Record<string, unknown>) => void;
|
|
5
14
|
warn: (msg: string, fields?: Record<string, unknown>) => void;
|
package/dist/log.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
const LOG_DIR = path.join(homedir(), ".botcord", "logs");
|
|
5
5
|
const LOG_FILE = path.join(LOG_DIR, "daemon.log");
|
|
6
|
+
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
const LOG_ROTATE_KEEP = 20;
|
|
6
8
|
let inited = false;
|
|
7
9
|
function ensureDir() {
|
|
8
10
|
if (inited)
|
|
@@ -39,10 +41,96 @@ export function formatLogLine(level, msg, fields, date = new Date()) {
|
|
|
39
41
|
const suffix = `ts=${date.toISOString()}`;
|
|
40
42
|
return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
|
|
41
43
|
}
|
|
44
|
+
function rotatedName(file, date = new Date()) {
|
|
45
|
+
const stamp = date.toISOString().replace(/[:.]/g, "-");
|
|
46
|
+
return `${file}.${stamp}.${process.pid}`;
|
|
47
|
+
}
|
|
48
|
+
export function listDaemonLogFiles(logFile = LOG_FILE) {
|
|
49
|
+
const dir = path.dirname(logFile);
|
|
50
|
+
const base = path.basename(logFile);
|
|
51
|
+
const entries = [];
|
|
52
|
+
try {
|
|
53
|
+
const st = statSync(logFile);
|
|
54
|
+
if (st.isFile()) {
|
|
55
|
+
entries.push({
|
|
56
|
+
path: logFile,
|
|
57
|
+
name: base,
|
|
58
|
+
sizeBytes: st.size,
|
|
59
|
+
mtimeMs: st.mtimeMs,
|
|
60
|
+
active: true,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// no active log
|
|
66
|
+
}
|
|
67
|
+
let names = [];
|
|
68
|
+
try {
|
|
69
|
+
names = readdirSync(dir);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return entries;
|
|
73
|
+
}
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
if (!name.startsWith(`${base}.`))
|
|
76
|
+
continue;
|
|
77
|
+
const file = path.join(dir, name);
|
|
78
|
+
try {
|
|
79
|
+
const st = statSync(file);
|
|
80
|
+
if (!st.isFile())
|
|
81
|
+
continue;
|
|
82
|
+
entries.push({
|
|
83
|
+
path: file,
|
|
84
|
+
name,
|
|
85
|
+
sizeBytes: st.size,
|
|
86
|
+
mtimeMs: st.mtimeMs,
|
|
87
|
+
active: false,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ignore disappearing files
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entries.sort((a, b) => {
|
|
95
|
+
if (a.active !== b.active)
|
|
96
|
+
return a.active ? -1 : 1;
|
|
97
|
+
return b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export function rotateLogIfNeeded(logFile = LOG_FILE, nextBytes = 0, maxBytes = LOG_ROTATE_MAX_BYTES, keep = LOG_ROTATE_KEEP) {
|
|
101
|
+
let currentSize = 0;
|
|
102
|
+
try {
|
|
103
|
+
const st = statSync(logFile);
|
|
104
|
+
if (!st.isFile())
|
|
105
|
+
return;
|
|
106
|
+
currentSize = st.size;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (currentSize + nextBytes <= maxBytes)
|
|
112
|
+
return;
|
|
113
|
+
try {
|
|
114
|
+
renameSync(logFile, rotatedName(logFile));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const rotated = listDaemonLogFiles(logFile).filter((entry) => !entry.active);
|
|
120
|
+
for (const entry of rotated.slice(Math.max(0, keep))) {
|
|
121
|
+
try {
|
|
122
|
+
unlinkSync(entry.path);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// best-effort cleanup
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
42
129
|
function write(level, msg, fields) {
|
|
43
130
|
ensureDir();
|
|
44
131
|
const line = formatLogLine(level, msg, fields);
|
|
45
132
|
try {
|
|
133
|
+
rotateLogIfNeeded(LOG_FILE, Buffer.byteLength(line) + 1);
|
|
46
134
|
appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
|
|
47
135
|
}
|
|
48
136
|
catch {
|
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.60",
|
|
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)");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
@@ -49,4 +49,40 @@ describe("diagnostics bundle", () => {
|
|
|
49
49
|
expect(log).toContain("Authorization: Bearer [REDACTED]");
|
|
50
50
|
expect(log).toContain('"refreshToken":"[REDACTED]"');
|
|
51
51
|
}, 20_000);
|
|
52
|
+
|
|
53
|
+
it("bundles active log plus latest 5 rotated logs by default, or all with includeAllLogs", async () => {
|
|
54
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "botcord-diag-logs-test-"));
|
|
55
|
+
const logFile = path.join(tmp, "daemon.log");
|
|
56
|
+
const configFile = path.join(tmp, "config.json");
|
|
57
|
+
const snapshotFile = path.join(tmp, "snapshot.json");
|
|
58
|
+
writeFileSync(logFile, "active\n");
|
|
59
|
+
writeFileSync(configFile, "{}\n");
|
|
60
|
+
writeFileSync(snapshotFile, "{}\n");
|
|
61
|
+
for (let i = 0; i < 7; i += 1) {
|
|
62
|
+
const rotated = path.join(tmp, `daemon.log.rot-${i}`);
|
|
63
|
+
writeFileSync(rotated, `rotated ${i}\n`);
|
|
64
|
+
const t = new Date(1_700_000_000_000 + i * 1000);
|
|
65
|
+
utimesSync(rotated, t, t);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseOpts = {
|
|
69
|
+
diagnosticsDir: path.join(tmp, "diagnostics"),
|
|
70
|
+
logFile,
|
|
71
|
+
configFile,
|
|
72
|
+
snapshotFile,
|
|
73
|
+
doctor: { text: "doctor ok", json: { ok: true } },
|
|
74
|
+
};
|
|
75
|
+
const bundle = await createDiagnosticBundle(baseOpts);
|
|
76
|
+
const listing = execFileSync("unzip", ["-l", bundle.path], { encoding: "utf8" });
|
|
77
|
+
expect(listing).toContain("daemon.log");
|
|
78
|
+
expect(listing).toContain("logs/daemon.log.rot-6");
|
|
79
|
+
expect(listing).toContain("logs/daemon.log.rot-2");
|
|
80
|
+
expect(listing).not.toContain("logs/daemon.log.rot-1");
|
|
81
|
+
expect(listing).not.toContain("logs/daemon.log.rot-0");
|
|
82
|
+
|
|
83
|
+
const full = await createDiagnosticBundle({ ...baseOpts, includeAllLogs: true });
|
|
84
|
+
const fullListing = execFileSync("unzip", ["-l", full.path], { encoding: "utf8" });
|
|
85
|
+
expect(fullListing).toContain("logs/daemon.log.rot-0");
|
|
86
|
+
expect(fullListing).toContain("logs/daemon.log.rot-6");
|
|
87
|
+
}, 20_000);
|
|
52
88
|
});
|
|
@@ -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());
|