@spinabot/brigade 1.3.2 → 1.5.0

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.
Files changed (238) hide show
  1. package/README.md +74 -11
  2. package/convex/config.d.ts +2 -2
  3. package/convex/extensions.d.ts +2 -2
  4. package/convex/logs.d.ts +2 -2
  5. package/convex/memory.d.ts +7 -7
  6. package/convex/messages.d.ts +4 -4
  7. package/convex/schema.d.ts +17 -17
  8. package/convex/subagents.d.ts +12 -12
  9. package/dist/agents/agent-loop.d.ts +1 -0
  10. package/dist/agents/agent-loop.d.ts.map +1 -1
  11. package/dist/agents/agent-loop.js +1 -1
  12. package/dist/agents/agent-loop.js.map +1 -1
  13. package/dist/agents/channels/access-control/format-allow-from.d.ts +50 -0
  14. package/dist/agents/channels/access-control/format-allow-from.d.ts.map +1 -0
  15. package/dist/agents/channels/access-control/format-allow-from.js +64 -0
  16. package/dist/agents/channels/access-control/format-allow-from.js.map +1 -0
  17. package/dist/agents/channels/access-control/index.d.ts +2 -1
  18. package/dist/agents/channels/access-control/index.d.ts.map +1 -1
  19. package/dist/agents/channels/access-control/index.js +2 -1
  20. package/dist/agents/channels/access-control/index.js.map +1 -1
  21. package/dist/agents/channels/access-control/store.d.ts +15 -0
  22. package/dist/agents/channels/access-control/store.d.ts.map +1 -1
  23. package/dist/agents/channels/access-control/store.js +44 -1
  24. package/dist/agents/channels/access-control/store.js.map +1 -1
  25. package/dist/agents/channels/bundled-channel-metas.d.ts +26 -0
  26. package/dist/agents/channels/bundled-channel-metas.d.ts.map +1 -0
  27. package/dist/agents/channels/bundled-channel-metas.js +44 -0
  28. package/dist/agents/channels/bundled-channel-metas.js.map +1 -0
  29. package/dist/agents/channels/channel-messaging-registry.d.ts +130 -0
  30. package/dist/agents/channels/channel-messaging-registry.d.ts.map +1 -0
  31. package/dist/agents/channels/channel-messaging-registry.js +211 -0
  32. package/dist/agents/channels/channel-messaging-registry.js.map +1 -0
  33. package/dist/agents/channels/channel-meta-registry.d.ts +60 -0
  34. package/dist/agents/channels/channel-meta-registry.d.ts.map +1 -0
  35. package/dist/agents/channels/channel-meta-registry.js +128 -0
  36. package/dist/agents/channels/channel-meta-registry.js.map +1 -0
  37. package/dist/agents/channels/channel-security-registry.d.ts +138 -0
  38. package/dist/agents/channels/channel-security-registry.d.ts.map +1 -0
  39. package/dist/agents/channels/channel-security-registry.js +265 -0
  40. package/dist/agents/channels/channel-security-registry.js.map +1 -0
  41. package/dist/agents/channels/exposure.d.ts +44 -0
  42. package/dist/agents/channels/exposure.d.ts.map +1 -0
  43. package/dist/agents/channels/exposure.js +48 -0
  44. package/dist/agents/channels/exposure.js.map +1 -0
  45. package/dist/agents/channels/general-callback.d.ts +25 -0
  46. package/dist/agents/channels/general-callback.d.ts.map +1 -0
  47. package/dist/agents/channels/general-callback.js +31 -0
  48. package/dist/agents/channels/general-callback.js.map +1 -0
  49. package/dist/agents/channels/inbound-pipeline.d.ts +9 -0
  50. package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
  51. package/dist/agents/channels/inbound-pipeline.js +429 -39
  52. package/dist/agents/channels/inbound-pipeline.js.map +1 -1
  53. package/dist/agents/channels/markdown-capability.d.ts +44 -0
  54. package/dist/agents/channels/markdown-capability.d.ts.map +1 -0
  55. package/dist/agents/channels/markdown-capability.js +66 -0
  56. package/dist/agents/channels/markdown-capability.js.map +1 -0
  57. package/dist/agents/channels/sdk.d.ts +170 -10
  58. package/dist/agents/channels/sdk.d.ts.map +1 -1
  59. package/dist/agents/channels/sdk.js +138 -6
  60. package/dist/agents/channels/sdk.js.map +1 -1
  61. package/dist/agents/channels/telegram/account-config.d.ts +41 -0
  62. package/dist/agents/channels/telegram/account-config.d.ts.map +1 -1
  63. package/dist/agents/channels/telegram/account-config.js +79 -0
  64. package/dist/agents/channels/telegram/account-config.js.map +1 -1
  65. package/dist/agents/channels/telegram/adapter.d.ts +6 -0
  66. package/dist/agents/channels/telegram/adapter.d.ts.map +1 -1
  67. package/dist/agents/channels/telegram/adapter.js +185 -6
  68. package/dist/agents/channels/telegram/adapter.js.map +1 -1
  69. package/dist/agents/channels/telegram/allowed-updates.d.ts +14 -5
  70. package/dist/agents/channels/telegram/allowed-updates.d.ts.map +1 -1
  71. package/dist/agents/channels/telegram/allowed-updates.js +8 -4
  72. package/dist/agents/channels/telegram/allowed-updates.js.map +1 -1
  73. package/dist/agents/channels/telegram/connection.d.ts +118 -1
  74. package/dist/agents/channels/telegram/connection.d.ts.map +1 -1
  75. package/dist/agents/channels/telegram/connection.js +380 -8
  76. package/dist/agents/channels/telegram/connection.js.map +1 -1
  77. package/dist/agents/channels/telegram/draft-stream.d.ts +98 -0
  78. package/dist/agents/channels/telegram/draft-stream.d.ts.map +1 -0
  79. package/dist/agents/channels/telegram/draft-stream.js +222 -0
  80. package/dist/agents/channels/telegram/draft-stream.js.map +1 -0
  81. package/dist/agents/channels/telegram/format.d.ts +17 -0
  82. package/dist/agents/channels/telegram/format.d.ts.map +1 -1
  83. package/dist/agents/channels/telegram/format.js +36 -0
  84. package/dist/agents/channels/telegram/format.js.map +1 -1
  85. package/dist/agents/channels/telegram/inbound-extras.d.ts +27 -2
  86. package/dist/agents/channels/telegram/inbound-extras.d.ts.map +1 -1
  87. package/dist/agents/channels/telegram/inbound-extras.js +134 -7
  88. package/dist/agents/channels/telegram/inbound-extras.js.map +1 -1
  89. package/dist/agents/channels/telegram/inline-keyboard.d.ts +36 -0
  90. package/dist/agents/channels/telegram/inline-keyboard.d.ts.map +1 -0
  91. package/dist/agents/channels/telegram/inline-keyboard.js +62 -0
  92. package/dist/agents/channels/telegram/inline-keyboard.js.map +1 -0
  93. package/dist/agents/channels/telegram/media.d.ts +8 -0
  94. package/dist/agents/channels/telegram/media.d.ts.map +1 -1
  95. package/dist/agents/channels/telegram/media.js +30 -2
  96. package/dist/agents/channels/telegram/media.js.map +1 -1
  97. package/dist/agents/channels/telegram/plugin.d.ts.map +1 -1
  98. package/dist/agents/channels/telegram/plugin.js +7 -11
  99. package/dist/agents/channels/telegram/plugin.js.map +1 -1
  100. package/dist/agents/channels/telegram/reasoning-lane.d.ts +41 -0
  101. package/dist/agents/channels/telegram/reasoning-lane.d.ts.map +1 -0
  102. package/dist/agents/channels/telegram/reasoning-lane.js +67 -0
  103. package/dist/agents/channels/telegram/reasoning-lane.js.map +1 -0
  104. package/dist/agents/channels/telegram/socks-dispatcher.d.ts +32 -0
  105. package/dist/agents/channels/telegram/socks-dispatcher.d.ts.map +1 -0
  106. package/dist/agents/channels/telegram/socks-dispatcher.js +97 -0
  107. package/dist/agents/channels/telegram/socks-dispatcher.js.map +1 -0
  108. package/dist/agents/channels/types.adapters.d.ts +137 -4
  109. package/dist/agents/channels/types.adapters.d.ts.map +1 -1
  110. package/dist/agents/channels/types.adapters.js +2 -2
  111. package/dist/agents/channels/types.core.d.ts +26 -2
  112. package/dist/agents/channels/types.core.d.ts.map +1 -1
  113. package/dist/agents/channels/types.plugin.d.ts +25 -7
  114. package/dist/agents/channels/types.plugin.d.ts.map +1 -1
  115. package/dist/agents/channels/types.plugin.js +6 -5
  116. package/dist/agents/channels/types.plugin.js.map +1 -1
  117. package/dist/agents/channels/whatsapp/adapter.d.ts +8 -0
  118. package/dist/agents/channels/whatsapp/adapter.d.ts.map +1 -1
  119. package/dist/agents/channels/whatsapp/adapter.js +7 -4
  120. package/dist/agents/channels/whatsapp/adapter.js.map +1 -1
  121. package/dist/agents/channels/whatsapp/connection.d.ts +24 -2
  122. package/dist/agents/channels/whatsapp/connection.d.ts.map +1 -1
  123. package/dist/agents/channels/whatsapp/connection.js +26 -5
  124. package/dist/agents/channels/whatsapp/connection.js.map +1 -1
  125. package/dist/agents/channels/whatsapp/plugin.d.ts.map +1 -1
  126. package/dist/agents/channels/whatsapp/plugin.js +6 -10
  127. package/dist/agents/channels/whatsapp/plugin.js.map +1 -1
  128. package/dist/agents/extensions/activation-planner.d.ts +125 -0
  129. package/dist/agents/extensions/activation-planner.d.ts.map +1 -0
  130. package/dist/agents/extensions/activation-planner.js +221 -0
  131. package/dist/agents/extensions/activation-planner.js.map +1 -0
  132. package/dist/agents/extensions/diagnose.d.ts +84 -0
  133. package/dist/agents/extensions/diagnose.d.ts.map +1 -0
  134. package/dist/agents/extensions/diagnose.js +123 -0
  135. package/dist/agents/extensions/diagnose.js.map +1 -0
  136. package/dist/agents/extensions/discovery.d.ts +85 -7
  137. package/dist/agents/extensions/discovery.d.ts.map +1 -1
  138. package/dist/agents/extensions/discovery.js +200 -15
  139. package/dist/agents/extensions/discovery.js.map +1 -1
  140. package/dist/agents/extensions/index.d.ts +3 -2
  141. package/dist/agents/extensions/index.d.ts.map +1 -1
  142. package/dist/agents/extensions/index.js +3 -2
  143. package/dist/agents/extensions/index.js.map +1 -1
  144. package/dist/agents/extensions/install-scan.d.ts +63 -0
  145. package/dist/agents/extensions/install-scan.d.ts.map +1 -0
  146. package/dist/agents/extensions/install-scan.js +201 -0
  147. package/dist/agents/extensions/install-scan.js.map +1 -0
  148. package/dist/agents/extensions/install.d.ts +135 -0
  149. package/dist/agents/extensions/install.d.ts.map +1 -0
  150. package/dist/agents/extensions/install.js +414 -0
  151. package/dist/agents/extensions/install.js.map +1 -0
  152. package/dist/agents/extensions/loader.d.ts +13 -2
  153. package/dist/agents/extensions/loader.d.ts.map +1 -1
  154. package/dist/agents/extensions/loader.js +126 -13
  155. package/dist/agents/extensions/loader.js.map +1 -1
  156. package/dist/agents/extensions/registry.d.ts +109 -0
  157. package/dist/agents/extensions/registry.d.ts.map +1 -1
  158. package/dist/agents/extensions/registry.js +172 -0
  159. package/dist/agents/extensions/registry.js.map +1 -1
  160. package/dist/agents/extensions/sdk-alias.d.ts +45 -0
  161. package/dist/agents/extensions/sdk-alias.d.ts.map +1 -0
  162. package/dist/agents/extensions/sdk-alias.js +94 -0
  163. package/dist/agents/extensions/sdk-alias.js.map +1 -0
  164. package/dist/agents/extensions/types.d.ts +166 -1
  165. package/dist/agents/extensions/types.d.ts.map +1 -1
  166. package/dist/agents/extensions/types.js.map +1 -1
  167. package/dist/agents/tools/composio-tool.d.ts +9 -1
  168. package/dist/agents/tools/composio-tool.d.ts.map +1 -1
  169. package/dist/agents/tools/composio-tool.js +68 -4
  170. package/dist/agents/tools/composio-tool.js.map +1 -1
  171. package/dist/agents/tools/message-action-tool.d.ts +6 -1
  172. package/dist/agents/tools/message-action-tool.d.ts.map +1 -1
  173. package/dist/agents/tools/message-action-tool.js +52 -2
  174. package/dist/agents/tools/message-action-tool.js.map +1 -1
  175. package/dist/agents/tools/send-message-tool.d.ts +1 -0
  176. package/dist/agents/tools/send-message-tool.d.ts.map +1 -1
  177. package/dist/agents/tools/send-message-tool.js +56 -1
  178. package/dist/agents/tools/send-message-tool.js.map +1 -1
  179. package/dist/buildstamp.json +1 -1
  180. package/dist/channel-sdk.d.ts +28 -0
  181. package/dist/channel-sdk.d.ts.map +1 -0
  182. package/dist/channel-sdk.js +28 -0
  183. package/dist/channel-sdk.js.map +1 -0
  184. package/dist/cli/commands/channels.d.ts.map +1 -1
  185. package/dist/cli/commands/channels.js +8 -8
  186. package/dist/cli/commands/channels.js.map +1 -1
  187. package/dist/cli/commands/connect.d.ts +8 -11
  188. package/dist/cli/commands/connect.d.ts.map +1 -1
  189. package/dist/cli/commands/connect.js +157 -17
  190. package/dist/cli/commands/connect.js.map +1 -1
  191. package/dist/cli/commands/convex-cmd.d.ts +2 -1
  192. package/dist/cli/commands/convex-cmd.d.ts.map +1 -1
  193. package/dist/cli/commands/convex-cmd.js +79 -6
  194. package/dist/cli/commands/convex-cmd.js.map +1 -1
  195. package/dist/cli/commands/doctor.d.ts.map +1 -1
  196. package/dist/cli/commands/doctor.js +64 -0
  197. package/dist/cli/commands/doctor.js.map +1 -1
  198. package/dist/cli/commands/extensions.d.ts +46 -0
  199. package/dist/cli/commands/extensions.d.ts.map +1 -0
  200. package/dist/cli/commands/extensions.js +578 -0
  201. package/dist/cli/commands/extensions.js.map +1 -0
  202. package/dist/cli/commands/pairing.d.ts.map +1 -1
  203. package/dist/cli/commands/pairing.js +16 -2
  204. package/dist/cli/commands/pairing.js.map +1 -1
  205. package/dist/cli/commands/update.d.ts +17 -0
  206. package/dist/cli/commands/update.d.ts.map +1 -0
  207. package/dist/cli/commands/update.js +104 -0
  208. package/dist/cli/commands/update.js.map +1 -0
  209. package/dist/cli/program/build-program.d.ts.map +1 -1
  210. package/dist/cli/program/build-program.js +114 -1
  211. package/dist/cli/program/build-program.js.map +1 -1
  212. package/dist/config/paths.d.ts +1 -0
  213. package/dist/config/paths.d.ts.map +1 -1
  214. package/dist/config/paths.js +9 -0
  215. package/dist/config/paths.js.map +1 -1
  216. package/dist/core/server.d.ts.map +1 -1
  217. package/dist/core/server.js +134 -2
  218. package/dist/core/server.js.map +1 -1
  219. package/dist/protocol.d.ts +25 -0
  220. package/dist/protocol.d.ts.map +1 -1
  221. package/dist/protocol.js.map +1 -1
  222. package/dist/system-prompt/assembler.d.ts.map +1 -1
  223. package/dist/system-prompt/assembler.js +17 -0
  224. package/dist/system-prompt/assembler.js.map +1 -1
  225. package/dist/system-prompt/identity-defaults.d.ts +28 -0
  226. package/dist/system-prompt/identity-defaults.d.ts.map +1 -1
  227. package/dist/system-prompt/identity-defaults.js +47 -0
  228. package/dist/system-prompt/identity-defaults.js.map +1 -1
  229. package/dist/ui/editor.d.ts.map +1 -1
  230. package/dist/ui/editor.js +1 -0
  231. package/dist/ui/editor.js.map +1 -1
  232. package/dist/version.d.ts +4 -3
  233. package/dist/version.d.ts.map +1 -1
  234. package/dist/version.js +27 -5
  235. package/dist/version.js.map +1 -1
  236. package/package.json +21 -4
  237. package/scripts/build-done.mjs +11 -2
  238. package/scripts/convex-dev.mjs +28 -2
