@spinabot/brigade 1.1.0 → 1.2.1

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 (139) hide show
  1. package/convex/channels.d.ts +5 -5
  2. package/convex/schema.d.ts +2 -2
  3. package/dist/agents/agent-loop.d.ts.map +1 -1
  4. package/dist/agents/agent-loop.js +27 -4
  5. package/dist/agents/agent-loop.js.map +1 -1
  6. package/dist/agents/channels/approval-callback-codec.d.ts +107 -0
  7. package/dist/agents/channels/approval-callback-codec.d.ts.map +1 -0
  8. package/dist/agents/channels/approval-callback-codec.js +173 -0
  9. package/dist/agents/channels/approval-callback-codec.js.map +1 -0
  10. package/dist/agents/channels/approval-router.d.ts +77 -20
  11. package/dist/agents/channels/approval-router.d.ts.map +1 -1
  12. package/dist/agents/channels/approval-router.js +163 -37
  13. package/dist/agents/channels/approval-router.js.map +1 -1
  14. package/dist/agents/channels/backoff.d.ts +55 -0
  15. package/dist/agents/channels/backoff.d.ts.map +1 -0
  16. package/dist/agents/channels/backoff.js +47 -0
  17. package/dist/agents/channels/backoff.js.map +1 -0
  18. package/dist/agents/channels/channel-secrets.d.ts +45 -0
  19. package/dist/agents/channels/channel-secrets.d.ts.map +1 -0
  20. package/dist/agents/channels/channel-secrets.js +69 -0
  21. package/dist/agents/channels/channel-secrets.js.map +1 -0
  22. package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
  23. package/dist/agents/channels/inbound-pipeline.js +67 -3
  24. package/dist/agents/channels/inbound-pipeline.js.map +1 -1
  25. package/dist/agents/channels/last-sent-message.d.ts +46 -0
  26. package/dist/agents/channels/last-sent-message.d.ts.map +1 -0
  27. package/dist/agents/channels/last-sent-message.js +55 -0
  28. package/dist/agents/channels/last-sent-message.js.map +1 -0
  29. package/dist/agents/channels/manager.d.ts +52 -0
  30. package/dist/agents/channels/manager.d.ts.map +1 -1
  31. package/dist/agents/channels/manager.js +141 -31
  32. package/dist/agents/channels/manager.js.map +1 -1
  33. package/dist/agents/channels/plugin-channel-manager-facade.d.ts +13 -2
  34. package/dist/agents/channels/plugin-channel-manager-facade.d.ts.map +1 -1
  35. package/dist/agents/channels/plugin-channel-manager-facade.js +21 -0
  36. package/dist/agents/channels/plugin-channel-manager-facade.js.map +1 -1
  37. package/dist/agents/channels/sdk.d.ts +426 -0
  38. package/dist/agents/channels/sdk.d.ts.map +1 -0
  39. package/dist/agents/channels/sdk.js +274 -0
  40. package/dist/agents/channels/sdk.js.map +1 -0
  41. package/dist/agents/channels/telegram/account-config.d.ts +92 -0
  42. package/dist/agents/channels/telegram/account-config.d.ts.map +1 -0
  43. package/dist/agents/channels/telegram/account-config.js +192 -0
  44. package/dist/agents/channels/telegram/account-config.js.map +1 -0
  45. package/dist/agents/channels/telegram/adapter.d.ts +79 -0
  46. package/dist/agents/channels/telegram/adapter.d.ts.map +1 -0
  47. package/dist/agents/channels/telegram/adapter.js +475 -0
  48. package/dist/agents/channels/telegram/adapter.js.map +1 -0
  49. package/dist/agents/channels/telegram/allowed-updates.d.ts +44 -0
  50. package/dist/agents/channels/telegram/allowed-updates.d.ts.map +1 -0
  51. package/dist/agents/channels/telegram/allowed-updates.js +52 -0
  52. package/dist/agents/channels/telegram/allowed-updates.js.map +1 -0
  53. package/dist/agents/channels/telegram/approval-authorize.d.ts +41 -0
  54. package/dist/agents/channels/telegram/approval-authorize.d.ts.map +1 -0
  55. package/dist/agents/channels/telegram/approval-authorize.js +69 -0
  56. package/dist/agents/channels/telegram/approval-authorize.js.map +1 -0
  57. package/dist/agents/channels/telegram/approval-native.d.ts +68 -0
  58. package/dist/agents/channels/telegram/approval-native.d.ts.map +1 -0
  59. package/dist/agents/channels/telegram/approval-native.js +94 -0
  60. package/dist/agents/channels/telegram/approval-native.js.map +1 -0
  61. package/dist/agents/channels/telegram/command-menu.d.ts +35 -0
  62. package/dist/agents/channels/telegram/command-menu.d.ts.map +1 -0
  63. package/dist/agents/channels/telegram/command-menu.js +59 -0
  64. package/dist/agents/channels/telegram/command-menu.js.map +1 -0
  65. package/dist/agents/channels/telegram/connection.d.ts +359 -0
  66. package/dist/agents/channels/telegram/connection.d.ts.map +1 -0
  67. package/dist/agents/channels/telegram/connection.js +865 -0
  68. package/dist/agents/channels/telegram/connection.js.map +1 -0
  69. package/dist/agents/channels/telegram/format.d.ts +48 -0
  70. package/dist/agents/channels/telegram/format.d.ts.map +1 -0
  71. package/dist/agents/channels/telegram/format.js +256 -0
  72. package/dist/agents/channels/telegram/format.js.map +1 -0
  73. package/dist/agents/channels/telegram/inbound-extras.d.ts +73 -0
  74. package/dist/agents/channels/telegram/inbound-extras.d.ts.map +1 -0
  75. package/dist/agents/channels/telegram/inbound-extras.js +231 -0
  76. package/dist/agents/channels/telegram/inbound-extras.js.map +1 -0
  77. package/dist/agents/channels/telegram/index.d.ts +14 -0
  78. package/dist/agents/channels/telegram/index.d.ts.map +1 -0
  79. package/dist/agents/channels/telegram/index.js +14 -0
  80. package/dist/agents/channels/telegram/index.js.map +1 -0
  81. package/dist/agents/channels/telegram/media.d.ts +68 -0
  82. package/dist/agents/channels/telegram/media.d.ts.map +1 -0
  83. package/dist/agents/channels/telegram/media.js +143 -0
  84. package/dist/agents/channels/telegram/media.js.map +1 -0
  85. package/dist/agents/channels/telegram/module.d.ts +15 -0
  86. package/dist/agents/channels/telegram/module.d.ts.map +1 -0
  87. package/dist/agents/channels/telegram/module.js +36 -0
  88. package/dist/agents/channels/telegram/module.js.map +1 -0
  89. package/dist/agents/channels/telegram/plugin.d.ts +76 -0
  90. package/dist/agents/channels/telegram/plugin.d.ts.map +1 -0
  91. package/dist/agents/channels/telegram/plugin.js +314 -0
  92. package/dist/agents/channels/telegram/plugin.js.map +1 -0
  93. package/dist/agents/channels/telegram/probe.d.ts +54 -0
  94. package/dist/agents/channels/telegram/probe.d.ts.map +1 -0
  95. package/dist/agents/channels/telegram/probe.js +95 -0
  96. package/dist/agents/channels/telegram/probe.js.map +1 -0
  97. package/dist/agents/channels/telegram/webhook.d.ts +55 -0
  98. package/dist/agents/channels/telegram/webhook.d.ts.map +1 -0
  99. package/dist/agents/channels/telegram/webhook.js +141 -0
  100. package/dist/agents/channels/telegram/webhook.js.map +1 -0
  101. package/dist/agents/extensions/modules/index.d.ts.map +1 -1
  102. package/dist/agents/extensions/modules/index.js +4 -0
  103. package/dist/agents/extensions/modules/index.js.map +1 -1
  104. package/dist/agents/extensions/types.d.ts +72 -2
  105. package/dist/agents/extensions/types.d.ts.map +1 -1
  106. package/dist/agents/extensions/types.js.map +1 -1
  107. package/dist/agents/tools/connect-channel-tool.d.ts +86 -0
  108. package/dist/agents/tools/connect-channel-tool.d.ts.map +1 -0
  109. package/dist/agents/tools/connect-channel-tool.js +398 -0
  110. package/dist/agents/tools/connect-channel-tool.js.map +1 -0
  111. package/dist/agents/tools/message-action-tool.d.ts +67 -0
  112. package/dist/agents/tools/message-action-tool.d.ts.map +1 -0
  113. package/dist/agents/tools/message-action-tool.js +216 -0
  114. package/dist/agents/tools/message-action-tool.js.map +1 -0
  115. package/dist/agents/tools/registry.d.ts.map +1 -1
  116. package/dist/agents/tools/registry.js +19 -0
  117. package/dist/agents/tools/registry.js.map +1 -1
  118. package/dist/buildstamp.json +1 -1
  119. package/dist/cli/commands/channels.d.ts.map +1 -1
  120. package/dist/cli/commands/channels.js +27 -2
  121. package/dist/cli/commands/channels.js.map +1 -1
  122. package/dist/core/server.d.ts.map +1 -1
  123. package/dist/core/server.js +77 -27
  124. package/dist/core/server.js.map +1 -1
  125. package/dist/cron/service/state.d.ts +10 -0
  126. package/dist/cron/service/state.d.ts.map +1 -1
  127. package/dist/cron/service/state.js.map +1 -1
  128. package/dist/cron/service/timer.d.ts.map +1 -1
  129. package/dist/cron/service/timer.js +43 -14
  130. package/dist/cron/service/timer.js.map +1 -1
  131. package/dist/cron/session-reaper.d.ts +27 -0
  132. package/dist/cron/session-reaper.d.ts.map +1 -1
  133. package/dist/cron/session-reaper.js +81 -0
  134. package/dist/cron/session-reaper.js.map +1 -1
  135. package/dist/system-prompt/assembler.d.ts +14 -0
  136. package/dist/system-prompt/assembler.d.ts.map +1 -1
  137. package/dist/system-prompt/assembler.js +36 -14
  138. package/dist/system-prompt/assembler.js.map +1 -1
  139. package/package.json +22 -6
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Approval-callback codec — the wire format for inline-button approvals.
3
+ *
4
+ * Some channels (Telegram, Slack block-kit, Discord components) render an
5
+ * approval prompt as native buttons instead of a "reply yes/no" text card. When
6
+ * the operator taps a button the channel delivers a `callback_query`-style event
7
+ * carrying an opaque payload string the BUTTON declared at send time. This codec
8
+ * is the central, channel-neutral encode/decode for that payload: it packs the
9
+ * pending-approval id + the chosen decision into one short string, and unpacks
10
+ * it back on the way in.
11
+ *
12
+ * THE 64-BYTE BUDGET. Telegram's `callback_data` is capped at **64 bytes** (a
13
+ * hard Bot API limit — a longer value is rejected outright). That is the
14
+ * tightest channel constraint, so this codec treats 64 bytes as the universal
15
+ * ceiling: {@link encodeApprovalCallback} returns `undefined` when the payload
16
+ * would exceed it (the caller then omits / falls back to the text prompt rather
17
+ * than ship an oversized button). The budget is measured in UTF-8 BYTES, not
18
+ * `string.length`, so a multi-byte id can't sneak past the limit.
19
+ *
20
+ * WIRE FORMAT (channel-neutral, no brand tokens, printable ASCII only):
21
+ *
22
+ * <tag>:<base64url(approvalId)>:<decisionCode>
23
+ *
24
+ * - `tag` is the constant {@link APPROVAL_CALLBACK_TAG} = `"bv1"` (a versioned
25
+ * marker — `b`rigade callback `v1` — that lets the decoder reject foreign
26
+ * button payloads fast and lets a future format bump cleanly).
27
+ * - the approval id is base64url-encoded so it can carry the `:`/`-`/`_`
28
+ * characters real ids use without colliding with the field delimiter.
29
+ * - `decisionCode` is a single char: `o` = allow-once, `a` = allow-always,
30
+ * `d` = deny. Single chars (rather than the full `allow-always` words) keep
31
+ * the payload comfortably inside 64 bytes.
32
+ *
33
+ * No NUL / control bytes ever appear: base64url is `[A-Za-z0-9_-]`, the tag is
34
+ * lowercase ASCII, the delimiter is a printable colon, and the decision code is
35
+ * one of three ASCII letters.
36
+ *
37
+ * The decision vocabulary is the channel-approval subset of the bridge's
38
+ * `ApprovalDecisionKind` — exactly the three an operator can choose from a
39
+ * button (`allow-once` / `allow-always` / `deny`); the bridge's other kinds
40
+ * (`allow-pattern`, `allow-session`) are not button-reachable.
41
+ */
42
+ /**
43
+ * Versioned, brand-neutral marker prefixing every approval callback payload.
44
+ * `b`rigade callback `v1`. Bumping the version (`bv2`, …) lets a new wire
45
+ * format coexist with old in-flight buttons.
46
+ */
47
+ export const APPROVAL_CALLBACK_TAG = "bv1";
48
+ /**
49
+ * Telegram's `callback_data` hard limit — the tightest channel constraint, used
50
+ * here as the universal ceiling. Measured in UTF-8 bytes.
51
+ */
52
+ export const APPROVAL_CALLBACK_MAX_BYTES = 64;
53
+ const DELIMITER = ":";
54
+ /** Map a full decision kind to its single-char wire code. */
55
+ function decisionToCode(decision) {
56
+ switch (decision) {
57
+ case "allow-once":
58
+ return "o";
59
+ case "allow-always":
60
+ return "a";
61
+ case "deny":
62
+ return "d";
63
+ }
64
+ }
65
+ /** Map a single-char wire code back to a decision kind (or null if unknown). */
66
+ function codeToDecision(code) {
67
+ switch (code) {
68
+ case "o":
69
+ return "allow-once";
70
+ case "a":
71
+ return "allow-always";
72
+ case "d":
73
+ return "deny";
74
+ default:
75
+ return null;
76
+ }
77
+ }
78
+ /** Base64url-encode (no padding) — `[A-Za-z0-9_-]`, never a NUL/control byte. */
79
+ function toBase64Url(value) {
80
+ return Buffer.from(value, "utf8").toString("base64url");
81
+ }
82
+ /** Base64url-decode back to UTF-8. Returns "" on malformed input. */
83
+ function fromBase64Url(value) {
84
+ try {
85
+ return Buffer.from(value, "base64url").toString("utf8");
86
+ }
87
+ catch {
88
+ return "";
89
+ }
90
+ }
91
+ /** True iff `value` fits the universal callback-data byte budget. */
92
+ export function fitsApprovalCallback(value) {
93
+ return Buffer.byteLength(value, "utf8") <= APPROVAL_CALLBACK_MAX_BYTES;
94
+ }
95
+ /**
96
+ * Build the standard three approval buttons (Allow once / Allow always / Deny)
97
+ * for an approval id, each carrying its codec-encoded payload. A channel's
98
+ * `sendApprovalPrompt` calls this to get a ready-made, byte-safe button spec
99
+ * instead of re-deriving the encoding by hand, then maps each `{ label, data }`
100
+ * onto its own native button type.
101
+ *
102
+ * `allowAlways: false` drops the "Allow always" button (for approvals where
103
+ * persisting an allowlist entry doesn't apply). Any button whose payload would
104
+ * exceed the 64-byte budget is OMITTED (never shipped oversized) — in practice
105
+ * that only happens for pathologically long approval ids, and the caller should
106
+ * fall back to the text prompt when fewer than two buttons come back.
107
+ */
108
+ export function buildApprovalCallbackButtons(args) {
109
+ const specs = [
110
+ { label: "Allow once", decision: "allow-once" },
111
+ ...(args.allowAlways === false
112
+ ? []
113
+ : [{ label: "Allow always", decision: "allow-always" }]),
114
+ { label: "Deny", decision: "deny" },
115
+ ];
116
+ const buttons = [];
117
+ for (const spec of specs) {
118
+ const data = encodeApprovalCallback({ approvalId: args.approvalId, decision: spec.decision });
119
+ if (!data)
120
+ continue; // oversized payload → omit this button
121
+ buttons.push({ label: spec.label, decision: spec.decision, data });
122
+ }
123
+ return buttons;
124
+ }
125
+ /**
126
+ * Encode an approval id + decision into a callback payload string.
127
+ *
128
+ * Returns `undefined` when the result would exceed {@link APPROVAL_CALLBACK_MAX_BYTES}
129
+ * (64 UTF-8 bytes) OR when the approval id is empty — in either case the caller
130
+ * must NOT render that button (fall back to the text prompt). A non-undefined
131
+ * return is always a wire-safe, printable-ASCII string under the limit.
132
+ */
133
+ export function encodeApprovalCallback(args) {
134
+ const id = args.approvalId.trim();
135
+ if (!id)
136
+ return undefined;
137
+ const payload = `${APPROVAL_CALLBACK_TAG}${DELIMITER}${toBase64Url(id)}${DELIMITER}${decisionToCode(args.decision)}`;
138
+ if (!fitsApprovalCallback(payload))
139
+ return undefined;
140
+ return payload;
141
+ }
142
+ /**
143
+ * Decode a callback payload string back into `{ approvalId, decision }`.
144
+ *
145
+ * Returns `null` for anything that isn't a well-formed approval callback — a
146
+ * foreign button payload, a truncated/garbled string, an unknown decision code,
147
+ * or an oversized value. A `null` return means "not an approval callback; let
148
+ * the caller fall through" exactly like the text decoder's `null`.
149
+ */
150
+ export function decodeApprovalCallback(data) {
151
+ if (typeof data !== "string" || data.length === 0)
152
+ return null;
153
+ // Reject oversized payloads up front — anything over the budget could not
154
+ // have been minted by our encoder, so it isn't ours to decode.
155
+ if (!fitsApprovalCallback(data))
156
+ return null;
157
+ const parts = data.split(DELIMITER);
158
+ if (parts.length !== 3)
159
+ return null;
160
+ const [tag, idB64, code] = parts;
161
+ if (tag !== APPROVAL_CALLBACK_TAG)
162
+ return null;
163
+ if (!idB64)
164
+ return null;
165
+ const approvalId = fromBase64Url(idB64).trim();
166
+ if (!approvalId)
167
+ return null;
168
+ const decision = codeToDecision(code ?? "");
169
+ if (decision === null)
170
+ return null;
171
+ return { approvalId, decision };
172
+ }
173
+ //# sourceMappingURL=approval-callback-codec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-callback-codec.js","sourceRoot":"","sources":["../../../src/agents/channels/approval-callback-codec.ts"],"names":[],"mappings":"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"}
@@ -40,7 +40,10 @@
40
40
  * threading a registry through 8 layers of args is the same fight as
