@spinabot/brigade 1.3.1 → 1.4.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.
- package/README.md +54 -10
- package/convex/config.d.ts +2 -2
- package/convex/extensions.d.ts +2 -2
- package/convex/logs.d.ts +2 -2
- package/convex/memory.d.ts +7 -7
- package/convex/messages.d.ts +4 -4
- package/convex/schema.d.ts +17 -17
- package/convex/subagents.d.ts +12 -12
- package/dist/agents/agent-loop.d.ts +1 -0
- package/dist/agents/agent-loop.d.ts.map +1 -1
- package/dist/agents/agent-loop.js +1 -1
- package/dist/agents/agent-loop.js.map +1 -1
- package/dist/agents/channels/access-control/format-allow-from.d.ts +50 -0
- package/dist/agents/channels/access-control/format-allow-from.d.ts.map +1 -0
- package/dist/agents/channels/access-control/format-allow-from.js +64 -0
- package/dist/agents/channels/access-control/format-allow-from.js.map +1 -0
- package/dist/agents/channels/access-control/index.d.ts +2 -1
- package/dist/agents/channels/access-control/index.d.ts.map +1 -1
- package/dist/agents/channels/access-control/index.js +2 -1
- package/dist/agents/channels/access-control/index.js.map +1 -1
- package/dist/agents/channels/access-control/store.d.ts +15 -0
- package/dist/agents/channels/access-control/store.d.ts.map +1 -1
- package/dist/agents/channels/access-control/store.js +44 -1
- package/dist/agents/channels/access-control/store.js.map +1 -1
- package/dist/agents/channels/bundled-channel-metas.d.ts +26 -0
- package/dist/agents/channels/bundled-channel-metas.d.ts.map +1 -0
- package/dist/agents/channels/bundled-channel-metas.js +44 -0
- package/dist/agents/channels/bundled-channel-metas.js.map +1 -0
- package/dist/agents/channels/channel-messaging-registry.d.ts +130 -0
- package/dist/agents/channels/channel-messaging-registry.d.ts.map +1 -0
- package/dist/agents/channels/channel-messaging-registry.js +211 -0
- package/dist/agents/channels/channel-messaging-registry.js.map +1 -0
- package/dist/agents/channels/channel-meta-registry.d.ts +60 -0
- package/dist/agents/channels/channel-meta-registry.d.ts.map +1 -0
- package/dist/agents/channels/channel-meta-registry.js +128 -0
- package/dist/agents/channels/channel-meta-registry.js.map +1 -0
- package/dist/agents/channels/channel-security-registry.d.ts +138 -0
- package/dist/agents/channels/channel-security-registry.d.ts.map +1 -0
- package/dist/agents/channels/channel-security-registry.js +265 -0
- package/dist/agents/channels/channel-security-registry.js.map +1 -0
- package/dist/agents/channels/exposure.d.ts +44 -0
- package/dist/agents/channels/exposure.d.ts.map +1 -0
- package/dist/agents/channels/exposure.js +48 -0
- package/dist/agents/channels/exposure.js.map +1 -0
- package/dist/agents/channels/general-callback.d.ts +25 -0
- package/dist/agents/channels/general-callback.d.ts.map +1 -0
- package/dist/agents/channels/general-callback.js +31 -0
- package/dist/agents/channels/general-callback.js.map +1 -0
- package/dist/agents/channels/inbound-pipeline.d.ts +9 -0
- package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
- package/dist/agents/channels/inbound-pipeline.js +429 -39
- package/dist/agents/channels/inbound-pipeline.js.map +1 -1
- package/dist/agents/channels/markdown-capability.d.ts +44 -0
- package/dist/agents/channels/markdown-capability.d.ts.map +1 -0
- package/dist/agents/channels/markdown-capability.js +66 -0
- package/dist/agents/channels/markdown-capability.js.map +1 -0
- package/dist/agents/channels/sdk.d.ts +170 -10
- package/dist/agents/channels/sdk.d.ts.map +1 -1
- package/dist/agents/channels/sdk.js +138 -6
- package/dist/agents/channels/sdk.js.map +1 -1
- package/dist/agents/channels/telegram/account-config.d.ts +41 -0
- package/dist/agents/channels/telegram/account-config.d.ts.map +1 -1
- package/dist/agents/channels/telegram/account-config.js +79 -0
- package/dist/agents/channels/telegram/account-config.js.map +1 -1
- package/dist/agents/channels/telegram/adapter.d.ts +6 -0
- package/dist/agents/channels/telegram/adapter.d.ts.map +1 -1
- package/dist/agents/channels/telegram/adapter.js +178 -6
- package/dist/agents/channels/telegram/adapter.js.map +1 -1
- package/dist/agents/channels/telegram/allowed-updates.d.ts +14 -5
- package/dist/agents/channels/telegram/allowed-updates.d.ts.map +1 -1
- package/dist/agents/channels/telegram/allowed-updates.js +8 -4
- package/dist/agents/channels/telegram/allowed-updates.js.map +1 -1
- package/dist/agents/channels/telegram/connection.d.ts +108 -1
- package/dist/agents/channels/telegram/connection.d.ts.map +1 -1
- package/dist/agents/channels/telegram/connection.js +219 -3
- package/dist/agents/channels/telegram/connection.js.map +1 -1
- package/dist/agents/channels/telegram/draft-stream.d.ts +98 -0
- package/dist/agents/channels/telegram/draft-stream.d.ts.map +1 -0
- package/dist/agents/channels/telegram/draft-stream.js +222 -0
- package/dist/agents/channels/telegram/draft-stream.js.map +1 -0
- package/dist/agents/channels/telegram/inbound-extras.d.ts +10 -1
- package/dist/agents/channels/telegram/inbound-extras.d.ts.map +1 -1
- package/dist/agents/channels/telegram/inbound-extras.js +66 -0
- package/dist/agents/channels/telegram/inbound-extras.js.map +1 -1
- package/dist/agents/channels/telegram/inline-keyboard.d.ts +36 -0
- package/dist/agents/channels/telegram/inline-keyboard.d.ts.map +1 -0
- package/dist/agents/channels/telegram/inline-keyboard.js +62 -0
- package/dist/agents/channels/telegram/inline-keyboard.js.map +1 -0
- package/dist/agents/channels/telegram/plugin.d.ts.map +1 -1
- package/dist/agents/channels/telegram/plugin.js +7 -11
- package/dist/agents/channels/telegram/plugin.js.map +1 -1
- package/dist/agents/channels/telegram/reasoning-lane.d.ts +41 -0
- package/dist/agents/channels/telegram/reasoning-lane.d.ts.map +1 -0
- package/dist/agents/channels/telegram/reasoning-lane.js +67 -0
- package/dist/agents/channels/telegram/reasoning-lane.js.map +1 -0
- package/dist/agents/channels/telegram/socks-dispatcher.d.ts +32 -0
- package/dist/agents/channels/telegram/socks-dispatcher.d.ts.map +1 -0
- package/dist/agents/channels/telegram/socks-dispatcher.js +97 -0
- package/dist/agents/channels/telegram/socks-dispatcher.js.map +1 -0
- package/dist/agents/channels/types.adapters.d.ts +137 -4
- package/dist/agents/channels/types.adapters.d.ts.map +1 -1
- package/dist/agents/channels/types.adapters.js +2 -2
- package/dist/agents/channels/types.core.d.ts +26 -2
- package/dist/agents/channels/types.core.d.ts.map +1 -1
- package/dist/agents/channels/types.plugin.d.ts +25 -7
- package/dist/agents/channels/types.plugin.d.ts.map +1 -1
- package/dist/agents/channels/types.plugin.js +6 -5
- package/dist/agents/channels/types.plugin.js.map +1 -1
- package/dist/agents/channels/whatsapp/adapter.d.ts +8 -0
- package/dist/agents/channels/whatsapp/adapter.d.ts.map +1 -1
- package/dist/agents/channels/whatsapp/adapter.js +7 -4
- package/dist/agents/channels/whatsapp/adapter.js.map +1 -1
- package/dist/agents/channels/whatsapp/connection.d.ts +24 -2
- package/dist/agents/channels/whatsapp/connection.d.ts.map +1 -1
- package/dist/agents/channels/whatsapp/connection.js +26 -5
- package/dist/agents/channels/whatsapp/connection.js.map +1 -1
- package/dist/agents/channels/whatsapp/plugin.d.ts.map +1 -1
- package/dist/agents/channels/whatsapp/plugin.js +6 -10
- package/dist/agents/channels/whatsapp/plugin.js.map +1 -1
- package/dist/agents/extensions/activation-planner.d.ts +125 -0
- package/dist/agents/extensions/activation-planner.d.ts.map +1 -0
- package/dist/agents/extensions/activation-planner.js +221 -0
- package/dist/agents/extensions/activation-planner.js.map +1 -0
- package/dist/agents/extensions/diagnose.d.ts +84 -0
- package/dist/agents/extensions/diagnose.d.ts.map +1 -0
- package/dist/agents/extensions/diagnose.js +123 -0
- package/dist/agents/extensions/diagnose.js.map +1 -0
- package/dist/agents/extensions/discovery.d.ts +85 -7
- package/dist/agents/extensions/discovery.d.ts.map +1 -1
- package/dist/agents/extensions/discovery.js +200 -15
- package/dist/agents/extensions/discovery.js.map +1 -1
- package/dist/agents/extensions/index.d.ts +3 -2
- package/dist/agents/extensions/index.d.ts.map +1 -1
- package/dist/agents/extensions/index.js +3 -2
- package/dist/agents/extensions/index.js.map +1 -1
- package/dist/agents/extensions/install-scan.d.ts +63 -0
- package/dist/agents/extensions/install-scan.d.ts.map +1 -0
- package/dist/agents/extensions/install-scan.js +201 -0
- package/dist/agents/extensions/install-scan.js.map +1 -0
- package/dist/agents/extensions/install.d.ts +135 -0
- package/dist/agents/extensions/install.d.ts.map +1 -0
- package/dist/agents/extensions/install.js +414 -0
- package/dist/agents/extensions/install.js.map +1 -0
- package/dist/agents/extensions/loader.d.ts +13 -2
- package/dist/agents/extensions/loader.d.ts.map +1 -1
- package/dist/agents/extensions/loader.js +126 -13
- package/dist/agents/extensions/loader.js.map +1 -1
- package/dist/agents/extensions/registry.d.ts +109 -0
- package/dist/agents/extensions/registry.d.ts.map +1 -1
- package/dist/agents/extensions/registry.js +172 -0
- package/dist/agents/extensions/registry.js.map +1 -1
- package/dist/agents/extensions/sdk-alias.d.ts +45 -0
- package/dist/agents/extensions/sdk-alias.d.ts.map +1 -0
- package/dist/agents/extensions/sdk-alias.js +94 -0
- package/dist/agents/extensions/sdk-alias.js.map +1 -0
- package/dist/agents/extensions/types.d.ts +155 -1
- package/dist/agents/extensions/types.d.ts.map +1 -1
- package/dist/agents/extensions/types.js.map +1 -1
- package/dist/agents/tools/composio-tool.d.ts +9 -1
- package/dist/agents/tools/composio-tool.d.ts.map +1 -1
- package/dist/agents/tools/composio-tool.js +68 -4
- package/dist/agents/tools/composio-tool.js.map +1 -1
- package/dist/agents/tools/message-action-tool.d.ts +6 -1
- package/dist/agents/tools/message-action-tool.d.ts.map +1 -1
- package/dist/agents/tools/message-action-tool.js +52 -2
- package/dist/agents/tools/message-action-tool.js.map +1 -1
- package/dist/agents/tools/send-message-tool.d.ts +1 -0
- package/dist/agents/tools/send-message-tool.d.ts.map +1 -1
- package/dist/agents/tools/send-message-tool.js +56 -1
- package/dist/agents/tools/send-message-tool.js.map +1 -1
- package/dist/buildstamp.json +1 -1
- package/dist/channel-sdk.d.ts +28 -0
- package/dist/channel-sdk.d.ts.map +1 -0
- package/dist/channel-sdk.js +28 -0
- package/dist/channel-sdk.js.map +1 -0
- package/dist/cli/commands/channels.d.ts.map +1 -1
- package/dist/cli/commands/channels.js +8 -8
- package/dist/cli/commands/channels.js.map +1 -1
- package/dist/cli/commands/connect.d.ts +8 -11
- package/dist/cli/commands/connect.d.ts.map +1 -1
- package/dist/cli/commands/connect.js +157 -17
- package/dist/cli/commands/connect.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +64 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/extensions.d.ts +46 -0
- package/dist/cli/commands/extensions.d.ts.map +1 -0
- package/dist/cli/commands/extensions.js +578 -0
- package/dist/cli/commands/extensions.js.map +1 -0
- package/dist/cli/commands/gateway.d.ts.map +1 -1
- package/dist/cli/commands/gateway.js +6 -0
- package/dist/cli/commands/gateway.js.map +1 -1
- package/dist/cli/commands/pairing.d.ts.map +1 -1
- package/dist/cli/commands/pairing.js +16 -2
- package/dist/cli/commands/pairing.js.map +1 -1
- package/dist/cli/commands/update.d.ts +17 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +104 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/program/build-program.d.ts.map +1 -1
- package/dist/cli/program/build-program.js +113 -0
- package/dist/cli/program/build-program.js.map +1 -1
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +9 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/core/gateway-probe.d.ts.map +1 -1
- package/dist/core/gateway-probe.js +9 -1
- package/dist/core/gateway-probe.js.map +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +134 -2
- package/dist/core/server.js.map +1 -1
- package/dist/protocol.d.ts +25 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/system-prompt/assembler.d.ts.map +1 -1
- package/dist/system-prompt/assembler.js +17 -0
- package/dist/system-prompt/assembler.js.map +1 -1
- package/dist/system-prompt/identity-defaults.d.ts +28 -0
- package/dist/system-prompt/identity-defaults.d.ts.map +1 -1
- package/dist/system-prompt/identity-defaults.js +47 -0
- package/dist/system-prompt/identity-defaults.js.map +1 -1
- package/dist/ui/editor.d.ts.map +1 -1
- package/dist/ui/editor.js +1 -0
- package/dist/ui/editor.js.map +1 -1
- package/dist/version.d.ts +4 -3
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +27 -5
- package/dist/version.js.map +1 -1
- package/package.json +21 -4
- package/scripts/build-done.mjs +11 -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
|
-
"
|
|
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
|
|
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 (
|
|
164
|
-
return
|
|
165
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
/**
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
}),
|
|
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
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
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:
|
|
580
|
-
}) ??
|
|
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
|
-
//
|
|
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
|
});
|