@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.
@@ -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({
@@ -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: "m_err",
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("m_err");
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("exposes typing as a safe no-op because Feishu has no bot typing API", async () => {
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: { ...SILENT_LOG, debug },
275
+ log: SILENT_LOG,
272
276
  });
273
277
 
274
- expect(larkMock.requests).toHaveLength(0);
275
- expect(debug).toHaveBeenCalledWith(
276
- "feishu typing ignored: no native bot typing API",
277
- expect.objectContaining({ channel: "gw_fs" }),
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
- ctx.log.debug("feishu typing ignored: no native bot typing API", {
525
- channel: opts.id,
526
- conversationId: ctx.conversationId,
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.id,
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.id,
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.id,
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.id,
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 : "";
@@ -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
  }
@@ -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;