41
41
  * the bridge itself (see `approval-bridge.ts` for the same rationale).
42
42
  */
43
+ import type { BrigadeConfig } from "../../config/io.js";
43
44
  import type { ApprovalDecision, ApprovalDecisionKind, ApprovalRequest } from "../approval-bridge.js";
45
+ import type { ChannelApprovalCapability, ChannelApprovalKind } from "./types.adapters.js";
46
+ import type { RuntimeEnv } from "./types.core.js";
44
47
  /** A pending approval the channel router is waiting on a yes/no for. */
45
48
  export interface ChannelApprovalRoute {
46
49
  channelId: string;
@@ -58,13 +61,38 @@ export interface ChannelApprovalRoute {
58
61
  * `startChannels` boot from each adapter's outbound surface.
59
62
  */
60
63
  export interface ChannelApprovalDispatcher {
61
- /** Send the approval prompt to the conversation. */
64
+ /**
65
+ * Send the approval prompt to the conversation. The return is intentionally
66
+ * widened to accept adapters whose `sendText` now yields `{ messageId? }`
67
+ * (the additive outbound-id surface) — the router ignores any returned id;
68
+ * it only needs the send to resolve.
69
+ */
62
70
  sendText: (conversationId: string, text: string, opts?: {
63
71
  threadId?: string;
64
72
  accountId?: string;
65
- }) => Promise<void>;
73
+ }) => Promise<{
74
+ messageId?: string;
75
+ } | void>;
66
76
  /** Human-readable label for log lines + the prompt header (e.g. "WhatsApp"). */
67
77
  prettyName: string;
78
+ /**
79
+ * Optional native-button approval capability. When the registering channel's
80
+ * adapter exposes `approvalCapability.sendApprovalPrompt`, the router renders
81
+ * the approval question via THAT (inline buttons carrying codec-encoded
82
+ * callback payloads) instead of the default text card. Absent → text path.
83
+ * Additive: dispatchers registered with only `{ sendText, prettyName }`
84
+ * (WhatsApp, the manager's default) keep using the text prompt unchanged.
85
+ */
86
+ approvalCapability?: ChannelApprovalCapability;
87
+ /**
88
+ * Supplies the `runtime` + `cfg` a native `sendApprovalPrompt` call needs.
89
+ * Only consulted when `approvalCapability.sendApprovalPrompt` is present, so
90
+ * text-only dispatchers never need it.
91
+ */
92
+ getApprovalContext?: () => {
93
+ runtime: RuntimeEnv;
94
+ cfg: BrigadeConfig;
95
+ };
68
96
  }
