@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.
@@ -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
- ctx.log.debug("feishu typing ignored: no native bot typing API", {
430
- channel: opts.id,
431
- conversationId: ctx.conversationId,
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
  }
@@ -149,6 +149,7 @@ export declare class Dispatcher {
149
149
  private recomposeUserTurn;
150
150
  private runTurn;
151
151
  private sendReply;
152
+ private providerReplyTo;
152
153
  private emitInbound;
153
154
  private emitOutbound;
154
155
  }
@@ -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.id,
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.id,
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.id,
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.id,
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
  }
@@ -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
  };
@@ -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.69",
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": "./dist/index.js"
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.18.0"
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();