@@ -24,9 +24,10 @@
24
24
  */
25
25
  import { createSubsystemLogger } from "../../logging/subsystem-logger.js";
26
26
  import { getActiveRegistry } from "../extensions/active-registry.js";
27
- import { addAllowFrom, evaluateAccess, readAllowFrom, readGroupAllowFrom, removeAllowFrom, upsertPairingRequest, } from "./access-control/index.js";
27
+ import { addAllowFrom, approvePairingCode, evaluateAccess, formatAllowFrom, readAllowFrom, readChannelOwner, readGroupAllowFrom, readPendingPairings, removeAllowFrom, revokePairingCode, upsertPairingRequest, } from "./access-control/index.js";
28
28
  import { isAbortTrigger } from "./abort-triggers.js";
29
29
  import { buildAgentSwitchCommands } from "./agent-switch-command.js";
30
+ import { decodeGeneralCallbackData, isGeneralCallbackData } from "./general-callback.js";
30
31
  import { tryConsumeChannelApprovalCallback, tryConsumeChannelApprovalReply, } from "./approval-router.js";
31
32
  import { recordLastChannelForAgent } from "./last-channel.js";
32
33
  import { recordLastSentMessage } from "./last-sent-message.js";
@@ -35,6 +36,8 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
35
36
  import { normalizeAccountId } from "../routing/account-id.js";