69
97
  /**
70
98
  * Register an adapter's outbound surface so the bridge can route prompts
@@ -114,38 +142,67 @@ export declare function dispatchChannelApproval(args: {
114
142
  route: ChannelApprovalRoute;
115
143
  resolveOnBridge: (decision: ApprovalDecision) => void;
116
144
  }): Promise<boolean>;
145
+ export declare function tryConsumeChannelApprovalReply(args: {
146
+ channelId: string;
147
+ conversationId: string;
148
+ text: string;
149
+ threadId?: string;
150
+ accountId?: string;
151
+ agentId?: string;
152
+ }): {
153
+ matched: true;
154
+ decision: ApprovalDecisionKind;
155
+ approvalId: string;
156
+ } | {
157
+ matched: false;
158
+ };
117
159
  /**
118
- * Try to consume `text` as a yes/no reply to a pending approval for this
119
- * peer. Returns:
120
- * - `true` → text WAS a yes/no answer + bridge has been resolved; the
121
- * caller (channel inbound handler) should `return` and NOT
122
- * dispatch a turn for this message.
123
- * - `false` → no pending approval for this peer, OR text wasn't a
124
- * yes/no shape. Caller proceeds with normal dispatch.
160
+ * Try to consume an inline-button press (Telegram `callback_query` etc.) as the
161
+ * answer to a pending approval for this peer. Mirrors `tryConsumeChannelApprovalReply`
162
+ * but decodes the button's codec payload instead of free text, and runs an
163
+ * optional central authorization gate before settling.
125
164
  *
126
- * Notes for the channel inbound:
127
- * - Must be called AFTER the access-policy check (we only intercept
128
- * trusted peers strangers can't accidentally answer an approval).
129
- * - Must be called BEFORE the abort-trigger check (the abort word "stop"
130
- * overlaps the "no" vocabulary; pending-approval intent wins).
131
- * - The channel adapter's `sendText` for the acknowledgement is the
132
- * caller's responsibility the router only does the bridge plumbing.
133
- * This keeps the router test-friendly (no I/O side effects on the
134
- * intercept path) and lets per-channel formatting differ.
165
+ * Returns:
166
+ * - `{ matched: true, decision, approvalId }` — the payload decoded to a
167
+ * pending approval for an AUTHORIZED presser and the bridge was resolved.
168
+ * The caller should acknowledge the press and NOT dispatch a turn.
169
+ * - `{ matched: false, refused: true, reason }` — the payload matched a
170
+ * pending approval but the presser was refused by `authorizeApprover`. The
171
+ * pending entry is left intact (a non-operator press must not consume the
172
+ * operator's approval). The caller should ack with the refusal reason.
173
+ * - `{ matched: false }` not an approval callback for this peer (foreign /
174
+ * malformed payload, or no pending entry). Caller proceeds normally.
175
+ *
176
+ * MUST be called AFTER the channel access gate (only trusted peers reach here)
177
+ * and BEFORE the text-reply path. `authorizeApprover` is the channel's own
178
+ * predicate (from its `ChannelApprovalCapability`); when provided it is invoked
179
+ * centrally so a non-operator's button press is refused here, not in the
180
+ * adapter.
135
181
  */
