@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
|
@@ -28,6 +28,8 @@ describe("stripBotCordPromptScaffolding", () => {
|
|
|
28
28
|
"",
|
|
29
29
|
"When a message matches an active monitoring rule, automation goal, working-memory task, keyword, sender rule, or owner-approved workflow, perform the required action even if you do not reply to the group.",
|
|
30
30
|
"",
|
|
31
|
+
"Before replying NO_REPLY in a non-owner group room, consider whether this message could match a memory-backed monitoring rule, automation goal, pending task, keyword, sender rule, or owner-approved workflow. If needed, use the botcord_memory skill to retrieve or update working memory.",
|
|
32
|
+
"",
|
|
31
33
|
'If no group reply and no background action is needed, reply exactly "NO_REPLY".]',
|
|
32
34
|
].join("\n");
|
|
33
35
|
expect(stripBotCordPromptScaffolding(wrapped)).toBe("hello world");
|
|
@@ -265,6 +265,8 @@ describe("wake_agent handler", () => {
|
|
|
265
265
|
message: "【BotCord 自主任务】执行本轮工作目标。",
|
|
266
266
|
run_id: "sr_test",
|
|
267
267
|
schedule_id: "sch_test",
|
|
268
|
+
scheduled_for: "2026-05-19T01:30:00+00:00",
|
|
269
|
+
dispatched_at: "2026-05-19T01:30:02+00:00",
|
|
268
270
|
dedupe_key: "sch_test:1:auto",
|
|
269
271
|
},
|
|
270
272
|
});
|
|
@@ -279,6 +281,14 @@ describe("wake_agent handler", () => {
|
|
|
279
281
|
expect(msg.sender.kind).toBe("system");
|
|
280
282
|
expect(msg.text).toContain("BotCord 自主任务");
|
|
281
283
|
expect(msg.conversation.threadId).toBe("sch_test");
|
|
284
|
+
expect(msg.raw).toMatchObject({
|
|
285
|
+
source_type: "botcord_schedule",
|
|
286
|
+
schedule_id: "sch_test",
|
|
287
|
+
scheduled_for: "2026-05-19T01:30:00+00:00",
|
|
288
|
+
dispatched_at: "2026-05-19T01:30:02+00:00",
|
|
289
|
+
run_id: "sr_test",
|
|
290
|
+
dedupe_key: "sch_test:1:auto",
|
|
291
|
+
});
|
|
282
292
|
});
|
|
283
293
|
|
|
284
294
|
it("rejects wake_agent for an unloaded agent", async () => {
|
|
@@ -37,6 +37,8 @@ describe("composeBotCordUserTurn", () => {
|
|
|
37
37
|
expect(out).toContain("do not send a message back to the current group room");
|
|
38
38
|
expect(out).toContain("owner-approved or policy-approved background actions");
|
|
39
39
|
expect(out).toContain("active monitoring rule");
|
|
40
|
+
expect(out).toContain("botcord_memory");
|
|
41
|
+
expect(out).toContain("retrieve or update working memory");
|
|
40
42
|
expect(out).toContain('"NO_REPLY"');
|
|
41
43
|
});
|
|
42
44
|
|
|
@@ -97,6 +99,31 @@ describe("composeBotCordUserTurn", () => {
|
|
|
97
99
|
expect(out).not.toContain("do NOT reply unless");
|
|
98
100
|
});
|
|
99
101
|
|
|
102
|
+
it("renders schedule timing metadata for proactive schedule turns", () => {
|
|
103
|
+
const out = composeBotCordUserTurn(
|
|
104
|
+
makeMessage({
|
|
105
|
+
conversation: { id: "rm_schedule_ag_me", kind: "direct", title: "BotCord Scheduler", threadId: "sch_daily" },
|
|
106
|
+
sender: { id: "hub", name: "BotCord Scheduler", kind: "system" },
|
|
107
|
+
text: "daily brief",
|
|
108
|
+
mentioned: true,
|
|
109
|
+
raw: {
|
|
110
|
+
source_type: "botcord_schedule",
|
|
111
|
+
schedule_id: "sch_daily",
|
|
112
|
+
scheduled_for: "2026-05-19T01:30:00+00:00",
|
|
113
|
+
dispatched_at: "2026-05-19T01:30:02+00:00",
|
|
114
|
+
run_id: "sr_daily",
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(out).toContain("[BotCord Schedule]");
|
|
119
|
+
expect(out).toContain("This turn was triggered by a proactive schedule.");
|
|
120
|
+
expect(out).toContain("schedule_id: sch_daily");
|
|
121
|
+
expect(out).toContain("scheduled_for: 2026-05-19T01:30:00+00:00");
|
|
122
|
+
expect(out).toContain("dispatched_at: 2026-05-19T01:30:02+00:00");
|
|
123
|
+
expect(out).toContain("run_id: sr_daily");
|
|
124
|
+
expect(out.indexOf("[BotCord Schedule]")).toBeLessThan(out.indexOf("<agent-message"));
|
|
125
|
+
});
|
|
126
|
+
|
|
100
127
|
it("keeps the botcord_send delivery hint for non-owner BotCord rooms", () => {
|
|
101
128
|
const out = composeBotCordUserTurn(
|
|
102
129
|
makeMessage({
|
package/src/agent-workspace.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { createRequire } from "node:module";
|
|
33
33
|
import { homedir } from "node:os";
|
|
34
34
|
import path from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
35
36
|
|
|
36
37
|
const require = createRequire(import.meta.url);
|
|
37
38
|
|
|
@@ -397,9 +398,23 @@ export function ensureAgentHermesWorkspace(
|
|
|
397
398
|
* Seeded fresh per `ensureAgent*` call (force-overwrite) so daemon
|
|
398
399
|
* upgrades propagate.
|
|
399
400
|
*/
|
|
400
|
-
const BUNDLED_SKILLS = ["botcord", "botcord-user-guide"] as const;
|
|
401
|
+
const BUNDLED_SKILLS = ["botcord", "botcord-user-guide", "botcord_memory"] as const;
|
|
402
|
+
|
|
403
|
+
function resolveRepoCliSkillsRoot(): string | null {
|
|
404
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
405
|
+
for (let i = 0; i < 6; i += 1) {
|
|
406
|
+
const candidate = path.join(dir, "cli", "skills");
|
|
407
|
+
if (existsSync(candidate)) return candidate;
|
|
408
|
+
const parent = path.dirname(dir);
|
|
409
|
+
if (parent === dir) break;
|
|
410
|
+
dir = parent;
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
401
414
|
|
|
402
415
|
function resolveBundledCliSkillsRoot(): string | null {
|
|
416
|
+
const repoRoot = resolveRepoCliSkillsRoot();
|
|
417
|
+
if (repoRoot) return repoRoot;
|
|
403
418
|
try {
|
|
404
419
|
const pkgJsonPath = require.resolve("@botcord/cli/package.json");
|
|
405
420
|
const root = path.join(path.dirname(pkgJsonPath), "skills");
|
|
@@ -293,6 +293,30 @@ describe("Dispatcher", () => {
|
|
|
293
293
|
expect(store.all()[0].threadId).toBe("t_1");
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
it("sends replies to the provider reply id when it differs from the internal message id", async () => {
|
|
297
|
+
const runtime = new FakeRuntime({ reply: "ok" });
|
|
298
|
+
const feishuChannel = new FakeChannel({ id: "gw_feishu_1", type: "feishu" });
|
|
299
|
+
const { dispatcher, channel } = await scaffold({
|
|
300
|
+
runtimeFactory: () => runtime,
|
|
301
|
+
channel: feishuChannel,
|
|
302
|
+
config: baseConfig({
|
|
303
|
+
channels: [{ id: "gw_feishu_1", type: "feishu", accountId: "ag_me" }],
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await dispatcher.handle(
|
|
308
|
+
makeEnvelope({
|
|
309
|
+
id: "feishu:om_internal_wrapped",
|
|
310
|
+
replyTo: "om_provider_raw",
|
|
311
|
+
channel: "gw_feishu_1",
|
|
312
|
+
conversation: { id: "feishu:user:oc_chat", kind: "direct" },
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(channel.sends.length).toBe(1);
|
|
317
|
+
expect(channel.sends[0].message.replyTo).toBe("om_provider_raw");
|
|
318
|
+
});
|
|
319
|
+
|
|
296
320
|
it("reuses session id on second message with same queue key", async () => {
|
|
297
321
|
const seen: Array<string | null> = [];
|
|
298
322
|
const runtime = new FakeRuntime({
|
|
@@ -2009,7 +2033,8 @@ describe("Dispatcher", () => {
|
|
|
2009
2033
|
});
|
|
2010
2034
|
await dispatcher.handle(
|
|
2011
2035
|
makeEnvelope({
|
|
2012
|
-
id: "
|
|
2036
|
+
id: "feishu:om_internal_err",
|
|
2037
|
+
replyTo: "om_provider_err",
|
|
2013
2038
|
conversation: { id: "rm_g_other", kind: "group" },
|
|
2014
2039
|
}),
|
|
2015
2040
|
);
|
|
@@ -2017,7 +2042,7 @@ describe("Dispatcher", () => {
|
|
|
2017
2042
|
expect(channel.sends[0].message.type).toBe("error");
|
|
2018
2043
|
expect(channel.sends[0].message.text).toContain("Runtime error: boom");
|
|
2019
2044
|
expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
|
|
2020
|
-
expect(channel.sends[0].message.replyTo).toBe("
|
|
2045
|
+
expect(channel.sends[0].message.replyTo).toBe("om_provider_err");
|
|
2021
2046
|
});
|
|
2022
2047
|
|
|
2023
2048
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -255,27 +255,80 @@ describe("createFeishuChannel", () => {
|
|
|
255
255
|
expect(JSON.parse(send.data.content as string)).toEqual({ file_key: "file_v2_uploaded" });
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
it("
|
|
259
|
-
const debug = vi.fn();
|
|
258
|
+
it("adds a Typing reaction for typing and removes it when replying", async () => {
|
|
260
259
|
const adapter = createFeishuChannel({
|
|
261
260
|
id: "gw_fs",
|
|
262
261
|
accountId: "ag_self",
|
|
263
262
|
appId: "cli_a",
|
|
264
263
|
appSecret: "sec",
|
|
265
264
|
});
|
|
265
|
+
larkMock.responses.push(
|
|
266
|
+
{ code: 0, data: { reaction_id: "react_typing_1" } },
|
|
267
|
+
{ code: 0, data: { message_id: "om_reply" } },
|
|
268
|
+
{ code: 0, data: {} },
|
|
269
|
+
);
|
|
266
270
|
|
|
267
271
|
await adapter.typing?.({
|
|
268
272
|
traceId: "feishu:om_1",
|
|
269
273
|
accountId: "ag_self",
|
|
270
274
|
conversationId: "feishu:chat:oc_chat",
|
|
271
|
-
log:
|
|
275
|
+
log: SILENT_LOG,
|
|
272
276
|
});
|
|
273
277
|
|
|
274
|
-
expect(larkMock.requests).toHaveLength(
|
|
275
|
-
expect(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
278
|
+
expect(larkMock.requests).toHaveLength(1);
|
|
279
|
+
expect(larkMock.requests[0]).toEqual({
|
|
280
|
+
method: "POST",
|
|
281
|
+
url: "/open-apis/im/v1/messages/om_1/reactions",
|
|
282
|
+
data: { reaction_type: { emoji_type: "Typing" } },
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await adapter.send({
|
|
286
|
+
message: {
|
|
287
|
+
channel: "gw_fs",
|
|
288
|
+
accountId: "ag_self",
|
|
289
|
+
conversationId: "feishu:chat:oc_chat",
|
|
290
|
+
text: "reply",
|
|
291
|
+
replyTo: "om_1",
|
|
292
|
+
},
|
|
293
|
+
log: SILENT_LOG,
|
|
294
|
+
});
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
296
|
+
|
|
297
|
+
expect(larkMock.requests).toHaveLength(3);
|
|
298
|
+
expect(larkMock.requests[2]).toEqual({
|
|
299
|
+
method: "DELETE",
|
|
300
|
+
url: "/open-apis/im/v1/messages/om_1/reactions/react_typing_1",
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("refreshes an existing Feishu typing reaction without creating duplicates", async () => {
|
|
305
|
+
const adapter = createFeishuChannel({
|
|
306
|
+
id: "gw_fs",
|
|
307
|
+
accountId: "ag_self",
|
|
308
|
+
appId: "cli_a",
|
|
309
|
+
appSecret: "sec",
|
|
310
|
+
});
|
|
311
|
+
larkMock.responses.push({ code: 0, data: { reaction_id: "react_typing_1" } });
|
|
312
|
+
|
|
313
|
+
await adapter.typing?.({
|
|
314
|
+
traceId: "feishu:om_1",
|
|
315
|
+
accountId: "ag_self",
|
|
316
|
+
conversationId: "feishu:chat:oc_chat",
|
|
317
|
+
log: SILENT_LOG,
|
|
318
|
+
});
|
|
319
|
+
await adapter.typing?.({
|
|
320
|
+
traceId: "feishu:om_1",
|
|
321
|
+
accountId: "ag_self",
|
|
322
|
+
conversationId: "feishu:chat:oc_chat",
|
|
323
|
+
log: SILENT_LOG,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(larkMock.requests).toHaveLength(1);
|
|
327
|
+
expect(larkMock.requests[0]).toEqual({
|
|
328
|
+
method: "POST",
|
|
329
|
+
url: "/open-apis/im/v1/messages/om_1/reactions",
|
|
330
|
+
data: { reaction_type: { emoji_type: "Typing" } },
|
|
331
|
+
});
|
|
279
332
|
});
|
|
280
333
|
|
|
281
334
|
it("surfaces websocket start failures in channel status", async () => {
|
|
@@ -21,6 +21,8 @@ import type { FeishuDomain } from "./feishu-registration.js";
|
|
|
21
21
|
const FEISHU_PROVIDER = "feishu" as const;
|
|
22
22
|
const DEFAULT_SPLIT_AT = 4000;
|
|
23
23
|
const MAX_SEEN_MESSAGES = 2048;
|
|
24
|
+
const TYPING_EMOJI = "Typing";
|
|
25
|
+
const TYPING_REACTION_TTL_MS = 20_000;
|
|
24
26
|
|
|
25
27
|
export interface FeishuChannelOptions {
|
|
26
28
|
id: string;
|
|
@@ -80,6 +82,10 @@ interface FeishuApiResponse {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
type FeishuClient = { request(args: unknown): Promise<unknown> };
|
|
85
|
+
type TypingReactionState = {
|
|
86
|
+
reactionId: string | null;
|
|
87
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
88
|
+
};
|
|
83
89
|
|
|
84
90
|
function sdkDomain(domain: FeishuDomain | undefined): unknown {
|
|
85
91
|
const sdk = Lark as unknown as {
|
|
@@ -137,6 +143,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
137
143
|
let botOpenId: string | undefined;
|
|
138
144
|
let botName: string | undefined;
|
|
139
145
|
let liveSetStatus: ((patch: Partial<ChannelStatusSnapshot>) => void) | null = null;
|
|
146
|
+
const activeTypingReactions = new Map<string, TypingReactionState>();
|
|
140
147
|
|
|
141
148
|
let statusSnapshot: ChannelStatusSnapshot = {
|
|
142
149
|
channel: opts.id,
|
|
@@ -387,6 +394,44 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
387
394
|
);
|
|
388
395
|
}
|
|
389
396
|
|
|
397
|
+
function resultReactionId(res: FeishuApiResponse): string | null {
|
|
398
|
+
return (
|
|
399
|
+
(typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
|
|
400
|
+
(typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
|
|
401
|
+
null
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function messageIdFromTrace(traceId: string): string | null {
|
|
406
|
+
if (!traceId.startsWith("feishu:")) return null;
|
|
407
|
+
const messageId = traceId.slice("feishu:".length).trim();
|
|
408
|
+
return messageId.length > 0 ? messageId : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function removeTypingReaction(messageId: string): Promise<void> {
|
|
412
|
+
const state = activeTypingReactions.get(messageId);
|
|
413
|
+
if (!state) return;
|
|
414
|
+
activeTypingReactions.delete(messageId);
|
|
415
|
+
if (state.timer) clearTimeout(state.timer);
|
|
416
|
+
if (!state.reactionId) return;
|
|
417
|
+
try {
|
|
418
|
+
await callFeishu({
|
|
419
|
+
method: "DELETE",
|
|
420
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
|
|
421
|
+
});
|
|
422
|
+
} catch (err) {
|
|
423
|
+
statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function scheduleTypingCleanup(messageId: string, state: TypingReactionState): void {
|
|
428
|
+
if (state.timer) clearTimeout(state.timer);
|
|
429
|
+
state.timer = setTimeout(() => {
|
|
430
|
+
void removeTypingReaction(messageId);
|
|
431
|
+
}, TYPING_REACTION_TTL_MS);
|
|
432
|
+
if (typeof state.timer.unref === "function") state.timer.unref();
|
|
433
|
+
}
|
|
434
|
+
|
|
390
435
|
function resultResourceKey(res: FeishuApiResponse, key: "image_key" | "file_key"): string {
|
|
391
436
|
const direct = res[key];
|
|
392
437
|
if (typeof direct === "string") return direct;
|
|
@@ -516,15 +561,61 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
516
561
|
replyInThread: Boolean(ctx.message.threadId),
|
|
517
562
|
}) ?? providerMessageId;
|
|
518
563
|
}
|
|
564
|
+
if (ctx.message.replyTo) {
|
|
565
|
+
void removeTypingReaction(ctx.message.replyTo);
|
|
566
|
+
}
|
|
567
|
+
if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
|
|
568
|
+
void removeTypingReaction(ctx.message.threadId);
|
|
569
|
+
}
|
|
519
570
|
markStatus({ lastSendAt: Date.now() });
|
|
520
571
|
return { providerMessageId };
|
|
521
572
|
}
|
|
522
573
|
|
|
523
574
|
async function typing(ctx: ChannelTypingContext): Promise<void> {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
575
|
+
const messageId = messageIdFromTrace(ctx.traceId);
|
|
576
|
+
if (!messageId) {
|
|
577
|
+
ctx.log.debug("feishu typing skipped: trace id has no message id", {
|
|
578
|
+
channel: opts.id,
|
|
579
|
+
conversationId: ctx.conversationId,
|
|
580
|
+
traceId: ctx.traceId,
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const existing = activeTypingReactions.get(messageId);
|
|
585
|
+
if (existing) {
|
|
586
|
+
scheduleTypingCleanup(messageId, existing);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const state: TypingReactionState = { reactionId: null, timer: null };
|
|
591
|
+
activeTypingReactions.set(messageId, state);
|
|
592
|
+
scheduleTypingCleanup(messageId, state);
|
|
593
|
+
try {
|
|
594
|
+
const res = await callFeishu({
|
|
595
|
+
method: "POST",
|
|
596
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
|
|
597
|
+
data: { reaction_type: { emoji_type: TYPING_EMOJI } },
|
|
598
|
+
});
|
|
599
|
+
const reactionId = resultReactionId(res);
|
|
600
|
+
if (activeTypingReactions.get(messageId) !== state) {
|
|
601
|
+
if (reactionId) {
|
|
602
|
+
await callFeishu({
|
|
603
|
+
method: "DELETE",
|
|
604
|
+
url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
state.reactionId = reactionId;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
activeTypingReactions.delete(messageId);
|
|
612
|
+
if (state.timer) clearTimeout(state.timer);
|
|
613
|
+
ctx.log.warn("feishu typing reaction failed", {
|
|
614
|
+
channel: opts.id,
|
|
615
|
+
conversationId: ctx.conversationId,
|
|
616
|
+
err: err instanceof Error ? err.message : String(err),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
528
619
|
}
|
|
529
620
|
|
|
530
621
|
async function stop(_ctx: ChannelStopContext): Promise<void> {
|
|
@@ -534,6 +625,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
|
|
|
534
625
|
// best effort
|
|
535
626
|
}
|
|
536
627
|
wsClient = null;
|
|
628
|
+
await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
|
|
537
629
|
try {
|
|
538
630
|
stateStore?.close();
|
|
539
631
|
} catch {
|
|
@@ -1343,7 +1343,7 @@ export class Dispatcher {
|
|
|
1343
1343
|
threadId: msg.conversation.threadId ?? null,
|
|
1344
1344
|
type: "error",
|
|
1345
1345
|
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
1346
|
-
replyTo: msg
|
|
1346
|
+
replyTo: this.providerReplyTo(msg),
|
|
1347
1347
|
traceId: msg.trace?.id ?? null,
|
|
1348
1348
|
}, turnId);
|
|
1349
1349
|
} else {
|
|
@@ -1389,7 +1389,7 @@ export class Dispatcher {
|
|
|
1389
1389
|
threadId: msg.conversation.threadId ?? null,
|
|
1390
1390
|
type: "error",
|
|
1391
1391
|
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
1392
|
-
replyTo: msg
|
|
1392
|
+
replyTo: this.providerReplyTo(msg),
|
|
1393
1393
|
traceId: msg.trace?.id ?? null,
|
|
1394
1394
|
}, turnId);
|
|
1395
1395
|
} else {
|
|
@@ -1494,7 +1494,7 @@ export class Dispatcher {
|
|
|
1494
1494
|
threadId: msg.conversation.threadId ?? null,
|
|
1495
1495
|
type: "error",
|
|
1496
1496
|
text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
|
|
1497
|
-
replyTo: msg
|
|
1497
|
+
replyTo: this.providerReplyTo(msg),
|
|
1498
1498
|
traceId: msg.trace?.id ?? null,
|
|
1499
1499
|
}, turnId);
|
|
1500
1500
|
this.emitOutbound({
|
|
@@ -1571,7 +1571,7 @@ export class Dispatcher {
|
|
|
1571
1571
|
conversationId: msg.conversation.id,
|
|
1572
1572
|
threadId: msg.conversation.threadId ?? null,
|
|
1573
1573
|
text: replyText,
|
|
1574
|
-
replyTo: msg
|
|
1574
|
+
replyTo: this.providerReplyTo(msg),
|
|
1575
1575
|
traceId: msg.trace?.id ?? null,
|
|
1576
1576
|
}, turnId);
|
|
1577
1577
|
this.emitOutbound({
|
|
@@ -1638,6 +1638,10 @@ export class Dispatcher {
|
|
|
1638
1638
|
return { ok: true };
|
|
1639
1639
|
}
|
|
1640
1640
|
|
|
1641
|
+
private providerReplyTo(msg: GatewayInboundMessage): string {
|
|
1642
|
+
return msg.replyTo ?? msg.id;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1641
1645
|
private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
|
|
1642
1646
|
if (!this.transcript.enabled) return;
|
|
1643
1647
|
const rawText = typeof msg.text === "string" ? msg.text : "";
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
GatewayChannelConfig,
|
|
14
14
|
GatewayConfig,
|
|
15
15
|
GatewayInboundMessage,
|
|
16
|
+
GatewayOutboundMessage,
|
|
16
17
|
GatewayRoute,
|
|
17
18
|
GatewayRuntimeSnapshot,
|
|
18
19
|
InboundObserver,
|
|
@@ -271,4 +272,17 @@ export class Gateway {
|
|
|
271
272
|
async injectInbound(message: GatewayInboundMessage): Promise<void> {
|
|
272
273
|
await this.dispatcher.handle({ message });
|
|
273
274
|
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Send a daemon-control initiated outbound message through a registered
|
|
278
|
+
* channel. Used by proactive third-party gateway sends where the runtime
|
|
279
|
+
* explicitly targets an external provider conversation.
|
|
280
|
+
*/
|
|
281
|
+
async sendOutbound(message: GatewayOutboundMessage): Promise<{ providerMessageId?: string | null }> {
|
|
282
|
+
const channel = this.channelMap.get(message.channel);
|
|
283
|
+
if (!channel) {
|
|
284
|
+
throw new Error(`channel "${message.channel}" is not registered`);
|
|
285
|
+
}
|
|
286
|
+
return channel.send({ message, log: this.log });
|
|
287
|
+
}
|
|
274
288
|
}
|
package/src/gateway-control.ts
CHANGED
|
@@ -176,6 +176,20 @@ interface GatewayRecentSendersResult {
|
|
|
176
176
|
senders: GatewayRecentSender[];
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
interface GatewaySendParams {
|
|
180
|
+
agentId: string;
|
|
181
|
+
gatewayId: string;
|
|
182
|
+
conversationId: string;
|
|
183
|
+
text: string;
|
|
184
|
+
idempotencyKey?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface GatewaySendResult {
|
|
188
|
+
gatewayId: string;
|
|
189
|
+
conversationId: string;
|
|
190
|
+
providerMessageId?: string | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
179
193
|
export type { FetchLike };
|
|
180
194
|
|
|
181
195
|
export interface GatewayControlContext {
|
|
@@ -927,6 +941,80 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
927
941
|
}
|
|
928
942
|
}
|
|
929
943
|
|
|
944
|
+
// --- gateway_send -------------------------------------------------------
|
|
945
|
+
async function handleSend(params: GatewaySendParams): Promise<AckBody> {
|
|
946
|
+
if (!params.agentId || typeof params.agentId !== "string") {
|
|
947
|
+
return badParams("gateway_send: agentId is required");
|
|
948
|
+
}
|
|
949
|
+
if (!params.gatewayId || typeof params.gatewayId !== "string") {
|
|
950
|
+
return badParams("gateway_send: gatewayId is required");
|
|
951
|
+
}
|
|
952
|
+
if (!params.conversationId || typeof params.conversationId !== "string") {
|
|
953
|
+
return badParams("gateway_send: conversationId is required");
|
|
954
|
+
}
|
|
955
|
+
if (typeof params.text !== "string" || params.text.length === 0) {
|
|
956
|
+
return badParams("gateway_send: text is required");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const cfg = cfgIO.load();
|
|
960
|
+
const profile = (cfg.thirdPartyGateways ?? []).find((g) => g.id === params.gatewayId);
|
|
961
|
+
if (!profile) {
|
|
962
|
+
return {
|
|
963
|
+
ok: false,
|
|
964
|
+
error: { code: "unknown_gateway", message: `no gateway with id "${params.gatewayId}"` },
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
if (profile.accountId !== params.agentId) {
|
|
968
|
+
return {
|
|
969
|
+
ok: false,
|
|
970
|
+
error: { code: "account_mismatch", message: "gateway is bound to a different agent" },
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (profile.enabled === false) {
|
|
974
|
+
return {
|
|
975
|
+
ok: false,
|
|
976
|
+
error: { code: "gateway_disabled", message: "gateway is disabled" },
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (profile.type === "wechat") {
|
|
980
|
+
return {
|
|
981
|
+
ok: false,
|
|
982
|
+
error: { code: "unsupported_provider", message: "wechat gateway_send requires an inbound context_token and is not supported" },
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const conversationErr = validateOutboundConversation(profile, params.conversationId);
|
|
987
|
+
if (conversationErr) return conversationErr;
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
const sendResult = await ctx.gateway.sendOutbound({
|
|
991
|
+
channel: params.gatewayId,
|
|
992
|
+
accountId: params.agentId,
|
|
993
|
+
conversationId: params.conversationId,
|
|
994
|
+
text: params.text,
|
|
995
|
+
traceId: `gateway-send:${params.idempotencyKey ?? Date.now()}`,
|
|
996
|
+
});
|
|
997
|
+
const result: GatewaySendResult = {
|
|
998
|
+
gatewayId: params.gatewayId,
|
|
999
|
+
conversationId: params.conversationId,
|
|
1000
|
+
providerMessageId: sendResult.providerMessageId ?? null,
|
|
1001
|
+
};
|
|
1002
|
+
return { ok: true, result };
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1005
|
+
daemonLog.warn("gateway_send failed", {
|
|
1006
|
+
gatewayId: params.gatewayId,
|
|
1007
|
+
accountId: params.agentId,
|
|
1008
|
+
conversationId: params.conversationId,
|
|
1009
|
+
error: message,
|
|
1010
|
+
});
|
|
1011
|
+
return {
|
|
1012
|
+
ok: false,
|
|
1013
|
+
error: { code: "send_failed", message },
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
930
1018
|
return {
|
|
931
1019
|
handleList,
|
|
932
1020
|
handleUpsert,
|
|
@@ -935,6 +1023,7 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
935
1023
|
handleLoginStart,
|
|
936
1024
|
handleLoginStatus,
|
|
937
1025
|
handleRecentSenders,
|
|
1026
|
+
handleSend,
|
|
938
1027
|
/** Exposed for tests — direct access to the in-memory session map. */
|
|
939
1028
|
_sessions: sessions,
|
|
940
1029
|
};
|
|
@@ -959,6 +1048,55 @@ function validateUpsertParams(p: UpsertGatewayParams): string | null {
|
|
|
959
1048
|
return null;
|
|
960
1049
|
}
|
|
961
1050
|
|
|
1051
|
+
function validateOutboundConversation(
|
|
1052
|
+
profile: ThirdPartyGatewayProfile,
|
|
1053
|
+
conversationId: string,
|
|
1054
|
+
): AckBody | null {
|
|
1055
|
+
const chatId = chatIdFromConversation(profile.type, conversationId);
|
|
1056
|
+
if (!chatId) {
|
|
1057
|
+
return {
|
|
1058
|
+
ok: false,
|
|
1059
|
+
error: {
|
|
1060
|
+
code: "bad_conversation",
|
|
1061
|
+
message: `conversationId "${conversationId}" is not valid for ${profile.type}`,
|
|
1062
|
+
},
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
const allowed = new Set((profile.allowedChatIds ?? []).map(String));
|
|
1066
|
+
if (!allowed.has(chatId)) {
|
|
1067
|
+
return {
|
|
1068
|
+
ok: false,
|
|
1069
|
+
error: {
|
|
1070
|
+
code: "conversation_not_allowed",
|
|
1071
|
+
message: "conversation is not in the gateway allowedChatIds list",
|
|
1072
|
+
},
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function chatIdFromConversation(provider: ThirdPartyGatewayProfile["type"], conversationId: string): string | null {
|
|
1079
|
+
if (provider === "telegram") {
|
|
1080
|
+
if (conversationId.startsWith("telegram:user:")) {
|
|
1081
|
+
return conversationId.slice("telegram:user:".length);
|
|
1082
|
+
}
|
|
1083
|
+
if (conversationId.startsWith("telegram:group:")) {
|
|
1084
|
+
return conversationId.slice("telegram:group:".length);
|
|
1085
|
+
}
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
if (provider === "feishu") {
|
|
1089
|
+
if (conversationId.startsWith("feishu:user:")) {
|
|
1090
|
+
return conversationId.slice("feishu:user:".length);
|
|
1091
|
+
}
|
|
1092
|
+
if (conversationId.startsWith("feishu:chat:")) {
|
|
1093
|
+
return conversationId.slice("feishu:chat:".length);
|
|
1094
|
+
}
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
962
1100
|
function annotateProfile(
|
|
963
1101
|
p: ThirdPartyGatewayProfile,
|
|
964
1102
|
status: import("./gateway/index.js").ChannelStatusSnapshot | undefined,
|
package/src/loop-risk.ts
CHANGED
|
@@ -90,6 +90,9 @@ export function stripBotCordPromptScaffolding(text: string): string {
|
|
|
90
90
|
if (line.startsWith("When a message matches an active monitoring rule")) return false;
|
|
91
91
|
if (line.startsWith("keyword, sender rule")) return false;
|
|
92
92
|
if (line.startsWith("you do not reply to the group")) return false;
|
|
93
|
+
if (line.startsWith("Before replying NO_REPLY in a non-owner group room")) return false;
|
|
94
|
+
if (line.startsWith("match a memory-backed monitoring rule")) return false;
|
|
95
|
+
if (line.startsWith("or owner-approved workflow. If needed")) return false;
|
|
93
96
|
if (line.startsWith("[If the conversation has naturally concluded")) return false;
|
|
94
97
|
if (line.startsWith("[You received a contact request")) return false;
|
|
95
98
|
if (line.includes("no background action is needed")) return false;
|