36
37
  import { resolveThreadSessionKeys } from "../routing/session-key.js";
37
38
  import { resolveLinkedPeerIdFromConfig } from "../identity-links.js";
39
+ import { resolveInboundConversation } from "./channel-messaging-registry.js";
40
+ import { consultChannelDmPolicy } from "./channel-security-registry.js";
38
41
  import { sanitizeReplyForChannel } from "./reply-sanitizer.js";
39
42
  import { classifyErrorReason, isBrigadeRetryError } from "../error-classifier.js";
40
43
  import { isRetryExhaustedError } from "../retry-policy.js";
@@ -146,9 +149,11 @@ function buildChallengeReply(args) {
146
149
  "```",
147
150
  args.code,
148
151
  "```",
149
- "Share it with your admin they'll approve you by running:",
152
+ "Send this code to your admin. They approve you by either:",
153
+ ` • replying *\`/approve ${args.code}\`* to this bot, or`,
154
+ ` • running this on the server:`,
150
155
  "```",
151
- `brigade pairing approve ${args.code}`,
156
+ `brigade pairing approve ${args.code} --channel ${args.channelId}`,
152
157
  "```",
153
158
  "✨ Once approved, just send your next message.",
154
159
  "⏱️ _Expires in 1 hour._",
@@ -158,11 +163,17 @@ function buildChallengeReply(args) {
158
163
  }
159
164
  /** Bundled built-in channel commands an operator can DM to admin the bot. */
160
165
  export function buildBundledCommands(adapter) {
161
- const isOperator = (senderId) => {
166
+ const norm = (s) => s.replace(/\s+/g, "");
167
+ const isOperator = (senderId, accountId) => {
168
+ const id = norm(senderId);
169
+ // The linked-self id (WhatsApp: the bot runs AS the operator).
162
170
  const self = adapter.selfId?.();
163
- if (!self)
164
- return false;
165
- return self.replace(/\s+/g, "") === senderId.replace(/\s+/g, "");
171
+ if (self && norm(self) === id)
172
+ return true;
173
+ // The recorded channel owner (Telegram: bot operator, owner is claimed
174
+ // via first /start). accountId scopes multi-account installs.
175
+ const owner = readChannelOwner(adapter.id, accountId ?? null);
176
+ return !!owner && norm(owner) === id;
166
177
  };
167
178
  return [
168
179
  {
@@ -171,7 +182,11 @@ export function buildBundledCommands(adapter) {
171
182
  handler: () => [
172
183
  "Brigade channel commands:",
173
184
  " /help — show this list",
185
+ " /start — welcome + how to use this bot",
174
186
  " /status — show your access state on this channel",
187
+ " /pending — operator-only: list people waiting for approval",
188
+ " /approve <code> — operator-only: approve a waiting person by their code",
189
+ " /deny <code> — operator-only: reject a pending request",
175
190
  " /allowlist list — operator-only: show approved senders",
176
191
  " /allowlist add <id> | /allowlist remove <id> — operator-only",
177
192
  " /agent <id> — pin future messages from you to that agent",
@@ -185,11 +200,85 @@ export function buildBundledCommands(adapter) {
185
200
  " stop / cancel / abort — kill the current turn",
186
201
  ].join("\n"),
187
202
  },
203
+ {
204
+ name: "start",
205
+ description: "Welcome message + how to use this bot.",
206
+ handler: (ctx) => {
207
+ if (isOperator(ctx.from, ctx.accountId ?? null)) {
208
+ return [
209
+ "🦁 *Brigade* — your private AI crew is online.",
210
+ "",
211
+ "Just send a message to chat with your crew.",
212
+ "Admin: /pending, /approve <code>, /allowlist, /agents, /org.",
213
+ "Type /help for the full list.",
214
+ ].join("\n");
215
+ }
216
+ return [
217
+ "🦁 *Brigade* — your private AI crew.",
218
+ "",
219
+ "Send a message and your crew will reply.",
220
+ "Type /help to see what you can do, or /whoami to see who answers you.",
221
+ ].join("\n");
222
+ },
223
+ },
224
+ {
225
+ name: "pending",
226
+ description: "Operator-only: list people waiting for approval.",
227
+ handler: (ctx) => {
228
+ if (!isOperator(ctx.from, ctx.accountId ?? null))
229
+ return "This command can only be run by the operator (the linked account).";
230
+ const pending = readPendingPairings(ctx.channel, ctx.accountId ?? null);
231
+ if (pending.length === 0)
232
+ return "No one is waiting for approval right now.";
233
+ const lines = pending.map((r) => {
234
+ const who = r.senderName ? `${r.senderName} (${r.senderId})` : r.senderId;
235
+ return ` ${r.code} — ${who}`;
236
+ });
237
+ return [
238
+ `${pending.length} waiting for approval:`,
239
+ ...lines,
240
+ "",
241
+ "Approve with /approve <code>, or reject with /deny <code>.",
242
+ ].join("\n");
243
+ },
244
+ },
245
+ {
246
+ name: "approve",
247
+ description: "Operator-only: approve a waiting person by their pairing code.",
248
+ handler: (ctx) => {
249
+ if (!isOperator(ctx.from, ctx.accountId ?? null))
250
+ return "This command can only be run by the operator (the linked account).";
251
+ const code = ctx.args.trim().split(/\s+/)[0] ?? "";
252
+ if (!code)
253
+ return "Usage: /approve <code> (see waiting codes with /pending)";
254
+ const approved = approvePairingCode(ctx.channel, code, ctx.accountId ?? null);
255
+ if (!approved)
256
+ return `No pending request with code "${code}". Check /pending.`;
257
+ const who = approved.senderName
258
+ ? `${approved.senderName} (${approved.senderId})`
259
+ : approved.senderId;
260
+ return `✅ Approved ${who}. They can chat now — no restart needed.`;
261
+ },
262
+ },
263
+ {
264
+ name: "deny",
265
+ description: "Operator-only: reject a pending request by its pairing code.",
266
+ handler: (ctx) => {
267
+ if (!isOperator(ctx.from, ctx.accountId ?? null))
268
+ return "This command can only be run by the operator (the linked account).";
269
+ const code = ctx.args.trim().split(/\s+/)[0] ?? "";
270
+ if (!code)
271
+ return "Usage: /deny <code> (see waiting codes with /pending)";
272
+ return revokePairingCode(ctx.channel, code, ctx.accountId ?? null)
273
+ ? `⛔ Rejected request ${code}.`
274
+ : `No pending request with code "${code}".`;
275
+ },
276
+ },
188
277
  {
189
278
  name: "status",
190
279
  description: "Show your access state on this channel.",
191
280
  handler: (ctx) => {
192
- const op = isOperator(ctx.from);
281
+ const op = isOperator(ctx.from, ctx.accountId ?? null);
193
282
  const allow = readAllowFrom(ctx.channel);
194
283
  const role = op ? "operator (self)" : allow.includes(ctx.from) ? "approved" : "unapproved";
195
284
  return [
@@ -204,15 +293,15 @@ export function buildBundledCommands(adapter) {
204
293
  name: "allowlist",
205
294
  description: "Operator-only: list / add / remove approved senders.",
206
295
  handler: (ctx) => {
207
- if (!isOperator(ctx.from))
296
+ if (!isOperator(ctx.from, ctx.accountId ?? null))
208
297
  return "This command can only be run by the operator (the linked account).";
209
298
  const parts = ctx.args.trim().split(/\s+/);
210
299
  const sub = (parts[0] ?? "list").toLowerCase();
211
300
  if (sub === "list" || !sub) {
212
301
  const allow = readAllowFrom(ctx.channel);
213
- return allow.length === 0
214
- ? "Allow-from list is empty."
215
- : `Allow-from (${allow.length}):\n ${allow.join("\n ")}`;
302
+ // Shared display formatter so the in-chat `/allowlist list` and the
303
+ // `brigade channels allow list` CLI render the list identically.
304
+ return formatAllowFrom(allow);
216
305
  }
217
306
  const target = parts[1];
218
307
  if (sub === "add" && target) {
@@ -260,13 +349,23 @@ async function safeSendText(adapter, conversationId, text, opts) {
260
349
  });
261
350
  }
262
351
  }
263
- /** Build the opts object the adapter expects, omitting undefined keys. */
264
- function buildSendOpts(threadId, accountId) {
352
+ /**
353
+ * Build the opts object the adapter expects, omitting undefined keys.
354
+ *
355
+ * `replyToId` is OPTIONAL and additive: pass the inbound message's id ONLY at a
356
+ * genuine reply-to-inbound send site so the adapter quotes the message it
357
+ * answers (WhatsApp quote / Telegram `reply_parameters`). Omit it everywhere
358
+ * else — a build with no `replyToId` produces a byte-identical opts object to
359
+ * before this field existed, so non-reply sends are unchanged.
360
+ */
361
+ function buildSendOpts(threadId, accountId, replyToId) {
265
362
  const out = {};
266
363
  if (threadId)
267
364
  out.threadId = threadId;
268
365
  if (accountId)
269
366
  out.accountId = accountId;
367
+ if (replyToId)
368
+ out.replyToId = replyToId;
270
369
  return Object.keys(out).length > 0 ? out : undefined;
271
370
  }
272
371
  /**
@@ -288,9 +387,48 @@ export async function runChannelInboundPipeline(ctx, msg) {
288
387
  !msg.callbackQuery) {
289
388
  return;
290
389
  }
390
+ // Plugin hook: `inbound_claim` (CLAIMING). A plugin may take ownership of
391
+ // this raw inbound BEFORE Brigade's access-control gate runs — the first
392
+ // handler to return `{ handled: true }` claims it and we abandon the
393
+ // inbound entirely (no gate, no command, no dispatch). Fires only when a
394
+ // registry is mounted (gateway boot); a non-gateway path skips it.
395
+ {
396
+ const registry = getActiveRegistry();
397
+ if (registry) {
398
+ const claim = await registry.fireHook("inbound_claim", {
399
+ channel: adapter.id,
400
+ msg,
401
+ });
402
+ if (claim.handled) {
403
+ log.debug("inbound claimed by plugin hook", {
404
+ channel: adapter.id,
405
+ conversationId: msg.conversationId,
406
+ by: claim.by,
407
+ });
408
+ return;
409
+ }
410
+ }
411
+ }
291
412
  // Access-control gate.
292
413
  const isGroup = msg.isGroup === true || msg.chatType === "group";
293
- const dmPolicy = resolveDmPolicy(cfg, adapter.id);
414
+ // Central config-read policy (AUTHORITATIVE). A channel plugin MAY also
415
+ // register a SUPPLEMENTARY security adapter; we consult it right here and
416
+ // reconcile under a strict TIGHTEN-ONLY rule — the adapter can make the
417
+ // effective DM policy stricter (owner-only > allowlist > open > the config)
418
+ // but can NEVER loosen it, and a channel that doesn't opt in leaves
419
+ // `dmPolicy` byte-identical to the local read. Never throws.
420
+ const localDmPolicy = resolveDmPolicy(cfg, adapter.id);
421
+ const dmPolicy = consultChannelDmPolicy({
422
+ channelId: adapter.id,
423
+ base: localDmPolicy,
424
+ ctx: {
425
+ account: undefined,
426
+ accountId: msg.accountId?.trim() || "",
427
+ cfg,
428
+ ...(msg.from?.trim() ? { peerId: msg.from.trim() } : {}),
429
+ peerKind: isGroup ? "group" : "direct",
430
+ },
431
+ });
294
432
  const groupPolicy = resolveGroupPolicy(cfg, adapter.id);
295
433
  const cfgEntry = channelAccessCfg(cfg, adapter.id);
296
434
  // Per-account ACL — multi-account WhatsApp installs partition the
@@ -307,7 +445,14 @@ export async function runChannelInboundPipeline(ctx, msg) {
307
445
  const groupAllowJids = configIds(cfgEntry.groupAllowJids);
308
446
  const selfId = adapter.selfId?.();
309
447
  const mentioned = !!(selfId && msg.mentions?.includes(selfId));
310
- const senderIsOwner = !!(selfId && selfId.trim() === msg.from.trim());
448
+ const fromId = msg.from.trim();
449
+ // On WhatsApp the bot runs AS the operator, so selfId === operator. On a
450
+ // separate-bot channel (Telegram) the operator is the RECORDED owner —
451
+ // established securely by the first CLI `pairing approve` (gateway-machine
452
+ // access is the proof), NOT by anyone who merely texts /start.
453
+ const isSelfOperator = !!(selfId && selfId.trim() === fromId);
454
+ const channelOwner = readChannelOwner(adapter.id, aclAccountId);
455
+ const senderIsOwner = isSelfOperator || (!!channelOwner && channelOwner === fromId);
311
456
  // "Addressed" superset for groups: mention OR a reply/quote to one of the
312
457
  // bot's OWN messages OR within the active follow-up window for this speaker.
313
458
  // Lets a member tag once / reply to the bot and keep the thread going
@@ -320,20 +465,24 @@ export async function runChannelInboundPipeline(ctx, msg) {
320
465
  const addressed = isGroup
321
466
  ? mentioned || isReplyToBot || withinGroupFollowUp(fuKey, groupFollowUpWindowMs)
322
467
  : mentioned;
323
- const decision = evaluateAccess({
324
- policy: dmPolicy,
325
- groupPolicy,
326
- senderId: msg.from,
327
- ...(msg.senderLid !== undefined ? { senderLid: msg.senderLid } : {}),
328
- selfId,
329
- allowFrom,
330
- groupAllowFrom,
331
- groupAllowJids,
332
- groupId: msg.conversationId,
333
- isGroup,
334
- mentioned,
335
- addressed,
336
- });
468
+ // The owner is ALWAYS admitted — skip the pairing/allowlist gate entirely
469
+ // (covers the just-claimed Telegram owner, whose id isn't selfId).
470
+ const decision = senderIsOwner
471
+ ? { kind: "allow", reason: "owner" }
472
+ : evaluateAccess({
473
+ policy: dmPolicy,
474
+ groupPolicy,
475
+ senderId: msg.from,
476
+ ...(msg.senderLid !== undefined ? { senderLid: msg.senderLid } : {}),
477
+ selfId,
478
+ allowFrom,
479
+ groupAllowFrom,
480
+ groupAllowJids,
481
+ groupId: msg.conversationId,
482
+ isGroup,
483
+ mentioned,
484
+ addressed,
485
+ });
337
486
  // Keep the active-conversation window alive on every admitted group turn,
338
487
  // so an ongoing back-and-forth doesn't require re-tagging.
339
488
  if (isGroup && decision.kind === "allow")
@@ -380,9 +529,13 @@ export async function runChannelInboundPipeline(ctx, msg) {
380
529
  await safeSendText(adapter, msg.conversationId, buildChallengeReply({
381
530
  code,
382
531
  senderId: challengeSenderId,
532
+ channelId: adapter.id,
383
533
  channelLabel: adapter.label,
384
534
  idLabel: adapter.pairing?.idLabel,
385
- }), buildSendOpts(msg.threadId, msg.accountId));
535
+ }),
536
+ // Genuine reply-to-inbound: quote the message that triggered the
537
+ // challenge so the requester sees what they're being asked to verify.
538
+ buildSendOpts(msg.threadId, msg.accountId, msg.messageId));
386
539
  if (msg.messageId && adapter.markRead) {
387
540
  try {
388
541
  await adapter.markRead(msg.conversationId, msg.messageId, msg.participantId);
@@ -443,9 +596,31 @@ export async function runChannelInboundPipeline(ctx, msg) {
443
596
  await safeSendText(adapter, msg.conversationId, cbResult.reason ?? "Not authorized to answer that approval.", buildSendOpts(msg.threadId, msg.accountId));
444
597
  return;
445
598
  }
446
- // A callback that matched no pending approval (stale / foreign
447
- // button) is dropped silently, there is nothing to dispatch.
448
- return;
599
+ // GENERAL (agent-attached) button: not an approval. Decode the
600
+ // app-defined token and route it through the pipeline as a synthetic
601
+ // turn so the agent that attached the button can react to the tap.
602
+ // We rewrite `msg.text` and FALL THROUGH to the normal routing +
603
+ // dispatch path below (instead of returning).
604
+ if (isGeneralCallbackData(msg.callbackQuery.data)) {
605
+ const token = decodeGeneralCallbackData(msg.callbackQuery.data);
606
+ if (token) {
607
+ // Synthetic inbound text the agent sees for the tap. Kept short +
608
+ // explicit so the agent can branch on the token it set.
609
+ msg.text = `[button] ${token}`;
610
+ // Clear the callbackQuery so the downstream path treats this as a
611
+ // normal text turn (and doesn't re-enter this block).
612
+ msg.callbackQuery = undefined;
613
+ // fall through ↓
614
+ }
615
+ else {
616
+ return;
617
+ }
618
+ }
619
+ else {
620
+ // A callback that matched no pending approval (stale / foreign
621
+ // button) is dropped silently, there is nothing to dispatch.
622
+ return;
623
+ }
449
624
  }
450
625
  // ── Sender ADMITTED — only now pay for media. ──────────────────────
451
626
  // Deferred downloads (msg.resolveMedia) run here, after the access
@@ -573,11 +748,18 @@ export async function runChannelInboundPipeline(ctx, msg) {
573
748
  const rawAccountId = msg.accountId?.trim() || msg.conversationId;
574
749
  const normalizedAccountId = normalizeAccountId(rawAccountId);
575
750
  const peerKind = isGroup ? "group" : "direct";
751
+ // INBOUND conversation resolution (the inverse of the outbound
752
+ // `resolveOutboundTarget`): canonicalise the incoming peer id via the
753
+ // channel's registered `messaging` adapter so a name-addressed inbound
754
+ // collapses onto the SAME conversation/session outbound resolves to. When
755
+ // the channel doesn't opt in, this returns `msg.from` unchanged, so the
756
+ // config-link resolve + routing below are byte-identical to before.
757
+ const inboundPeerId = resolveInboundConversation({ channelId: adapter.id, peerId: msg.from });
576
758
  const canonicalPeerId = resolveLinkedPeerIdFromConfig({
577
759
  config: cfg,
578
760
  channel: adapter.id,
579
- peerId: msg.from,
580
- }) ?? msg.from;
761
+ peerId: inboundPeerId,
762
+ }) ?? inboundPeerId;
581
763
  const route = resolveAgentRoute({
582
764
  cfg,
583
765
  channel: adapter.id,
@@ -633,7 +815,34 @@ export async function runChannelInboundPipeline(ctx, msg) {
633
815
  }
634
816
  return;
635
817
  }
636
- // Immediate dispatch.
818
+ // Plugin hook: `before_dispatch` (CLAIMING). A plugin may claim the turn
819
+ // just before it dispatches to the agent — claim → skip dispatch entirely
820
+ // (no reply). Fired for the immediate path here; the debounced path fires
821
+ // the same hook inside `flushDispatch` before its own dispatch.
822
+ {
823
+ const registry = getActiveRegistry();
824
+ if (registry) {
825
+ const claim = await registry.fireHook("before_dispatch", {
826
+ channel: adapter.id,
827
+ agentId: resolvedAgentId,
828
+ sessionKey,
829
+ text,
830
+ conversationId: msg.conversationId,
831
+ ...(msg.threadId !== undefined ? { threadId: msg.threadId } : {}),
832
+ ...(msg.accountId !== undefined ? { accountId: msg.accountId } : {}),
833
+ });
834
+ if (claim.handled) {
835
+ log.debug("dispatch claimed by plugin hook", {
836
+ channel: adapter.id,
837
+ conversationId: msg.conversationId,
838
+ by: claim.by,
839
+ });
840
+ return;
841
+ }
842
+ }
843
+ }
844
+ // Immediate dispatch. Pass the inbound's id as the reply target so the
845
+ // agent's answer NATIVELY quotes the message it answers.
637
846
  await dispatchTurn(ctx, {
638
847
  text,
639
848
  sessionKey,
@@ -641,6 +850,7 @@ export async function runChannelInboundPipeline(ctx, msg) {
641
850
  conversationId: msg.conversationId,
642
851
  ...(msg.threadId !== undefined ? { threadId: msg.threadId } : {}),
643
852
  ...(msg.accountId !== undefined ? { accountId: msg.accountId } : {}),
853
+ ...(msg.messageId !== undefined ? { replyToId: msg.messageId } : {}),
644
854
  senderIsOwner,
645
855
  channelApprovalRoute,
646
856
  });
@@ -682,6 +892,34 @@ export async function runChannelInboundPipeline(ctx, msg) {
682
892
  /* cosmetic */
683
893
  }
684
894
  }
895
+ // Live-streaming: if the adapter advertises `beginReplyStream` AND it
896
+ // returns a stream (the adapter gates on its own config), open it and
897
+ // feed the gateway's accumulating answer text into it as tokens arrive.
898
+ // The stream is best-effort UX — the FINAL reply below is still sent
899
+ // authoritatively (or skipped when the stream already delivered it).
900
+ let replyStream = null;
901
+ if (typeof c.adapter.beginReplyStream === "function") {
902
+ try {
903
+ replyStream = c.adapter.beginReplyStream(a.conversationId, buildSendOpts(a.threadId, a.accountId, a.replyToId));
904
+ }
905
+ catch {
906
+ replyStream = null;
907
+ }
908
+ }
909
+ // Only forward deltas when a stream is actually open; the sink sanitizes
910
+ // upstream, but a closed/aborted stream must drop late deltas.
911
+ const onReplyDelta = replyStream
912
+ ? (text) => {
913
+ if (controller.signal.aborted)
914
+ return;
915
+ try {
916
+ replyStream?.update(text);
917
+ }
918
+ catch {
919
+ /* stream hiccup never breaks the turn */
920
+ }
921
+ }
922
+ : undefined;
685
923
  let result;
686
924
  try {
687
925
  result = await c.runTurn({
@@ -693,9 +931,11 @@ export async function runChannelInboundPipeline(ctx, msg) {
693
931
  ...(a.channelApprovalRoute !== undefined
694
932
  ? { channelApprovalRoute: a.channelApprovalRoute }
695
933
  : {}),
934
+ ...(onReplyDelta ? { onReplyDelta } : {}),
696
935
  });
697
936
  }
698
937
  catch (err) {
938
+ replyStream?.stop();
699
939
  if (c.inflight.get(ilKey) === controller)
700
940
  c.inflight.delete(ilKey);
701
941
  if (c.adapter.setComposing) {
@@ -718,15 +958,118 @@ export async function runChannelInboundPipeline(ctx, msg) {
718
958
  /* cosmetic */
719
959
  }
720
960
  }
721
- if (controller.signal.aborted)
961
+ if (controller.signal.aborted) {
962
+ replyStream?.stop();
722
963
  return;
964
+ }
723
965
  const reply = sanitizeReplyForChannel(result.reply?.trim() ?? "");
724
966
  if (reply) {
967
+ // Plugin hook: `reply_dispatch` (CLAIMING). A plugin may suppress /
968
+ // take over the outgoing reply — if a handler claims (`{ handled:
969
+ // true }`), it OWNS delivery: Brigade sends nothing of its own AND
970
+ // emits no reasoning trace + records no last-sent message. Consulted
971
+ // FIRST (before reasoning, before any stream finalize / sendText) so a
972
+ // replaced reply never leaks its reasoning or a half-stream. Fires only
973
+ // when a registry is mounted; identical for the streaming +
974
+ // non-streaming paths below.
975
+ const replyRegistry = getActiveRegistry();
976
+ if (replyRegistry) {
977
+ const claim = await replyRegistry.fireHook("reply_dispatch", {
978
+ channel: c.adapter.id,
979
+ agentId: a.agentId,
980
+ conversationId: a.conversationId,
981
+ reply,
982
+ ...(a.threadId !== undefined ? { threadId: a.threadId } : {}),
983
+ ...(a.accountId !== undefined ? { accountId: a.accountId } : {}),
984
+ });
985
+ if (claim.handled) {
986
+ log.debug("reply suppressed by plugin hook", {
987
+ channel: c.adapter.id,
988
+ conversationId: a.conversationId,
989
+ by: claim.by,
990
+ });
991
+ // A claimed reply means the plugin owns delivery — abandon any
992
+ // open stream WITHOUT finalizing so it doesn't leak a placeholder.
993
+ replyStream?.stop();
994
+ return;
995
+ }
996
+ }
997
+ // Reasoning lane (OPTIONAL, default OFF): when the adapter opts in, hand
998
+ // it the RAW reply so it can deliver a `<think>` trace as a separate
999
+ // prefixed message BEFORE the answer. The adapter gates on its own
1000
+ // config; a no-op adapter (or disabled config) sends nothing. Runs for
1001
+ // BOTH streaming + non-streaming paths so reasoning always precedes the
1002
+ // answer. Best-effort — never blocks the answer below.
1003
+ if (typeof c.adapter.deliverReasoning === "function") {
1004
+ try {
1005
+ await c.adapter.deliverReasoning(a.conversationId, result.reply ?? "", buildSendOpts(a.threadId, a.accountId, a.replyToId));
1006
+ }
1007
+ catch (err) {
1008
+ log.warn("deliverReasoning failed (non-fatal)", {
1009
+ channel: c.adapter.id,
1010
+ conversationId: a.conversationId,
1011
+ error: err instanceof Error ? err.message : String(err),
1012
+ });
1013
+ }
1014
+ }
1015
+ // STREAMING path: when a live stream is open, FINALIZE it with the
1016
+ // complete reply (it edits the in-progress message to the full text +
1017
+ // rolls overflow into new messages). This replaces the single
1018
+ // `sendText` below — the stream is now authoritative for delivery. On a
1019
+ // successful finalize the streamed send fires `message_sent` (VOID) just
1020
+ // like the non-streaming path, then returns.
1021
+ if (replyStream) {
1022
+ try {
1023
+ const sent = await replyStream.finalize(reply);
1024
+ const streamedMessageId = sent && typeof sent === "object" ? sent.messageId : undefined;
1025
+ recordLastSentMessage({
1026
+ agentId: a.agentId,
1027
+ channelId: c.adapter.id,
1028
+ conversationId: a.conversationId,
1029
+ messageId: streamedMessageId,
1030
+ ...(a.threadId !== undefined ? { threadId: a.threadId } : {}),
1031
+ ...(a.accountId !== undefined ? { accountId: a.accountId } : {}),
1032
+ });
1033
+ // Plugin hook: `message_sent` (VOID) — telemetry after the streamed
1034
+ // reply lands. Awaited so async handler work flushes; the result is
1035
+ // ignored (void handlers can never alter or block delivery).
1036
+ if (replyRegistry) {
1037
+ await replyRegistry.fireHook("message_sent", {
1038
+ channel: c.adapter.id,
1039
+ agentId: a.agentId,
1040
+ conversationId: a.conversationId,
1041
+ text: reply,
1042
+ messageId: streamedMessageId,
1043
+ ...(a.threadId !== undefined ? { threadId: a.threadId } : {}),
1044
+ ...(a.accountId !== undefined ? { accountId: a.accountId } : {}),
1045
+ });
1046
+ }
1047
+ return;
1048
+ }
1049
+ catch (err) {
1050
+ // Stream finalize failed — fall through to the non-streaming
1051
+ // sendText so the recipient still gets the complete reply.
1052
+ log.warn("reply stream finalize failed; falling back to sendText", {
1053
+ channel: c.adapter.id,
1054
+ conversationId: a.conversationId,
1055
+ error: err instanceof Error ? err.message : String(err),
1056
+ });
1057
+ try {
1058
+ replyStream.stop();
1059
+ }
1060
+ catch {
1061
+ /* best-effort */
1062
+ }
1063
+ }
1064
+ }
1065
+ // NON-STREAMING path (or a stream that failed to finalize above). The
1066
+ // `reply_dispatch` claim was already consulted at the top of this block,
1067
+ // so a suppressed reply never reaches here.
725
1068
  // Capture the sent id (additive `{ messageId }` return) so the agent
726
1069
  // can later reference "my last message" via `message_action` without
727
1070
  // having to track ids itself. Channels that return void simply leave
728
1071
  // the last-sent record unset.
729
- const sent = await c.adapter.sendText(a.conversationId, reply, buildSendOpts(a.threadId, a.accountId));
1072
+ const sent = await c.adapter.sendText(a.conversationId, reply, buildSendOpts(a.threadId, a.accountId, a.replyToId));
730
1073
  recordLastSentMessage({
731
1074
  agentId: a.agentId,
732
1075
  channelId: c.adapter.id,
@@ -735,6 +1078,25 @@ export async function runChannelInboundPipeline(ctx, msg) {
735
1078
  ...(a.threadId !== undefined ? { threadId: a.threadId } : {}),
736
1079
  ...(a.accountId !== undefined ? { accountId: a.accountId } : {}),
737
1080
  });
1081
+ // Plugin hook: `message_sent` (VOID) — fire-and-forget telemetry after a
1082
+ // reply lands. Awaited (so a handler's async work is flushed) but the
1083
+ // result is ignored; void handlers can never alter or block delivery.
1084
+ if (replyRegistry) {
1085
+ await replyRegistry.fireHook("message_sent", {
1086
+ channel: c.adapter.id,
1087
+ agentId: a.agentId,
1088
+ conversationId: a.conversationId,
1089
+ text: reply,
1090
+ messageId: sent && typeof sent === "object" ? sent.messageId : undefined,
1091
+ ...(a.threadId !== undefined ? { threadId: a.threadId } : {}),
1092
+ ...(a.accountId !== undefined ? { accountId: a.accountId } : {}),
1093
+ });
1094
+ }
1095
+ }
1096
+ else {
1097
+ // No reply text but a stream may have been opened (and possibly already
1098
+ // sent a placeholder) — stop it so it doesn't leak.
1099
+ replyStream?.stop();
738
1100
  }
739
1101
  }
740
1102
  // Flush a debounce slot.
@@ -747,6 +1109,33 @@ export async function runChannelInboundPipeline(ctx, msg) {
747
1109
  const conversationId = slot.baseMsg.conversationId;
748
1110
  const threadId = slot.baseMsg.threadId;
749
1111
  const accountId = slot.baseMsg.accountId;
1112
+ // Coalesced turn: quote the FIRST message of the debounce window (the one
1113
+ // that opened the slot) as the reply target.
1114
+ const replyToId = slot.baseMsg.messageId;
1115
+ // Plugin hook: `before_dispatch` (CLAIMING) — debounced path. Same hook
1116
+ // as the immediate path above; a claim here skips the coalesced dispatch.
1117
+ {
1118
+ const registry = getActiveRegistry();
1119
+ if (registry) {
1120
+ const claim = await registry.fireHook("before_dispatch", {
1121
+ channel: c.adapter.id,
1122
+ agentId: slot.agentId,
1123
+ sessionKey: slot.sessionKey,
1124
+ text: combined,
1125
+ conversationId,
1126
+ ...(threadId !== undefined ? { threadId } : {}),
1127
+ ...(accountId !== undefined ? { accountId } : {}),
1128
+ });
1129
+ if (claim.handled) {
1130
+ log.debug("debounced dispatch claimed by plugin hook", {
1131
+ channel: c.adapter.id,
1132
+ conversationId,
1133
+ by: claim.by,
1134
+ });
1135
+ return;
1136
+ }
1137
+ }
1138
+ }
750
1139
  try {
751
1140
  await dispatchTurn(c, {
752
1141
  text: combined,
@@ -755,6 +1144,7 @@ export async function runChannelInboundPipeline(ctx, msg) {
755
1144
  conversationId,
756
1145
  ...(threadId !== undefined ? { threadId } : {}),
757
1146
  ...(accountId !== undefined ? { accountId } : {}),
1147
+ ...(replyToId !== undefined ? { replyToId } : {}),
758
1148
  senderIsOwner: slot.senderIsOwner,
759
1149
  channelApprovalRoute: slot.channelApprovalRoute,
760
1150
  });