136
- export declare function tryConsumeChannelApprovalReply(args: {
182
+ export declare function tryConsumeChannelApprovalCallback(args: {
137
183
  channelId: string;
138
184
  conversationId: string;
139
- text: string;
185
+ callbackData: string;
140
186
  threadId?: string;
141
187
  accountId?: string;
142
188
  agentId?: string;
189
+ senderId?: string;
190
+ authorizeApprover?: (params: {
191
+ accountId?: string;
192
+ senderId?: string;
193
+ approvalKind: ChannelApprovalKind;
194
+ }) => {
195
+ authorized: boolean;
196
+ reason?: string;
197
+ };
143
198
  }): {
144
199
  matched: true;
145
200
  decision: ApprovalDecisionKind;
146
201
  approvalId: string;
147
202
  } | {
148
203
  matched: false;
204
+ refused?: true;
205
+ reason?: string;
149
206
  };
150
207
  /**
151
208
  * Cancel a pending approval by request id (e.g. on session abort).
@@ -1 +1 @@
1
- {"version":3,"file":"approval-router.d.ts","sourceRoot":"","sources":["../../../src/agents/channels/approval-router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAIrG,wEAAwE;AACxE,MAAM,WAAW,oBAAoB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kIAAkI;IAClI,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,yBAAyB;IACzC,oDAAoD;IACpD,QAAQ,EAAE,CACT,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAC5C,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;CACnB;AAkED;;;;;;;;GAQG;AACH,wBAAgB,iCAAiC,CAChD,SAAS,EAAE,MAAM,EACjB,qBAAqB,EAAE,MAAM,GAAG,SAAS,GAAG,yBAAyB,EACrE,eAAe,CAAC,EAAE,yBAAyB,GACzC,IAAI,CAYN;AAED;;;;;GAKG;AACH,wBAAgB,+BAA+B,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAoB3F;AAED,6DAA6D;AAC7D,wBAAgB,8BAA8B,IAAI,MAAM,EAAE,CAEzD;AAED,sEAAsE;AACtE,wBAAgB,2BAA2B,IAAI,KAAK,CAAC;IACpD,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACd,CAAC,CAYD;AA2FD;;;;;;;;;;;;GAYG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE;IACnD,OAAO,EAAE,eAAe,CAAC;IACzB,KAAK,EAAE,oBAAoB,CAAC;IAC5B,eAAe,EAAE,CAAC,QAAQ,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACtD,GAAG,OAAO,CAAC,OAAO,CAAC,CAwGnB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,8BAA8B,CAAC,IAAI,EAAE;IACpD,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,oBAAoB,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CA+C7F;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAOlE;AAED,4DAA4D;AAC5D,wBAAgB,kCAAkC,IAAI,IAAI,CAKzD"}
1
+ {"version":3,"file":"approval-router.d.ts","sourceRoot":"","sources":["../../../src/agents/channels/approval-router.ts"],"names":[],"mappings":"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"}
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { createSubsystemLogger } from "../../logging/subsystem-logger.js";
44
44
  import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
45
+ import { decodeApprovalCallback, } from "./approval-callback-codec.js";
45
46
  const log = createSubsystemLogger("brigade/channel-approvals");
46
47
  /** All three approval-router maps are pinned via global-singleton so a hot-reload / dual-build run shares one routing state. */
47
48
  const APPROVAL_ROUTER_DISPATCHERS_KEY = Symbol.for("brigade.approvalRouter.dispatchers");
@@ -181,6 +182,16 @@ function buildPromptText(args) {
181
182
  "Times out in 5 minutes.",
182
183
  ].join("\n");
183
184
  }
185
+ /**
186
+ * Infer the approval KIND for a request so a native prompt can label itself
187
+ * ("approve this shell command" vs "approve this plugin action"). Brigade's
188
+ * exec-gate raises shell-command approvals (the overwhelming common case); a
189
+ * `plugin:`-prefixed id marks a plugin-capability approval. The distinction is
190
+ * cosmetic for the channel render — the bridge resolution is identical.
191
+ */
192
+ function approvalKindForRequest(request) {
193
+ return request.id.startsWith("plugin:") ? "plugin" : "exec";
194
+ }
184
195
  /**
185
196
  * Decode an operator's text reply into a decision kind. Liberal in what
186
197
  * it accepts so the operator can type the obvious shapes without thinking:
@@ -299,7 +310,33 @@ export async function dispatchChannelApproval(args) {
299
310
  sendOpts.threadId = route.threadId;
300
311
  if (route.accountId)
301
312
  sendOpts.accountId = route.accountId;
302
- await dispatcher.sendText(route.conversationId, prompt, Object.keys(sendOpts).length > 0 ? sendOpts : undefined);
313
+ // Native inline-button render when the channel opted in; otherwise the
314
+ // default text prompt. The button payloads carry codec-encoded
315
+ // `{ approvalId, decision }` and the press comes back as an
316
+ // `InboundMessage.callbackQuery` → `tryConsumeChannelApprovalCallback`.
317
+ const native = dispatcher.approvalCapability?.sendApprovalPrompt;
318
+ if (native) {
319
+ const promptCtx = dispatcher.getApprovalContext?.();
320
+ const promptParams = {
321
+ // `runtime`/`cfg` come from the registering channel; fall back to
322
+ // empty shapes so a capability that ignores them still works.
323
+ runtime: promptCtx?.runtime ?? {},
324
+ cfg: promptCtx?.cfg ?? {},
325
+ conversationId: route.conversationId,
326
+ ...(route.accountId !== undefined ? { accountId: route.accountId } : {}),
327
+ ...(route.threadId !== undefined ? { threadId: route.threadId } : {}),
328
+ approvalId: request.id,
329
+ approvalKind: approvalKindForRequest(request),
330
+ command: request.command,
331
+ ...(request.toolName !== undefined ? { toolName: request.toolName } : {}),
332
+ ...(request.cwd !== undefined ? { cwd: request.cwd } : {}),
333
+ timeoutMs: request.timeoutMs,
334
+ };
335
+ await native(promptParams);
336
+ }
337
+ else {
338
+ await dispatcher.sendText(route.conversationId, prompt, Object.keys(sendOpts).length > 0 ? sendOpts : undefined);
339
+ }
303
340
  }
304
341
  catch (err) {
305
342
  // Release the reserved slot so the WS-fallback path can run cleanly.
@@ -345,6 +382,7 @@ export async function dispatchChannelApproval(args) {
345
382
  conversationId: route.conversationId,
346
383
  approvalId: request.id,
347
384
  via: dispatcher.prettyName,
385
+ render: dispatcher.approvalCapability?.sendApprovalPrompt ? "buttons" : "text",
348
386
  });
349
387
  return true;
350
388
  }
@@ -367,13 +405,14 @@ export async function dispatchChannelApproval(args) {
367
405
  * This keeps the router test-friendly (no I/O side effects on the
368
406
  * intercept path) and lets per-channel formatting differ.
369
407
  */
370
- export function tryConsumeChannelApprovalReply(args) {
371
- // First, try the exact-route key (covers thread + account + agent
372
- // disambiguation). If nothing matches AND the caller didn't pin all
373
- // dimensions, scan for any pending entry whose route's pinned dimensions
374
- // agree with the caller that handles the WhatsApp-style flat-DM case
375
- // where the inbound carries no threadId but the approval was raised on
376
- // the same channel+conversation.
408
+ /**
409
+ * Locate the pending approval that a reply / callback from this peer should
410
+ * settle, returning the entry + its map key. Shared by BOTH the text-reply and
411
+ * the inline-button consumers so the peer-disambiguation rules (exact-route key
412
+ * first, then a per-channel+conversation fallback scan for under-pinned
413
+ * inbounds like WhatsApp flat-DMs) are identical on both paths.
414
+ */
415
+ function findPendingEntry(args) {
377
416
  const exactKey = peerKey({
378
417
  channelId: args.channelId,
379
418
  conversationId: args.conversationId,
@@ -381,45 +420,132 @@ export function tryConsumeChannelApprovalReply(args) {
381
420
  ...(args.accountId !== undefined ? { accountId: args.accountId } : {}),
382
421
  ...(args.agentId !== undefined ? { agentId: args.agentId } : {}),
383
422
  });
384
- let entry = pendingByPeer.get(exactKey);
385
- let entryKey = exactKey;
386
- if (!entry) {
387
- // Fall back to a per-channel + per-conversation scan that matches when
388
- // the caller didn't pin every dimension (e.g. WhatsApp inbound has no
389
- // thread/account). Stops on the first agreeing route to preserve the
390
- // "one prompt per peer at a time" invariant.
391
- for (const [k, candidate] of pendingByPeer.entries()) {
392
- const r = candidate.route;
393
- if (r.channelId !== args.channelId || r.conversationId !== args.conversationId)
394
- continue;
395
- if (args.threadId !== undefined && r.threadId !== undefined && r.threadId !== args.threadId)
396
- continue;
397
- if (args.accountId !== undefined && r.accountId !== undefined && r.accountId !== args.accountId)
398
- continue;
399
- if (args.agentId !== undefined && r.agentId !== undefined && r.agentId !== args.agentId)
400
- continue;
401
- entry = candidate;
402
- entryKey = k;
403
- break;
404
- }
423
+ const exact = pendingByPeer.get(exactKey);
424
+ if (exact)
425
+ return { entry: exact, entryKey: exactKey };
426
+ // Fall back to a per-channel + per-conversation scan that matches when the
427
+ // caller didn't pin every dimension (e.g. WhatsApp inbound has no
428
+ // thread/account). Stops on the first agreeing route to preserve the
429
+ // "one prompt per peer at a time" invariant.
430
+ for (const [k, candidate] of pendingByPeer.entries()) {
431
+ const r = candidate.route;
432
+ if (r.channelId !== args.channelId || r.conversationId !== args.conversationId)
433
+ continue;
434
+ if (args.threadId !== undefined && r.threadId !== undefined && r.threadId !== args.threadId)
435
+ continue;
436
+ if (args.accountId !== undefined && r.accountId !== undefined && r.accountId !== args.accountId)
437
+ continue;
438
+ if (args.agentId !== undefined && r.agentId !== undefined && r.agentId !== args.agentId)
439
+ continue;
440
+ return { entry: candidate, entryKey: k };
405
441
  }
406
- if (!entry)
442
+ return undefined;
443
+ }
444
+ /** Evict a resolved entry from both maps + cancel its watchdog, then settle the bridge. */
445
+ function settlePending(entry, entryKey, kind) {
446
+ pendingByPeer.delete(entryKey);
447
+ pendingById.delete(entry.request.id);
448
+ clearTimeout(entry.timer);
449
+ entry.resolveOnBridge({ kind });
450
+ }
451
+ export function tryConsumeChannelApprovalReply(args) {
452
+ const found = findPendingEntry(args);
453
+ if (!found)
407
454
  return { matched: false };
408
455
  const kind = decodeReply(args.text);
409
456
  if (kind === null)
410
457
  return { matched: false };
411
- pendingByPeer.delete(entryKey);
412
- pendingById.delete(entry.request.id);
413
- clearTimeout(entry.timer);
414
- const decision = { kind };
415
- entry.resolveOnBridge(decision);
458
+ settlePending(found.entry, found.entryKey, kind);
416
459
  log.info("approval resolved via channel reply", {
417
460
  channelId: args.channelId,
418
461
  conversationId: args.conversationId,
419
- approvalId: entry.request.id,
462
+ approvalId: found.entry.request.id,
420
463
  decision: kind,
421
464
  });
422
- return { matched: true, decision: kind, approvalId: entry.request.id };
465
+ return { matched: true, decision: kind, approvalId: found.entry.request.id };
466
+ }
467
+ /**
468
+ * Try to consume an inline-button press (Telegram `callback_query` etc.) as the
469
+ * answer to a pending approval for this peer. Mirrors `tryConsumeChannelApprovalReply`
470
+ * but decodes the button's codec payload instead of free text, and runs an
471
+ * optional central authorization gate before settling.
472
+ *
473
+ * Returns:
474
+ * - `{ matched: true, decision, approvalId }` — the payload decoded to a
475
+ * pending approval for an AUTHORIZED presser and the bridge was resolved.
476
+ * The caller should acknowledge the press and NOT dispatch a turn.
477
+ * - `{ matched: false, refused: true, reason }` — the payload matched a
478
+ * pending approval but the presser was refused by `authorizeApprover`. The
479
+ * pending entry is left intact (a non-operator press must not consume the
480
+ * operator's approval). The caller should ack with the refusal reason.
481
+ * - `{ matched: false }` — not an approval callback for this peer (foreign /
482
+ * malformed payload, or no pending entry). Caller proceeds normally.
483
+ *
484
+ * MUST be called AFTER the channel access gate (only trusted peers reach here)
485
+ * and BEFORE the text-reply path. `authorizeApprover` is the channel's own
486
+ * predicate (from its `ChannelApprovalCapability`); when provided it is invoked
487
+ * centrally so a non-operator's button press is refused here, not in the
488
+ * adapter.
489
+ */
490
+ export function tryConsumeChannelApprovalCallback(args) {
491
+ // Decode FIRST — a foreign / malformed button payload is simply "not ours".
492
+ const decoded = decodeApprovalCallback(args.callbackData);
493
+ if (!decoded)
494
+ return { matched: false };
495
+ const found = findPendingEntry(args);
496
+ if (!found)
497
+ return { matched: false };
498
+ // The decoded payload must reference the SAME pending approval we matched by
499
+ // peer — otherwise a stale button from a previous (already-resolved-and-
500
+ // replaced) prompt could settle the wrong approval.
501
+ if (found.entry.request.id !== decoded.approvalId)
502
+ return { matched: false };
503
+ // Central approver authorization: refuse a non-operator press BEFORE
504
+ // settling. Leave the pending entry intact so the real operator can still
505
+ // answer.
506
+ if (args.authorizeApprover) {
507
+ const verdict = args.authorizeApprover({
508
+ ...(args.accountId !== undefined ? { accountId: args.accountId } : {}),
509
+ ...(args.senderId !== undefined ? { senderId: args.senderId } : {}),
510
+ approvalKind: approvalKindForRequest(found.entry.request),
511
+ });
512
+ if (!verdict.authorized) {
513
+ log.warn("approval callback refused — presser not authorized", {
514
+ channelId: args.channelId,
515
+ conversationId: args.conversationId,
516
+ approvalId: found.entry.request.id,
517
+ senderId: args.senderId,
518
+ });
519
+ return {
520
+ matched: false,
521
+ refused: true,
522
+ ...(verdict.reason !== undefined ? { reason: verdict.reason } : {}),
523
+ };
524
+ }
525
+ }
526
+ const kind = callbackDecisionToBridgeKind(decoded.decision);
527
+ settlePending(found.entry, found.entryKey, kind);
528
+ log.info("approval resolved via channel callback", {
529
+ channelId: args.channelId,
530
+ conversationId: args.conversationId,
531
+ approvalId: found.entry.request.id,
532
+ decision: kind,
533
+ });
534
+ return { matched: true, decision: kind, approvalId: found.entry.request.id };
535
+ }
536
+ /** Map a codec decision onto the bridge's decision-kind vocabulary. */
537
+ function callbackDecisionToBridgeKind(decision) {
538
+ // The codec's three values are a strict subset of ApprovalDecisionKind, so
539
+ // this is a widening pass — kept explicit so a future codec value can't
540
+ // silently leak an unhandled kind onto the bridge.
541
+ switch (decision) {
542
+ case "allow-once":
543
+ return "allow-once";
544
+ case "allow-always":
545
+ return "allow-always";
546
+ case "deny":
547
+ return "deny";
548
+ }
423
549
  }
424
550
  /**
425
551
  * Cancel a pending approval by request id (e.g. on session abort).