@botcord/daemon 0.2.69 → 0.2.71
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/agent-workspace.js +18 -1
- package/dist/gateway/channels/feishu.js +94 -4
- package/dist/gateway/dispatcher.d.ts +1 -0
- package/dist/gateway/dispatcher.js +7 -4
- package/dist/gateway/gateway.d.ts +9 -1
- package/dist/gateway/gateway.js +12 -0
- package/dist/gateway-control.d.ts +8 -0
- package/dist/gateway-control.js +117 -0
- package/dist/loop-risk.js +6 -0
- package/dist/provision.js +12 -0
- package/dist/turn-text.js +30 -0
- package/package.json +3 -3
- package/src/__tests__/agent-workspace.test.ts +6 -0
- package/src/__tests__/gateway-control.test.ts +71 -0
- package/src/__tests__/loop-risk.test.ts +2 -0
- package/src/__tests__/provision.test.ts +10 -0
- package/src/__tests__/turn-text.test.ts +27 -0
- package/src/agent-workspace.ts +16 -1
- package/src/gateway/__tests__/dispatcher.test.ts +27 -2
- package/src/gateway/__tests__/feishu-channel.test.ts +61 -8
- package/src/gateway/channels/feishu.ts +96 -4
- package/src/gateway/dispatcher.ts +8 -4
- package/src/gateway/gateway.ts +14 -0
- package/src/gateway-control.ts +138 -0
- package/src/loop-risk.ts +3 -0
- package/src/provision.ts +18 -0
- package/src/turn-text.ts +40 -0
package/dist/agent-workspace.js
CHANGED
|
@@ -20,6 +20,7 @@ import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, read
|
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
// Accepted agent id pattern. Enforced at every path-builder entry so a
|
|
25
26
|
// malicious / malformed agentId (e.g. "../../etc") cannot escape
|
|
@@ -357,8 +358,24 @@ export function ensureAgentHermesWorkspace(agentId, opts = {}) {
|
|
|
357
358
|
* Seeded fresh per `ensureAgent*` call (force-overwrite) so daemon
|
|
358
359
|
* upgrades propagate.
|
|
359
360
|
*/
|
|
360
|
-
const BUNDLED_SKILLS = ["botcord", "botcord-user-guide"];
|
|
361
|
+
const BUNDLED_SKILLS = ["botcord", "botcord-user-guide", "botcord_memory"];
|
|
362
|
+
function resolveRepoCliSkillsRoot() {
|
|
363
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
364
|
+
for (let i = 0; i < 6; i += 1) {
|
|
365
|
+
const candidate = path.join(dir, "cli", "skills");
|
|
366
|
+
if (existsSync(candidate))
|
|
367
|
+
return candidate;
|
|
368
|
+
const parent = path.dirname(dir);
|
|
369
|
+
if (parent === dir)
|
|
370
|
+
break;
|
|
371
|
+
dir = parent;
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
361
375
|
function resolveBundledCliSkillsRoot() {
|
|
376
|
+
const repoRoot = resolveRepoCliSkillsRoot();
|
|
377
|
+
if (repoRoot)
|
|
378
|
+
return repoRoot;
|
|
362
379
|
try {
|
|
363
380
|
const pkgJsonPath = require.resolve("@botcord/cli/package.json");
|
|
364
381
|
const root = path.join(path.dirname(pkgJsonPath), "skills");
|
|
@@ -8,6 +8,8 @@ import { splitText } from "./text-split.js";
|
|
|
8
8
|
const FEISHU_PROVIDER = "feishu";
|
|
9
9
|
const DEFAULT_SPLIT_AT = 4000;
|
|
10
10
|
const MAX_SEEN_MESSAGES = 2048;
|
|
11
|
+
const TYPING_EMOJI = "Typing";
|
|
12
|
+
const TYPING_REACTION_TTL_MS = 20_000;
|
|
11
13
|
function sdkDomain(domain) {
|
|
12
14
|
const sdk = Lark;
|
|
13
15
|
return domain === "lark" ? sdk.Domain?.Lark : sdk.Domain?.Feishu;
|
|
@@ -63,6 +65,7 @@ export function createFeishuChannel(opts) {
|
|
|
63
65
|
let botOpenId;
|
|
64
66
|
let botName;
|
|
65
67
|
let liveSetStatus = null;
|
|
68
|
+
const activeTypingReactions = new Map();
|
|
66
69
|
let statusSnapshot = {
|
|
67
70
|
channel: opts.id,
|
|
68
71
|
accountId: opts.accountId,
|
|
@@ -292,6 +295,45 @@ export function createFeishuChannel(opts) {
|
|
|
292
295
|
return ((typeof res.data?.message_id === "string" ? res.data.message_id : undefined) ??
|
|
293
296
|
(typeof res.message_id === "string" ? res.message_id : undefined));
|
|
294
297
|
}
|
|
298
|
+
function resultReactionId(res) {
|
|
299
|
+
return ((typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
|
|
300
|
+
(typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
|
|
301
|
+
null);
|
|
302
|
+
}
|
|
303
|
+
function messageIdFromTrace(traceId) {
|
|
304
|
+
if (!traceId.startsWith("feishu:"))
|
|
305
|
+
return null;
|
|
306
|
+
const messageId = traceId.slice("feishu:".length).trim();
|
|
307
|
+
return messageId.length > 0 ? messageId : null;
|
|
308
|
+
}
|
|
309
|
+
async function removeTypingReaction(messageId) {
|
|
310
|
+
const state = activeTypingReactions.get(messageId);
|
|
311
|
+
if (!state)
|
|
312
|
+
return;
|
|
313
|
+
activeTypingReactions.delete(messageId);
|
|
314
|
+
if (state.timer)
|
|
315
|
+
clearTimeout(state.timer);
|
|
316
|
+
if (!state.reactionId)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
await callFeishu({
|
|
320
|
+
method: "DELETE",
|
|
321
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function scheduleTypingCleanup(messageId, state) {
|
|
329
|
+
if (state.timer)
|
|
330
|
+
clearTimeout(state.timer);
|
|
331
|
+
state.timer = setTimeout(() => {
|
|
332
|
+
void removeTypingReaction(messageId);
|
|
333
|
+
}, TYPING_REACTION_TTL_MS);
|
|
334
|
+
if (typeof state.timer.unref === "function")
|
|
335
|
+
state.timer.unref();
|
|
336
|
+
}
|
|
295
337
|
function resultResourceKey(res, key) {
|
|
296
338
|
const direct = res[key];
|
|
297
339
|
if (typeof direct === "string")
|
|
@@ -422,14 +464,61 @@ export function createFeishuChannel(opts) {
|
|
|
422
464
|
replyInThread: Boolean(ctx.message.threadId),
|
|
423
465
|
}) ?? providerMessageId;
|
|
424
466
|
}
|
|
467
|
+
if (ctx.message.replyTo) {
|
|
468
|
+
void removeTypingReaction(ctx.message.replyTo);
|
|
469
|
+
}
|
|
470
|
+
if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
|
|
471
|
+
void removeTypingReaction(ctx.message.threadId);
|
|
472
|
+
}
|
|
425
473
|
markStatus({ lastSendAt: Date.now() });
|
|
426
474
|
return { providerMessageId };
|
|
427
475
|
}
|
|
428
476
|
async function typing(ctx) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
477
|
+
const messageId = messageIdFromTrace(ctx.traceId);
|
|
478
|
+
if (!messageId) {
|
|
479
|
+
ctx.log.debug("feishu typing skipped: trace id has no message id", {
|
|
480
|
+
channel: opts.id,
|
|
481
|
+
conversationId: ctx.conversationId,
|
|
482
|
+
traceId: ctx.traceId,
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const existing = activeTypingReactions.get(messageId);
|
|
487
|
+
if (existing) {
|
|
488
|
+
scheduleTypingCleanup(messageId, existing);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const state = { reactionId: null, timer: null };
|
|
492
|
+
activeTypingReactions.set(messageId, state);
|
|
493
|
+
scheduleTypingCleanup(messageId, state);
|
|
494
|
+
try {
|
|
495
|
+
const res = await callFeishu({
|
|
496
|
+
method: "POST",
|
|
497
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
|
|
498
|
+
data: { reaction_type: { emoji_type: TYPING_EMOJI } },
|
|
499
|
+
});
|
|
500
|
+
const reactionId = resultReactionId(res);
|
|
501
|
+
if (activeTypingReactions.get(messageId) !== state) {
|
|
502
|
+
if (reactionId) {
|
|
503
|
+
await callFeishu({
|
|
504
|
+
method: "DELETE",
|
|
505
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
state.reactionId = reactionId;
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
activeTypingReactions.delete(messageId);
|
|
514
|
+
if (state.timer)
|
|
515
|
+
clearTimeout(state.timer);
|
|
516
|
+
ctx.log.warn("feishu typing reaction failed", {
|
|
517
|
+
channel: opts.id,
|
|
518
|
+
conversationId: ctx.conversationId,
|
|
519
|
+
err: err instanceof Error ? err.message : String(err),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
433
522
|
}
|
|
434
523
|
async function stop(_ctx) {
|
|
435
524
|
try {
|
|
@@ -439,6 +528,7 @@ export function createFeishuChannel(opts) {
|
|
|
439
528
|
// best effort
|
|
440
529
|
}
|
|
441
530
|
wsClient = null;
|
|
531
|
+
await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
|
|
442
532
|
try {
|
|
443
533
|
stateStore?.close();
|
|
444
534
|
}
|
|
@@ -1098,7 +1098,7 @@ export class Dispatcher {
|
|
|
1098
1098
|
threadId: msg.conversation.threadId ?? null,
|
|
1099
1099
|
type: "error",
|
|
1100
1100
|
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
1101
|
-
replyTo: msg
|
|
1101
|
+
replyTo: this.providerReplyTo(msg),
|
|
1102
1102
|
traceId: msg.trace?.id ?? null,
|
|
1103
1103
|
}, turnId);
|
|
1104
1104
|
}
|
|
@@ -1144,7 +1144,7 @@ export class Dispatcher {
|
|
|
1144
1144
|
threadId: msg.conversation.threadId ?? null,
|
|
1145
1145
|
type: "error",
|
|
1146
1146
|
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
1147
|
-
replyTo: msg
|
|
1147
|
+
replyTo: this.providerReplyTo(msg),
|
|
1148
1148
|
traceId: msg.trace?.id ?? null,
|
|
1149
1149
|
}, turnId);
|
|
1150
1150
|
}
|
|
@@ -1252,7 +1252,7 @@ export class Dispatcher {
|
|
|
1252
1252
|
threadId: msg.conversation.threadId ?? null,
|
|
1253
1253
|
type: "error",
|
|
1254
1254
|
text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
|
|
1255
|
-
replyTo: msg
|
|
1255
|
+
replyTo: this.providerReplyTo(msg),
|
|
1256
1256
|
traceId: msg.trace?.id ?? null,
|
|
1257
1257
|
}, turnId);
|
|
1258
1258
|
this.emitOutbound({
|
|
@@ -1323,7 +1323,7 @@ export class Dispatcher {
|
|
|
1323
1323
|
conversationId: msg.conversation.id,
|
|
1324
1324
|
threadId: msg.conversation.threadId ?? null,
|
|
1325
1325
|
text: replyText,
|
|
1326
|
-
replyTo: msg
|
|
1326
|
+
replyTo: this.providerReplyTo(msg),
|
|
1327
1327
|
traceId: msg.trace?.id ?? null,
|
|
1328
1328
|
}, turnId);
|
|
1329
1329
|
this.emitOutbound({
|
|
@@ -1388,6 +1388,9 @@ export class Dispatcher {
|
|
|
1388
1388
|
}
|
|
1389
1389
|
return { ok: true };
|
|
1390
1390
|
}
|
|
1391
|
+
providerReplyTo(msg) {
|
|
1392
|
+
return msg.replyTo ?? msg.id;
|
|
1393
|
+
}
|
|
1391
1394
|
emitInbound(turnId, msg) {
|
|
1392
1395
|
if (!this.transcript.enabled)
|
|
1393
1396
|
return;
|
|
@@ -2,7 +2,7 @@ import { type ChannelBackoffOptions } from "./channel-manager.js";
|
|
|
2
2
|
import { type RuntimeFactory } from "./dispatcher.js";
|
|
3
3
|
import { type GatewayLogger } from "./log.js";
|
|
4
4
|
import { type TranscriptWriter } from "./transcript.js";
|
|
5
|
-
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
5
|
+
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
6
6
|
/** Constructor options for `Gateway`. */
|
|
7
7
|
export interface GatewayBootOptions {
|
|
8
8
|
config: GatewayConfig;
|
|
@@ -124,4 +124,12 @@ export declare class Gateway {
|
|
|
124
124
|
* routing, queueing, transcript, and runtime behavior as channel messages.
|
|
125
125
|
*/
|
|
126
126
|
injectInbound(message: GatewayInboundMessage): Promise<void>;
|
|
127
|
+
/**
|
|
128
|
+
* Send a daemon-control initiated outbound message through a registered
|
|
129
|
+
* channel. Used by proactive third-party gateway sends where the runtime
|
|
130
|
+
* explicitly targets an external provider conversation.
|
|
131
|
+
*/
|
|
132
|
+
sendOutbound(message: GatewayOutboundMessage): Promise<{
|
|
133
|
+
providerMessageId?: string | null;
|
|
134
|
+
}>;
|
|
127
135
|
}
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -174,4 +174,16 @@ export class Gateway {
|
|
|
174
174
|
async injectInbound(message) {
|
|
175
175
|
await this.dispatcher.handle({ message });
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Send a daemon-control initiated outbound message through a registered
|
|
179
|
+
* channel. Used by proactive third-party gateway sends where the runtime
|
|
180
|
+
* explicitly targets an external provider conversation.
|
|
181
|
+
*/
|
|
182
|
+
async sendOutbound(message) {
|
|
183
|
+
const channel = this.channelMap.get(message.channel);
|
|
184
|
+
if (!channel) {
|
|
185
|
+
throw new Error(`channel "${message.channel}" is not registered`);
|
|
186
|
+
}
|
|
187
|
+
return channel.send({ message, log: this.log });
|
|
188
|
+
}
|
|
177
189
|
}
|
|
@@ -60,6 +60,13 @@ interface GatewayRecentSendersParams {
|
|
|
60
60
|
accountId: string;
|
|
61
61
|
timeoutSeconds?: number;
|
|
62
62
|
}
|
|
63
|
+
interface GatewaySendParams {
|
|
64
|
+
agentId: string;
|
|
65
|
+
gatewayId: string;
|
|
66
|
+
conversationId: string;
|
|
67
|
+
text: string;
|
|
68
|
+
idempotencyKey?: string;
|
|
69
|
+
}
|
|
63
70
|
export type { FetchLike };
|
|
64
71
|
export interface GatewayControlContext {
|
|
65
72
|
gateway: Gateway;
|
|
@@ -98,6 +105,7 @@ export declare function createGatewayControl(ctx: GatewayControlContext): {
|
|
|
98
105
|
handleLoginStart: (params: GatewayLoginStartParams) => Promise<AckBody>;
|
|
99
106
|
handleLoginStatus: (params: GatewayLoginStatusParams) => Promise<AckBody>;
|
|
100
107
|
handleRecentSenders: (params: GatewayRecentSendersParams) => Promise<AckBody>;
|
|
108
|
+
handleSend: (params: GatewaySendParams) => Promise<AckBody>;
|
|
101
109
|
/** Exposed for tests — direct access to the in-memory session map. */
|
|
102
110
|
_sessions: LoginSessionStore;
|
|
103
111
|
};
|
package/dist/gateway-control.js
CHANGED
|
@@ -731,6 +731,78 @@ export function createGatewayControl(ctx) {
|
|
|
731
731
|
};
|
|
732
732
|
}
|
|
733
733
|
}
|
|
734
|
+
// --- gateway_send -------------------------------------------------------
|
|
735
|
+
async function handleSend(params) {
|
|
736
|
+
if (!params.agentId || typeof params.agentId !== "string") {
|
|
737
|
+
return badParams("gateway_send: agentId is required");
|
|
738
|
+
}
|
|
739
|
+
if (!params.gatewayId || typeof params.gatewayId !== "string") {
|
|
740
|
+
return badParams("gateway_send: gatewayId is required");
|
|
741
|
+
}
|
|
742
|
+
if (!params.conversationId || typeof params.conversationId !== "string") {
|
|
743
|
+
return badParams("gateway_send: conversationId is required");
|
|
744
|
+
}
|
|
745
|
+
if (typeof params.text !== "string" || params.text.length === 0) {
|
|
746
|
+
return badParams("gateway_send: text is required");
|
|
747
|
+
}
|
|
748
|
+
const cfg = cfgIO.load();
|
|
749
|
+
const profile = (cfg.thirdPartyGateways ?? []).find((g) => g.id === params.gatewayId);
|
|
750
|
+
if (!profile) {
|
|
751
|
+
return {
|
|
752
|
+
ok: false,
|
|
753
|
+
error: { code: "unknown_gateway", message: `no gateway with id "${params.gatewayId}"` },
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (profile.accountId !== params.agentId) {
|
|
757
|
+
return {
|
|
758
|
+
ok: false,
|
|
759
|
+
error: { code: "account_mismatch", message: "gateway is bound to a different agent" },
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
if (profile.enabled === false) {
|
|
763
|
+
return {
|
|
764
|
+
ok: false,
|
|
765
|
+
error: { code: "gateway_disabled", message: "gateway is disabled" },
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (profile.type === "wechat") {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
error: { code: "unsupported_provider", message: "wechat gateway_send requires an inbound context_token and is not supported" },
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const conversationErr = validateOutboundConversation(profile, params.conversationId);
|
|
775
|
+
if (conversationErr)
|
|
776
|
+
return conversationErr;
|
|
777
|
+
try {
|
|
778
|
+
const sendResult = await ctx.gateway.sendOutbound({
|
|
779
|
+
channel: params.gatewayId,
|
|
780
|
+
accountId: params.agentId,
|
|
781
|
+
conversationId: params.conversationId,
|
|
782
|
+
text: params.text,
|
|
783
|
+
traceId: `gateway-send:${params.idempotencyKey ?? Date.now()}`,
|
|
784
|
+
});
|
|
785
|
+
const result = {
|
|
786
|
+
gatewayId: params.gatewayId,
|
|
787
|
+
conversationId: params.conversationId,
|
|
788
|
+
providerMessageId: sendResult.providerMessageId ?? null,
|
|
789
|
+
};
|
|
790
|
+
return { ok: true, result };
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
794
|
+
daemonLog.warn("gateway_send failed", {
|
|
795
|
+
gatewayId: params.gatewayId,
|
|
796
|
+
accountId: params.agentId,
|
|
797
|
+
conversationId: params.conversationId,
|
|
798
|
+
error: message,
|
|
799
|
+
});
|
|
800
|
+
return {
|
|
801
|
+
ok: false,
|
|
802
|
+
error: { code: "send_failed", message },
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
}
|
|
734
806
|
return {
|
|
735
807
|
handleList,
|
|
736
808
|
handleUpsert,
|
|
@@ -739,6 +811,7 @@ export function createGatewayControl(ctx) {
|
|
|
739
811
|
handleLoginStart,
|
|
740
812
|
handleLoginStatus,
|
|
741
813
|
handleRecentSenders,
|
|
814
|
+
handleSend,
|
|
742
815
|
/** Exposed for tests — direct access to the in-memory session map. */
|
|
743
816
|
_sessions: sessions,
|
|
744
817
|
};
|
|
@@ -761,6 +834,50 @@ function validateUpsertParams(p) {
|
|
|
761
834
|
return "upsert_gateway: accountId is required";
|
|
762
835
|
return null;
|
|
763
836
|
}
|
|
837
|
+
function validateOutboundConversation(profile, conversationId) {
|
|
838
|
+
const chatId = chatIdFromConversation(profile.type, conversationId);
|
|
839
|
+
if (!chatId) {
|
|
840
|
+
return {
|
|
841
|
+
ok: false,
|
|
842
|
+
error: {
|
|
843
|
+
code: "bad_conversation",
|
|
844
|
+
message: `conversationId "${conversationId}" is not valid for ${profile.type}`,
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
const allowed = new Set((profile.allowedChatIds ?? []).map(String));
|
|
849
|
+
if (!allowed.has(chatId)) {
|
|
850
|
+
return {
|
|
851
|
+
ok: false,
|
|
852
|
+
error: {
|
|
853
|
+
code: "conversation_not_allowed",
|
|
854
|
+
message: "conversation is not in the gateway allowedChatIds list",
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
function chatIdFromConversation(provider, conversationId) {
|
|
861
|
+
if (provider === "telegram") {
|
|
862
|
+
if (conversationId.startsWith("telegram:user:")) {
|
|
863
|
+
return conversationId.slice("telegram:user:".length);
|
|
864
|
+
}
|
|
865
|
+
if (conversationId.startsWith("telegram:group:")) {
|
|
866
|
+
return conversationId.slice("telegram:group:".length);
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
if (provider === "feishu") {
|
|
871
|
+
if (conversationId.startsWith("feishu:user:")) {
|
|
872
|
+
return conversationId.slice("feishu:user:".length);
|
|
873
|
+
}
|
|
874
|
+
if (conversationId.startsWith("feishu:chat:")) {
|
|
875
|
+
return conversationId.slice("feishu:chat:".length);
|
|
876
|
+
}
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
764
881
|
function annotateProfile(p, status) {
|
|
765
882
|
return {
|
|
766
883
|
id: p.id,
|
package/dist/loop-risk.js
CHANGED
|
@@ -76,6 +76,12 @@ export function stripBotCordPromptScaffolding(text) {
|
|
|
76
76
|
return false;
|
|
77
77
|
if (line.startsWith("you do not reply to the group"))
|
|
78
78
|
return false;
|
|
79
|
+
if (line.startsWith("Before replying NO_REPLY in a non-owner group room"))
|
|
80
|
+
return false;
|
|
81
|
+
if (line.startsWith("match a memory-backed monitoring rule"))
|
|
82
|
+
return false;
|
|
83
|
+
if (line.startsWith("or owner-approved workflow. If needed"))
|
|
84
|
+
return false;
|
|
79
85
|
if (line.startsWith("[If the conversation has naturally concluded"))
|
|
80
86
|
return false;
|
|
81
87
|
if (line.startsWith("[You received a contact request"))
|
package/dist/provision.js
CHANGED
|
@@ -255,6 +255,14 @@ export function createProvisioner(opts) {
|
|
|
255
255
|
return v.ack;
|
|
256
256
|
return gatewayControl.handleRecentSenders(v.params);
|
|
257
257
|
}
|
|
258
|
+
case "gateway_send": {
|
|
259
|
+
const v = validateGatewayParams(frame.params, {
|
|
260
|
+
required: ["agentId", "gatewayId", "conversationId", "text"],
|
|
261
|
+
});
|
|
262
|
+
if (!v.ok)
|
|
263
|
+
return v.ack;
|
|
264
|
+
return gatewayControl.handleSend(v.params);
|
|
265
|
+
}
|
|
258
266
|
case "list_agent_files": {
|
|
259
267
|
const params = (frame.params ?? {});
|
|
260
268
|
if (!params.agentId) {
|
|
@@ -344,6 +352,8 @@ async function handleWakeAgent(gateway, raw) {
|
|
|
344
352
|
}
|
|
345
353
|
const runId = params.run_id || params.runId || `wake-${Date.now()}`;
|
|
346
354
|
const scheduleId = params.schedule_id || params.scheduleId;
|
|
355
|
+
const scheduledFor = params.scheduled_for || params.scheduledFor;
|
|
356
|
+
const dispatchedAt = params.dispatched_at || params.dispatchedAt;
|
|
347
357
|
const dedupeKey = params.dedupe_key || params.dedupeKey;
|
|
348
358
|
const conversationId = `rm_schedule_${agentId}`;
|
|
349
359
|
const msg = {
|
|
@@ -365,6 +375,8 @@ async function handleWakeAgent(gateway, raw) {
|
|
|
365
375
|
raw: {
|
|
366
376
|
source_type: "botcord_schedule",
|
|
367
377
|
schedule_id: scheduleId,
|
|
378
|
+
scheduled_for: scheduledFor,
|
|
379
|
+
dispatched_at: dispatchedAt,
|
|
368
380
|
run_id: runId,
|
|
369
381
|
dedupe_key: dedupeKey,
|
|
370
382
|
},
|
package/dist/turn-text.js
CHANGED
|
@@ -9,6 +9,10 @@ const GROUP_HINT = "[In group chats, do not send a message back to the current g
|
|
|
9
9
|
"When a message matches an active monitoring rule, automation goal, working-memory task, " +
|
|
10
10
|
"keyword, sender rule, or owner-approved workflow, perform the required action even if " +
|
|
11
11
|
"you do not reply to the group.\n\n" +
|
|
12
|
+
"Before replying NO_REPLY in a non-owner group room, consider whether this message could " +
|
|
13
|
+
"match a memory-backed monitoring rule, automation goal, pending task, keyword, sender rule, " +
|
|
14
|
+
"or owner-approved workflow. If needed, use the botcord_memory skill to retrieve or update " +
|
|
15
|
+
"working memory.\n\n" +
|
|
12
16
|
'If no group reply and no background action is needed, reply exactly "NO_REPLY".]';
|
|
13
17
|
const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
|
|
14
18
|
'reply with exactly "NO_REPLY" and nothing else.]';
|
|
@@ -56,6 +60,31 @@ function appendConversationFields(fields, msg) {
|
|
|
56
60
|
fields.push(`channel: ${sanitizeSenderName(msg.channel)}`);
|
|
57
61
|
}
|
|
58
62
|
}
|
|
63
|
+
function formatScheduleContext(raw) {
|
|
64
|
+
const r = raw && typeof raw === "object" ? raw : {};
|
|
65
|
+
if (r.source_type !== "botcord_schedule")
|
|
66
|
+
return [];
|
|
67
|
+
const fields = [];
|
|
68
|
+
if (typeof r.schedule_id === "string" && r.schedule_id) {
|
|
69
|
+
fields.push(`schedule_id: ${sanitizeSenderName(r.schedule_id)}`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof r.scheduled_for === "string" && r.scheduled_for) {
|
|
72
|
+
fields.push(`scheduled_for: ${sanitizeSenderName(r.scheduled_for)}`);
|
|
73
|
+
}
|
|
74
|
+
if (typeof r.dispatched_at === "string" && r.dispatched_at) {
|
|
75
|
+
fields.push(`dispatched_at: ${sanitizeSenderName(r.dispatched_at)}`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof r.run_id === "string" && r.run_id) {
|
|
78
|
+
fields.push(`run_id: ${sanitizeSenderName(r.run_id)}`);
|
|
79
|
+
}
|
|
80
|
+
return fields.length > 0
|
|
81
|
+
? [
|
|
82
|
+
"[BotCord Schedule]",
|
|
83
|
+
"This turn was triggered by a proactive schedule.",
|
|
84
|
+
fields.join(" | "),
|
|
85
|
+
]
|
|
86
|
+
: ["[BotCord Schedule]", "This turn was triggered by a proactive schedule."];
|
|
87
|
+
}
|
|
59
88
|
/**
|
|
60
89
|
* Read the `raw.batch` array emitted by the BotCord channel when inbox
|
|
61
90
|
* drain groups multiple messages for the same `(room, topic)`. Returns the
|
|
@@ -179,6 +208,7 @@ export function composeBotCordUserTurn(msg) {
|
|
|
179
208
|
: null;
|
|
180
209
|
const lines = [
|
|
181
210
|
headerFields.join(" | "),
|
|
211
|
+
...formatScheduleContext(msg.raw),
|
|
182
212
|
...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
|
|
183
213
|
`<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
|
|
184
214
|
trimmed,
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.71",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"botcord-daemon": "
|
|
7
|
+
"botcord-daemon": "dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@botcord/cli": "^0.1.7",
|
|
31
31
|
"@botcord/protocol-core": "^0.2.4",
|
|
32
32
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
33
|
-
"ws": "^8.
|
|
33
|
+
"ws": "^8.20.1"
|
|
34
34
|
},
|
|
35
35
|
"overrides": {
|
|
36
36
|
"axios": "^1.15.2"
|
|
@@ -88,6 +88,7 @@ describe("ensureAgentWorkspace", () => {
|
|
|
88
88
|
const skillsDir = path.join(agentWorkspaceDir("ag_skills"), ".claude", "skills");
|
|
89
89
|
expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
|
|
90
90
|
expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
|
|
91
|
+
expect(existsSync(path.join(skillsDir, "botcord_memory", "SKILL.md"))).toBe(true);
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
it("re-seeds skills on a second call so daemon upgrades propagate", () => {
|
|
@@ -113,6 +114,7 @@ describe("ensureAgentWorkspace", () => {
|
|
|
113
114
|
const skillsDir = path.join(agentCodexHomeDir("ag_codex_skills"), "skills");
|
|
114
115
|
expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
|
|
115
116
|
expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
|
|
117
|
+
expect(existsSync(path.join(skillsDir, "botcord_memory", "SKILL.md"))).toBe(true);
|
|
116
118
|
});
|
|
117
119
|
|
|
118
120
|
it("re-seeds codex skills on subsequent ensureAgentCodexHome calls", () => {
|
|
@@ -137,6 +139,7 @@ describe("ensureAgentWorkspace", () => {
|
|
|
137
139
|
const skillsDir = path.join(hermesHome, "skills");
|
|
138
140
|
expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
|
|
139
141
|
expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
|
|
142
|
+
expect(existsSync(path.join(skillsDir, "botcord_memory", "SKILL.md"))).toBe(true);
|
|
140
143
|
});
|
|
141
144
|
|
|
142
145
|
it("re-seeds hermes skills on subsequent ensureAgentHermesWorkspace calls", () => {
|
|
@@ -164,6 +167,9 @@ describe("ensureAgentWorkspace", () => {
|
|
|
164
167
|
expect(existsSync(path.join(profileHome, "skills", "botcord-user-guide", "SKILL.md"))).toBe(
|
|
165
168
|
true,
|
|
166
169
|
);
|
|
170
|
+
expect(existsSync(path.join(profileHome, "skills", "botcord_memory", "SKILL.md"))).toBe(
|
|
171
|
+
true,
|
|
172
|
+
);
|
|
167
173
|
expect(existsSync(hermesWorkspace)).toBe(true);
|
|
168
174
|
expect(existsSync(hermesHome)).toBe(false);
|
|
169
175
|
});
|
|
@@ -43,6 +43,7 @@ interface FakeGateway {
|
|
|
43
43
|
channels: Map<string, { id: string; status: Record<string, unknown> }>;
|
|
44
44
|
addChannel: ReturnType<typeof vi.fn>;
|
|
45
45
|
removeChannel: ReturnType<typeof vi.fn>;
|
|
46
|
+
sendOutbound: ReturnType<typeof vi.fn>;
|
|
46
47
|
snapshot: () => { channels: Record<string, any>; turns: Record<string, any> };
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -66,6 +67,7 @@ function makeFakeGateway(): FakeGateway {
|
|
|
66
67
|
removeChannel: vi.fn(async (id: string) => {
|
|
67
68
|
channels.delete(id);
|
|
68
69
|
}),
|
|
70
|
+
sendOutbound: vi.fn(async () => ({ providerMessageId: "provider-msg-1" })),
|
|
69
71
|
snapshot: () => ({
|
|
70
72
|
channels: Object.fromEntries([...channels].map(([id, e]) => [id, e.status])),
|
|
71
73
|
turns: {},
|
|
@@ -617,6 +619,75 @@ describe("list_gateways", () => {
|
|
|
617
619
|
});
|
|
618
620
|
});
|
|
619
621
|
|
|
622
|
+
describe("gateway_send", () => {
|
|
623
|
+
it("sends through an enabled allowed telegram gateway", async () => {
|
|
624
|
+
const gw = makeFakeGateway();
|
|
625
|
+
const gwId = uniqId("send");
|
|
626
|
+
const { io } = makeConfigIO({
|
|
627
|
+
...baseCfg(),
|
|
628
|
+
thirdPartyGateways: [
|
|
629
|
+
{
|
|
630
|
+
id: gwId,
|
|
631
|
+
type: "telegram",
|
|
632
|
+
accountId: "ag_alice",
|
|
633
|
+
enabled: true,
|
|
634
|
+
allowedChatIds: ["-1001"],
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
});
|
|
638
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
639
|
+
|
|
640
|
+
const ack = await ctrl.handleSend({
|
|
641
|
+
agentId: "ag_alice",
|
|
642
|
+
gatewayId: gwId,
|
|
643
|
+
conversationId: "telegram:group:-1001",
|
|
644
|
+
text: "hello",
|
|
645
|
+
idempotencyKey: "k1",
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
expect(ack.ok).toBe(true);
|
|
649
|
+
expect(gw.sendOutbound).toHaveBeenCalledWith(
|
|
650
|
+
expect.objectContaining({
|
|
651
|
+
channel: gwId,
|
|
652
|
+
accountId: "ag_alice",
|
|
653
|
+
conversationId: "telegram:group:-1001",
|
|
654
|
+
text: "hello",
|
|
655
|
+
traceId: "gateway-send:k1",
|
|
656
|
+
}),
|
|
657
|
+
);
|
|
658
|
+
expect((ack.result as any).providerMessageId).toBe("provider-msg-1");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("rejects conversations outside allowedChatIds", async () => {
|
|
662
|
+
const gw = makeFakeGateway();
|
|
663
|
+
const gwId = uniqId("send-deny");
|
|
664
|
+
const { io } = makeConfigIO({
|
|
665
|
+
...baseCfg(),
|
|
666
|
+
thirdPartyGateways: [
|
|
667
|
+
{
|
|
668
|
+
id: gwId,
|
|
669
|
+
type: "feishu",
|
|
670
|
+
accountId: "ag_alice",
|
|
671
|
+
enabled: true,
|
|
672
|
+
allowedChatIds: ["oc_allowed"],
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
});
|
|
676
|
+
const ctrl = createGatewayControl({ gateway: gw as any, configIO: io });
|
|
677
|
+
|
|
678
|
+
const ack = await ctrl.handleSend({
|
|
679
|
+
agentId: "ag_alice",
|
|
680
|
+
gatewayId: gwId,
|
|
681
|
+
conversationId: "feishu:chat:oc_other",
|
|
682
|
+
text: "hello",
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
expect(ack.ok).toBe(false);
|
|
686
|
+
expect(ack.error?.code).toBe("conversation_not_allowed");
|
|
687
|
+
expect(gw.sendOutbound).not.toHaveBeenCalled();
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
620
691
|
describe("W4: handleLoginStatus accountId ownership check", () => {
|
|
621
692
|
it("returns forbidden when accountId does not match the login session", async () => {
|
|
622
693
|
const gw = makeFakeGateway();
|