@vellumai/assistant 0.12.2-dev.202609181913.1108ac3 → 0.12.2-dev.202609182015.188de8a
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/ARCHITECTURE.md +1 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
- package/package.json +1 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +1 -1
- package/src/__tests__/notification-telegram-adapter.test.ts +2 -2
- package/src/cli/commands/__tests__/notifications.test.ts +10 -10
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/channel-reply-ipc-routes.test.ts +111 -0
- package/src/ipc/routes/channel-reply-ipc-routes.ts +73 -0
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +30 -1
- package/src/messaging/providers/telegram-bot/send.ts +10 -7
- package/src/notifications/README.md +3 -1
- package/src/notifications/__tests__/broadcaster.test.ts +1 -1
- package/src/notifications/__tests__/copy-composer.test.ts +1 -1
- package/src/notifications/__tests__/decision-engine.test.ts +4 -2
- package/src/notifications/__tests__/deterministic-checks.test.ts +10 -10
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +6 -4
- package/src/notifications/copy-composer.ts +0 -23
- package/src/notifications/deterministic-checks.ts +1 -1
- package/src/notifications/home-feed-side-effect.ts +4 -6
- package/src/notifications/signal.ts +3 -18
- package/src/runtime/AGENTS.md +2 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -300,7 +300,7 @@ The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsAp
|
|
|
300
300
|
|
|
301
301
|
**Egress** (daemon WhatsApp transport, `src/messaging/providers/whatsapp/`):
|
|
302
302
|
|
|
303
|
-
1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The
|
|
303
|
+
1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The gateway's own replies to invite and verification codes it intercepts at ingress reach this transport the same way: `deliverVerificationReply` (`gateway/src/verification/reply-delivery.ts`) hands the reply and the inbound message's callback URL to the daemon's IPC-only `deliver_gateway_reply` method (`src/ipc/routes/channel-reply-ipc-routes.ts`), which resolves the transport from that URL.
|
|
304
304
|
2. `api.ts` reads `phone_number_id` and `access_token` from the secure store and sends through the Cloud API `/{phoneNumberId}/messages` endpoint.
|
|
305
305
|
3. `send.ts` splits text into 4096-character chunks, preferring newline and then whitespace boundaries.
|
|
306
306
|
4. An approval prompt renders as an interactive message with up to three reply buttons whose ids follow the shared `apr:<requestId>:<action>` convention. When the cap forces a cut, the reject and block actions are kept. The gateway normalizes the guardian's `button_reply` back into callback data on ingress.
|
|
@@ -24,13 +24,15 @@ export {
|
|
|
24
24
|
PersistentIpcClient,
|
|
25
25
|
} from "./ipc-client.js";
|
|
26
26
|
|
|
27
|
-
// Outbound delivery contract
|
|
27
|
+
// Outbound delivery contract: Zod schemas + derived types
|
|
28
28
|
export {
|
|
29
29
|
ApprovalActionOptionSchema,
|
|
30
30
|
ApprovalUIMetadataSchema,
|
|
31
31
|
AttachmentMetadataSchema,
|
|
32
32
|
ChannelDeliveryResultSchema,
|
|
33
33
|
ChannelReplyPayloadSchema,
|
|
34
|
+
DELIVER_GATEWAY_REPLY_IPC_METHOD,
|
|
35
|
+
GatewayReplyRequestSchema,
|
|
34
36
|
MessageAudienceSchema,
|
|
35
37
|
PermissionRequestDetailsSchema,
|
|
36
38
|
StreamOpSchema,
|
|
@@ -45,6 +47,7 @@ export type {
|
|
|
45
47
|
AttachmentMetadata,
|
|
46
48
|
ChannelDeliveryResult,
|
|
47
49
|
ChannelReplyPayload,
|
|
50
|
+
GatewayReplyRequest,
|
|
48
51
|
MessageAudience,
|
|
49
52
|
PermissionRequestDetails,
|
|
50
53
|
StreamOp,
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Outbound channel delivery contract.
|
|
3
3
|
*
|
|
4
|
-
* Zod schemas
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Zod schemas for a reply to a channel chat. The daemon constructs these
|
|
5
|
+
* payloads in `deliverChannelReply()` and `deliverApprovalPrompt()` and hands
|
|
6
|
+
* them to the channel transport its callback URL names
|
|
7
|
+
* (`messaging/providers`). `/deliver/{channel}` is only that callback URL's
|
|
8
|
+
* addressing form: no service serves it. A callback URL no
|
|
9
|
+
* transport owns (a managed callback carrying a `callback_token`) is POSTed
|
|
10
|
+
* over HTTP by `http-delivery.ts` instead.
|
|
7
11
|
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* The gateway sends through the same transports, never to a provider itself:
|
|
13
|
+
* a reply it composes for a message it answered at ingress goes to the daemon
|
|
14
|
+
* as a {@link GatewayReplyRequest}.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { KnownBlock } from "@slack/types";
|
|
@@ -279,6 +283,33 @@ export const ChannelReplyPayloadSchema = z.object({
|
|
|
279
283
|
|
|
280
284
|
export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
|
|
281
285
|
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Gateway reply: gateway to daemon
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Daemon IPC method that delivers a {@link GatewayReplyRequest}. IPC-only:
|
|
292
|
+
* it has no HTTP route.
|
|
293
|
+
*/
|
|
294
|
+
export const DELIVER_GATEWAY_REPLY_IPC_METHOD = "deliver_gateway_reply";
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A text reply the gateway composed for an inbound message it answered
|
|
298
|
+
* itself (a verification code, an invite redemption), which the daemon
|
|
299
|
+
* delivers through the channel transport `callbackUrl` names. The callback
|
|
300
|
+
* URL is the one the inbound message carried, so the reply lands in the chat
|
|
301
|
+
* and thread the person wrote from.
|
|
302
|
+
*/
|
|
303
|
+
export const GatewayReplyRequestSchema = ChannelReplyPayloadSchema.pick({
|
|
304
|
+
chatId: true,
|
|
305
|
+
assistantId: true,
|
|
306
|
+
}).extend({
|
|
307
|
+
callbackUrl: z.string().min(1),
|
|
308
|
+
text: z.string().min(1),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
export type GatewayReplyRequest = z.infer<typeof GatewayReplyRequestSchema>;
|
|
312
|
+
|
|
282
313
|
// ---------------------------------------------------------------------------
|
|
283
314
|
// Channel delivery result — gateway response
|
|
284
315
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -662,7 +662,7 @@ describe("high/critical urgency channel force", () => {
|
|
|
662
662
|
sourceChannel: "watcher" | "assistant_tool",
|
|
663
663
|
) {
|
|
664
664
|
return emitNotificationSignal({
|
|
665
|
-
sourceEventName: "watcher.
|
|
665
|
+
sourceEventName: "watcher.notification",
|
|
666
666
|
sourceChannel,
|
|
667
667
|
sourceContextId: "watch-1",
|
|
668
668
|
routingIntent: "single_channel",
|
|
@@ -183,7 +183,7 @@ describe("TelegramAdapter", () => {
|
|
|
183
183
|
|
|
184
184
|
await adapter.send(
|
|
185
185
|
makePayload({
|
|
186
|
-
sourceEventName: "watcher.
|
|
186
|
+
sourceEventName: "watcher.notification",
|
|
187
187
|
copy: {
|
|
188
188
|
title: " ",
|
|
189
189
|
body: "",
|
|
@@ -191,7 +191,7 @@ describe("TelegramAdapter", () => {
|
|
|
191
191
|
}),
|
|
192
192
|
makeDestination(),
|
|
193
193
|
);
|
|
194
|
-
expect(sendCalls[2]?.text).toBe("watcher
|
|
194
|
+
expect(sendCalls[2]?.text).toBe("watcher notification");
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
// ── Access request inline keyboard tests ──────────────────────────────
|
|
@@ -175,7 +175,7 @@ describe("notifications send", () => {
|
|
|
175
175
|
"--source-channel",
|
|
176
176
|
"assistant_tool",
|
|
177
177
|
"--source-event-name",
|
|
178
|
-
"
|
|
178
|
+
"schedule.notify",
|
|
179
179
|
"--message",
|
|
180
180
|
"Hello",
|
|
181
181
|
]);
|
|
@@ -186,7 +186,7 @@ describe("notifications send", () => {
|
|
|
186
186
|
|
|
187
187
|
const body = lastSendBody();
|
|
188
188
|
expect(body.sourceChannel).toBe("assistant_tool");
|
|
189
|
-
expect(body.sourceEventName).toBe("
|
|
189
|
+
expect(body.sourceEventName).toBe("schedule.notify");
|
|
190
190
|
const payload = body.contextPayload as Record<string, unknown>;
|
|
191
191
|
expect(payload.requestedMessage).toBe("Hello");
|
|
192
192
|
});
|
|
@@ -221,7 +221,7 @@ describe("notifications send", () => {
|
|
|
221
221
|
"--source-channel",
|
|
222
222
|
"assistant_tool",
|
|
223
223
|
"--source-event-name",
|
|
224
|
-
"
|
|
224
|
+
"schedule.notify",
|
|
225
225
|
"--message",
|
|
226
226
|
"Hello",
|
|
227
227
|
"--preferred-channels",
|
|
@@ -241,7 +241,7 @@ describe("notifications send", () => {
|
|
|
241
241
|
"--source-channel",
|
|
242
242
|
"assistant_tool",
|
|
243
243
|
"--source-event-name",
|
|
244
|
-
"
|
|
244
|
+
"schedule.notify",
|
|
245
245
|
"--message",
|
|
246
246
|
"Hello",
|
|
247
247
|
"--urgency",
|
|
@@ -265,7 +265,7 @@ describe("notifications send", () => {
|
|
|
265
265
|
"--source-channel",
|
|
266
266
|
"water_reminder",
|
|
267
267
|
"--source-event-name",
|
|
268
|
-
"
|
|
268
|
+
"schedule.notify",
|
|
269
269
|
"--message",
|
|
270
270
|
"Hello",
|
|
271
271
|
]);
|
|
@@ -300,7 +300,7 @@ describe("notifications send", () => {
|
|
|
300
300
|
"--source-channel",
|
|
301
301
|
"assistant_tool",
|
|
302
302
|
"--source-event-name",
|
|
303
|
-
"
|
|
303
|
+
"schedule.notify",
|
|
304
304
|
"--message",
|
|
305
305
|
"Hi",
|
|
306
306
|
"--conversation-id",
|
|
@@ -320,7 +320,7 @@ describe("notifications send", () => {
|
|
|
320
320
|
"--source-channel",
|
|
321
321
|
"assistant_tool",
|
|
322
322
|
"--source-event-name",
|
|
323
|
-
"
|
|
323
|
+
"schedule.notify",
|
|
324
324
|
"--message",
|
|
325
325
|
"Hi",
|
|
326
326
|
]);
|
|
@@ -335,7 +335,7 @@ describe("notifications send", () => {
|
|
|
335
335
|
"--source-channel",
|
|
336
336
|
"assistant_tool",
|
|
337
337
|
"--source-event-name",
|
|
338
|
-
"
|
|
338
|
+
"schedule.notify",
|
|
339
339
|
"--message",
|
|
340
340
|
"Hi",
|
|
341
341
|
"--conversation-id",
|
|
@@ -359,7 +359,7 @@ describe("notifications send", () => {
|
|
|
359
359
|
"--source-channel",
|
|
360
360
|
"assistant_tool",
|
|
361
361
|
"--source-event-name",
|
|
362
|
-
"
|
|
362
|
+
"schedule.notify",
|
|
363
363
|
"--message",
|
|
364
364
|
"Hello",
|
|
365
365
|
]);
|
|
@@ -384,7 +384,7 @@ describe("notifications send", () => {
|
|
|
384
384
|
"--source-channel",
|
|
385
385
|
"assistant_tool",
|
|
386
386
|
"--source-event-name",
|
|
387
|
-
"
|
|
387
|
+
"schedule.notify",
|
|
388
388
|
"--message",
|
|
389
389
|
"Hello",
|
|
390
390
|
]);
|
|
@@ -59,6 +59,7 @@ import { RouteResponse } from "../runtime/routes/types.js";
|
|
|
59
59
|
import { getLogger } from "../util/logger.js";
|
|
60
60
|
import { mapGatewayIpcConnectError } from "./gateway-ipc-errors.js";
|
|
61
61
|
import { ACTIVATION_SYNC_IPC_METHODS } from "./routes/activation-sync-ipc-routes.js";
|
|
62
|
+
import { CHANNEL_REPLY_IPC_METHODS } from "./routes/channel-reply-ipc-routes.js";
|
|
62
63
|
import { CONTACTS_INFO_IPC_METHODS } from "./routes/contacts-info-ipc-routes.js";
|
|
63
64
|
import { CONTACTS_MIRROR_IPC_METHODS } from "./routes/contacts-mirror-ipc-routes.js";
|
|
64
65
|
import { CONVERSATION_SYNC_IPC_METHODS } from "./routes/conversation-sync-ipc-routes.js";
|
|
@@ -216,6 +217,7 @@ export class AssistantIpcServer {
|
|
|
216
217
|
// never in ROUTES.
|
|
217
218
|
for (const methodMap of [
|
|
218
219
|
INVITE_IPC_METHODS,
|
|
220
|
+
CHANNEL_REPLY_IPC_METHODS,
|
|
219
221
|
CONTACTS_INFO_IPC_METHODS,
|
|
220
222
|
CONTACTS_MIRROR_IPC_METHODS,
|
|
221
223
|
GUARDIAN_LABEL_IPC_METHODS,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway's replies to messages it answered itself reach the person
|
|
3
|
+
* through the channel transport, the same one a turn's reply uses.
|
|
4
|
+
*
|
|
5
|
+
* The Bot API call is the boundary: everything between the gateway's request
|
|
6
|
+
* and Telegram's `sendMessage` is the real code, so the assertion is that the
|
|
7
|
+
* text a person would read left for their chat, in the topic they wrote from.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { DELIVER_GATEWAY_REPLY_IPC_METHOD } from "@vellumai/gateway-client";
|
|
13
|
+
|
|
14
|
+
type CallTelegramBotApi =
|
|
15
|
+
typeof import("../../../messaging/providers/telegram-bot/api.js").callTelegramBotApi;
|
|
16
|
+
|
|
17
|
+
const callTelegramBotApiMock = mock<CallTelegramBotApi>(
|
|
18
|
+
async () => ({ message_id: 42 }) as never,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// Spread the actual module so exports this suite does not touch stay
|
|
22
|
+
// importable by files that share the bun process.
|
|
23
|
+
const actualTelegramApi =
|
|
24
|
+
await import("../../../messaging/providers/telegram-bot/api.js");
|
|
25
|
+
mock.module("../../../messaging/providers/telegram-bot/api.js", () => ({
|
|
26
|
+
...actualTelegramApi,
|
|
27
|
+
callTelegramBotApi: (method: string, body: Record<string, unknown>) =>
|
|
28
|
+
callTelegramBotApiMock(method, body),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const { CHANNEL_REPLY_IPC_METHODS } =
|
|
32
|
+
await import("../channel-reply-ipc-routes.js");
|
|
33
|
+
const { BadGatewayError, BadRequestError } =
|
|
34
|
+
await import("../../../runtime/routes/errors.js");
|
|
35
|
+
|
|
36
|
+
const deliverGatewayReply =
|
|
37
|
+
CHANNEL_REPLY_IPC_METHODS[DELIVER_GATEWAY_REPLY_IPC_METHOD]!;
|
|
38
|
+
|
|
39
|
+
// The callback URL exactly as the gateway's Telegram webhook builds it for a
|
|
40
|
+
// message sent in a topic.
|
|
41
|
+
const TELEGRAM_CALLBACK = "http://127.0.0.1:7830/deliver/telegram?threadId=7";
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
callTelegramBotApiMock.mockClear();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("deliver_gateway_reply", () => {
|
|
48
|
+
test("sends the reply to the person's Telegram chat, in their topic", async () => {
|
|
49
|
+
const result = await deliverGatewayReply({
|
|
50
|
+
body: {
|
|
51
|
+
callbackUrl: TELEGRAM_CALLBACK,
|
|
52
|
+
chatId: "12345",
|
|
53
|
+
text: "Welcome! You've been granted access.",
|
|
54
|
+
assistantId: "self",
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const sends = callTelegramBotApiMock.mock.calls.filter(
|
|
59
|
+
([method]) => method === "sendMessage",
|
|
60
|
+
);
|
|
61
|
+
expect(sends).toHaveLength(1);
|
|
62
|
+
expect(sends[0]![1]).toMatchObject({
|
|
63
|
+
chat_id: "12345",
|
|
64
|
+
text: "Welcome! You've been granted access.",
|
|
65
|
+
message_thread_id: 7,
|
|
66
|
+
});
|
|
67
|
+
expect(result).toEqual({ ok: true, messageIds: ["42"] });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("answers a send the channel refuses with a status, not a bare error", async () => {
|
|
71
|
+
// Only a RouteError crosses IPC with a status; without one the gateway
|
|
72
|
+
// reads the refusal as a daemon that never answered.
|
|
73
|
+
callTelegramBotApiMock.mockImplementationOnce(async () => {
|
|
74
|
+
throw new Error("Bad Request: chat not found");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const rejection = expect(
|
|
78
|
+
deliverGatewayReply({
|
|
79
|
+
body: {
|
|
80
|
+
callbackUrl: TELEGRAM_CALLBACK,
|
|
81
|
+
chatId: "12345",
|
|
82
|
+
text: "Welcome! You've been granted access.",
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
).rejects;
|
|
86
|
+
await rejection.toBeInstanceOf(BadGatewayError);
|
|
87
|
+
await rejection.toMatchObject({ statusCode: 502 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("refuses a callback no channel transport owns, sending nothing", async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
deliverGatewayReply({
|
|
93
|
+
body: {
|
|
94
|
+
callbackUrl: "http://127.0.0.1:7830/deliver/email",
|
|
95
|
+
chatId: "someone@example.com",
|
|
96
|
+
text: "Verification successful.",
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
100
|
+
expect(callTelegramBotApiMock).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("refuses a request with no text, sending nothing", async () => {
|
|
104
|
+
await expect(
|
|
105
|
+
deliverGatewayReply({
|
|
106
|
+
body: { callbackUrl: TELEGRAM_CALLBACK, chatId: "12345", text: "" },
|
|
107
|
+
}),
|
|
108
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
109
|
+
expect(callTelegramBotApiMock).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC-only channel reply method called by the gateway over the assistant IPC
|
|
3
|
+
* socket (`ipcCallAssistant`).
|
|
4
|
+
*
|
|
5
|
+
* The gateway answers some inbound messages itself without forwarding them
|
|
6
|
+
* (a verification code, an invite redemption), so the assistant never sees
|
|
7
|
+
* them. Its reply still has to reach the person, and the channel transports
|
|
8
|
+
* that can send it live here. This hands the reply to the transport the
|
|
9
|
+
* inbound message's callback URL names, which is how the daemon's own
|
|
10
|
+
* intercepts answer an inbound message: the gateway sends through the same
|
|
11
|
+
* transports and never to a provider itself.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is recorded in a conversation. The message the reply answers never
|
|
14
|
+
* entered one, so a row for the reply would stand alone in a transcript that
|
|
15
|
+
* does not hold what it responds to.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
DELIVER_GATEWAY_REPLY_IPC_METHOD,
|
|
20
|
+
GatewayReplyRequestSchema,
|
|
21
|
+
} from "@vellumai/gateway-client";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
deliverDirect,
|
|
25
|
+
isDirectDelivery,
|
|
26
|
+
} from "../../messaging/providers/index.js";
|
|
27
|
+
import {
|
|
28
|
+
BadGatewayError,
|
|
29
|
+
BadRequestError,
|
|
30
|
+
} from "../../runtime/routes/errors.js";
|
|
31
|
+
import { parseBody } from "../../runtime/routes/parse-body.js";
|
|
32
|
+
import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deliver a gateway-composed reply through the channel transport its callback
|
|
36
|
+
* URL names. Refuses a callback no transport owns rather than fetching it, so
|
|
37
|
+
* the gateway learns the channel cannot be answered this way.
|
|
38
|
+
*
|
|
39
|
+
* A send the channel refuses is answered as a `BadGatewayError`, as
|
|
40
|
+
* `channels/send` answers it: only a `RouteError` carries a status over IPC,
|
|
41
|
+
* and the gateway reads an error without one as a daemon that never answered.
|
|
42
|
+
*/
|
|
43
|
+
export async function handleDeliverGatewayReply({
|
|
44
|
+
body = {},
|
|
45
|
+
}: RouteHandlerArgs) {
|
|
46
|
+
const { callbackUrl, chatId, text, assistantId } = parseBody(
|
|
47
|
+
GatewayReplyRequestSchema,
|
|
48
|
+
body,
|
|
49
|
+
);
|
|
50
|
+
if (!isDirectDelivery(callbackUrl)) {
|
|
51
|
+
throw new BadRequestError("No channel transport owns this callback URL");
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return await deliverDirect(callbackUrl, {
|
|
55
|
+
chatId,
|
|
56
|
+
text,
|
|
57
|
+
...(assistantId ? { assistantId } : {}),
|
|
58
|
+
});
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new BadGatewayError(err instanceof Error ? err.message : String(err));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* IPC-only channel reply methods, keyed by IPC operationId. Registered
|
|
66
|
+
* directly on the assistant IPC server (see `assistant-server.ts`).
|
|
67
|
+
*/
|
|
68
|
+
export const CHANNEL_REPLY_IPC_METHODS: Record<
|
|
69
|
+
string,
|
|
70
|
+
(args: RouteHandlerArgs) => unknown
|
|
71
|
+
> = {
|
|
72
|
+
[DELIVER_GATEWAY_REPLY_IPC_METHOD]: handleDeliverGatewayReply,
|
|
73
|
+
};
|
|
@@ -41,8 +41,9 @@ const DISCORD_ALLOWED_MENTIONS = { parse: ["users"] } as const;
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Upper bound on an outbound attachment. Discord's real limit is the guild's
|
|
44
|
-
* boost tier (
|
|
45
|
-
*
|
|
44
|
+
* boost tier (20 MiB per file by default, more above that; see
|
|
45
|
+
* https://discord.com/developers/docs/reference#uploading-files), which the
|
|
46
|
+
* API does not expose here, so the client-side guard is only the ceiling no tier exceeds:
|
|
46
47
|
* past it the upload is provably futile and not worth the bandwidth. Anything
|
|
47
48
|
* under it is attempted and, if the guild's own tier rejects it, reported
|
|
48
49
|
* through the same failure notice as any other attachment error.
|
|
@@ -692,7 +692,9 @@ describe("telegramTransport.streamReply", () => {
|
|
|
692
692
|
});
|
|
693
693
|
|
|
694
694
|
describe("sendTelegramAttachments", () => {
|
|
695
|
-
// The Bot API's
|
|
695
|
+
// The Bot API's multipart upload limits: a photo, and any other file. The
|
|
696
|
+
// sender skips anything over the second.
|
|
697
|
+
const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
|
|
696
698
|
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
697
699
|
|
|
698
700
|
function attachment(
|
|
@@ -759,6 +761,33 @@ describe("sendTelegramAttachments", () => {
|
|
|
759
761
|
expect(await document.text()).toBe("pdf");
|
|
760
762
|
});
|
|
761
763
|
|
|
764
|
+
test("sends an image over the photo limit with sendDocument", async () => {
|
|
765
|
+
storeHolds({
|
|
766
|
+
"att-1": Buffer.alloc(MAX_PHOTO_BYTES),
|
|
767
|
+
"att-2": Buffer.alloc(MAX_PHOTO_BYTES + 1),
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const result = await sendTelegramAttachments("123", [
|
|
771
|
+
attachment("att-1", "fits.png", "image/png", MAX_PHOTO_BYTES),
|
|
772
|
+
attachment("att-2", "scan.png", "image/png", MAX_PHOTO_BYTES + 1),
|
|
773
|
+
]);
|
|
774
|
+
|
|
775
|
+
expect(multipartCalls().map((c) => c.method)).toEqual([
|
|
776
|
+
"sendPhoto",
|
|
777
|
+
"sendDocument",
|
|
778
|
+
]);
|
|
779
|
+
const document = multipartCalls()[1]?.form.get("document") as File;
|
|
780
|
+
expect(document.name).toBe("scan.png");
|
|
781
|
+
expect(document.type).toBe("image/png");
|
|
782
|
+
expect(document.size).toBe(MAX_PHOTO_BYTES + 1);
|
|
783
|
+
expect(noticeTexts()).toEqual([]);
|
|
784
|
+
expect(result).toEqual({
|
|
785
|
+
allFailed: false,
|
|
786
|
+
failureCount: 0,
|
|
787
|
+
totalCount: 2,
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
762
791
|
test("targets the topic on both the upload and the failure notice", async () => {
|
|
763
792
|
storeHolds({ "att-1": Buffer.from("png") });
|
|
764
793
|
|
|
@@ -31,7 +31,9 @@ const TELEGRAM_MAX_MESSAGE_LEN = 4000;
|
|
|
31
31
|
/** Telegram Bot API enforces a 1-64 byte limit on InlineKeyboardButton callback_data. */
|
|
32
32
|
const TELEGRAM_MAX_CALLBACK_DATA_BYTES = 64;
|
|
33
33
|
|
|
34
|
-
//
|
|
34
|
+
// Bot API limits for a multipart upload: 10 MB for a photo, 50 MB for any
|
|
35
|
+
// other file (https://core.telegram.org/bots/api#sending-files).
|
|
36
|
+
const TELEGRAM_MAX_PHOTO_BYTES = 10 * 1024 * 1024;
|
|
35
37
|
const TELEGRAM_MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
36
38
|
|
|
37
39
|
const TELEGRAM_IMAGE_MIME_PREFIXES = [
|
|
@@ -296,8 +298,9 @@ export type TelegramAttachmentResult = {
|
|
|
296
298
|
};
|
|
297
299
|
|
|
298
300
|
/**
|
|
299
|
-
* Send attachments to a Telegram chat, using sendPhoto for
|
|
300
|
-
* sendDocument for everything else
|
|
301
|
+
* Send attachments to a Telegram chat, using sendPhoto for an image within
|
|
302
|
+
* the photo limit and sendDocument for everything else, so an image over it
|
|
303
|
+
* still arrives as a file.
|
|
301
304
|
*/
|
|
302
305
|
export async function sendTelegramAttachments(
|
|
303
306
|
chatId: string,
|
|
@@ -351,10 +354,10 @@ export async function sendTelegramAttachments(
|
|
|
351
354
|
form.set("message_thread_id", String(threadFields.message_thread_id));
|
|
352
355
|
}
|
|
353
356
|
|
|
354
|
-
const
|
|
355
|
-
mimeType.startsWith(p)
|
|
356
|
-
|
|
357
|
-
if (
|
|
357
|
+
const sendsAsPhoto =
|
|
358
|
+
TELEGRAM_IMAGE_MIME_PREFIXES.some((p) => mimeType.startsWith(p)) &&
|
|
359
|
+
content.length <= TELEGRAM_MAX_PHOTO_BYTES;
|
|
360
|
+
if (sendsAsPhoto) {
|
|
358
361
|
form.set("photo", blob, filename);
|
|
359
362
|
await callTelegramBotApiMultipart("sendPhoto", form);
|
|
360
363
|
} else {
|
|
@@ -300,7 +300,9 @@ Local SSE via the assistant's broadcast mechanism. The `VellumAdapter` emits a `
|
|
|
300
300
|
- `title` and `body` -- rendered notification copy
|
|
301
301
|
- `deepLinkMetadata` -- optional metadata for navigating to the relevant context (e.g. `{ conversationId }`)
|
|
302
302
|
|
|
303
|
-
|
|
303
|
+
Every first-party client runs the shared web renderer (browser, desktop app, mobile apps), which posts a local notification from this payload and acks the delivery. When the user taps the notification, the client uses `deepLinkMetadata` to navigate to the relevant conversation.
|
|
304
|
+
|
|
305
|
+
A guardian-sensitive notification (approval requests, access requests, channel activation codes) carries `targetGuardianPrincipalId` and is published with `targetActorPrincipalId`, so the event hub delivers it only to connections authenticated as the guardian. The client shows it when the sending assistant is new enough to apply that targeting (`clients/web/src/lib/backwards-compat/guardian-notification-targeting.ts`) and drops it otherwise.
|
|
304
306
|
|
|
305
307
|
### Platform (always connected)
|
|
306
308
|
|
|
@@ -140,7 +140,7 @@ function makeSignal(
|
|
|
140
140
|
createdAt: 1700000000000,
|
|
141
141
|
sourceChannel: "scheduler",
|
|
142
142
|
sourceContextId: "ctx-1",
|
|
143
|
-
sourceEventName: "
|
|
143
|
+
sourceEventName: "assistant.share",
|
|
144
144
|
contextPayload: {},
|
|
145
145
|
attentionHints: {
|
|
146
146
|
requiresAction: false,
|
|
@@ -21,7 +21,7 @@ function makeSignal(
|
|
|
21
21
|
createdAt: Date.now(),
|
|
22
22
|
sourceChannel: "scheduler",
|
|
23
23
|
sourceContextId: "ctx-1",
|
|
24
|
-
sourceEventName: "
|
|
24
|
+
sourceEventName: "assistant.share",
|
|
25
25
|
contextPayload: {},
|
|
26
26
|
attentionHints: {
|
|
27
27
|
requiresAction: false,
|
|
@@ -99,7 +99,7 @@ function makeAssistantToolSignal(
|
|
|
99
99
|
createdAt: Date.now(),
|
|
100
100
|
sourceChannel: "assistant_tool",
|
|
101
101
|
sourceContextId: "tool-call-1",
|
|
102
|
-
sourceEventName: "
|
|
102
|
+
sourceEventName: "assistant.share",
|
|
103
103
|
contextPayload: {
|
|
104
104
|
requestedMessage: "exact verbatim text here",
|
|
105
105
|
requestedTitle: "Custom Title",
|
|
@@ -905,7 +905,9 @@ describe("scheduler requested-message pass-through in notification decision engi
|
|
|
905
905
|
expect(decision.reasoningSummary).not.toBe(
|
|
906
906
|
"scheduler requested-message pass-through",
|
|
907
907
|
);
|
|
908
|
-
expect(decision.reasoningSummary).not.toBe(
|
|
908
|
+
expect(decision.reasoningSummary).not.toBe(
|
|
909
|
+
"schedule_result pass-through",
|
|
910
|
+
);
|
|
909
911
|
} finally {
|
|
910
912
|
providerSendMessage = previousSendMessage;
|
|
911
913
|
}
|
|
@@ -107,10 +107,10 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
|
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
test("fails when body is the raw source event name", async () => {
|
|
110
|
-
const signal = makeSignal({ sourceEventName: "
|
|
110
|
+
const signal = makeSignal({ sourceEventName: "assistant.share" });
|
|
111
111
|
const decision = makeDecision({
|
|
112
112
|
renderedCopy: {
|
|
113
|
-
vellum: { title: "Reminder", body: "
|
|
113
|
+
vellum: { title: "Reminder", body: "assistant.share" },
|
|
114
114
|
},
|
|
115
115
|
});
|
|
116
116
|
const result = await runDeterministicChecks(signal, decision, context);
|
|
@@ -119,10 +119,10 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
test("fails when body matches the normalized source event name", async () => {
|
|
122
|
-
const signal = makeSignal({ sourceEventName: "
|
|
122
|
+
const signal = makeSignal({ sourceEventName: "assistant.share" });
|
|
123
123
|
const decision = makeDecision({
|
|
124
124
|
renderedCopy: {
|
|
125
|
-
vellum: { title: "Reminder", body: "
|
|
125
|
+
vellum: { title: "Reminder", body: "assistant share" },
|
|
126
126
|
},
|
|
127
127
|
});
|
|
128
128
|
const result = await runDeterministicChecks(signal, decision, context);
|
|
@@ -174,11 +174,11 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
|
|
|
174
174
|
test("still validates body quality for channels with rendered copy", async () => {
|
|
175
175
|
// Even when some channels lack copy (broadcaster fallback territory),
|
|
176
176
|
// channels that DO have copy must still pass the empty/event-name checks.
|
|
177
|
-
const signal = makeSignal({ sourceEventName: "
|
|
177
|
+
const signal = makeSignal({ sourceEventName: "assistant.share" });
|
|
178
178
|
const decision = makeDecision({
|
|
179
179
|
selectedChannels: ["vellum", "telegram"],
|
|
180
180
|
renderedCopy: {
|
|
181
|
-
telegram: { title: "Reminder", body: "
|
|
181
|
+
telegram: { title: "Reminder", body: "assistant.share" },
|
|
182
182
|
},
|
|
183
183
|
});
|
|
184
184
|
const result = await runDeterministicChecks(signal, decision, {
|
|
@@ -194,7 +194,7 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
|
|
|
194
194
|
// a usable body (no template for sourceEventName → buildGenericCopy
|
|
195
195
|
// returns body=""), the gate must fail-closed rather than letting
|
|
196
196
|
// dispatchDecision report 0/N sent.
|
|
197
|
-
const signal = makeSignal({ sourceEventName: "
|
|
197
|
+
const signal = makeSignal({ sourceEventName: "assistant.share" });
|
|
198
198
|
const decision = makeDecision({
|
|
199
199
|
selectedChannels: ["vellum"],
|
|
200
200
|
renderedCopy: {},
|
|
@@ -218,7 +218,7 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
test("passes when shouldNotify is false regardless of copy contents", async () => {
|
|
221
|
-
const signal = makeSignal({ sourceEventName: "
|
|
221
|
+
const signal = makeSignal({ sourceEventName: "assistant.share" });
|
|
222
222
|
const decision = makeDecision({
|
|
223
223
|
shouldNotify: false,
|
|
224
224
|
// Empty body + event-name body would both fail the copy check if
|
|
@@ -286,11 +286,11 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
|
|
|
286
286
|
test("still fails non-pass-through decision when body matches event name", async () => {
|
|
287
287
|
// Regression guard: the pass-through short-circuit must not weaken
|
|
288
288
|
// the check for LLM/fallback paths.
|
|
289
|
-
const signal = makeSignal({ sourceEventName: "
|
|
289
|
+
const signal = makeSignal({ sourceEventName: "assistant.share" });
|
|
290
290
|
const decision = makeDecision({
|
|
291
291
|
reasoningSummary: "llm classification",
|
|
292
292
|
renderedCopy: {
|
|
293
|
-
vellum: { title: "Reminder", body: "
|
|
293
|
+
vellum: { title: "Reminder", body: "assistant.share" },
|
|
294
294
|
},
|
|
295
295
|
});
|
|
296
296
|
const result = await runDeterministicChecks(signal, decision, context);
|
|
@@ -281,7 +281,9 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
281
281
|
"The deployment completed successfully.",
|
|
282
282
|
);
|
|
283
283
|
expect(appendCalls[0]!.conversationId).toBe("conv-source-1");
|
|
284
|
-
expect(
|
|
284
|
+
expect(
|
|
285
|
+
appendCalls[0]!.metadata?.notificationConversationMessageId,
|
|
286
|
+
).toBeUndefined();
|
|
285
287
|
expect(messageAppends).toEqual([]);
|
|
286
288
|
expect(messagesInvalidated).toEqual([]);
|
|
287
289
|
expect(conversationLookups).toEqual(["conv-source-1", "conv-source-1"]);
|
|
@@ -956,7 +958,7 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
956
958
|
conversationRow = { conversationType: "background" };
|
|
957
959
|
const signal = makeSignal({
|
|
958
960
|
sourceChannel: "assistant_tool",
|
|
959
|
-
sourceEventName: "
|
|
961
|
+
sourceEventName: "assistant.share",
|
|
960
962
|
contextPayload: { title: "Tool share", body: "Body" },
|
|
961
963
|
});
|
|
962
964
|
|
|
@@ -970,7 +972,7 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
970
972
|
conversationRow = { conversationType: "background" };
|
|
971
973
|
const signal = makeSignal({
|
|
972
974
|
sourceChannel: "assistant_tool",
|
|
973
|
-
sourceEventName: "
|
|
975
|
+
sourceEventName: "assistant.share",
|
|
974
976
|
contextPayload: { title: "Tool share", body: "Body" },
|
|
975
977
|
});
|
|
976
978
|
|
|
@@ -1126,7 +1128,7 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
1126
1128
|
conversationRow = { conversationType: "background" };
|
|
1127
1129
|
const signal = makeSignal({
|
|
1128
1130
|
sourceChannel: "assistant_tool",
|
|
1129
|
-
sourceEventName: "
|
|
1131
|
+
sourceEventName: "assistant.share",
|
|
1130
1132
|
contextPayload: { title: "Tool share", body: "Body" },
|
|
1131
1133
|
});
|
|
1132
1134
|
|
|
@@ -331,19 +331,6 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
331
331
|
body: str(payload.body, "A watcher event occurred"),
|
|
332
332
|
}),
|
|
333
333
|
|
|
334
|
-
"watcher.escalation": (payload) => ({
|
|
335
|
-
title: str(payload.title, "Watcher Escalation"),
|
|
336
|
-
body: str(payload.body, "A watcher event requires your attention"),
|
|
337
|
-
}),
|
|
338
|
-
|
|
339
|
-
"tool_confirmation.required_action": (payload) => {
|
|
340
|
-
const toolName = str(payload.toolName, "A tool");
|
|
341
|
-
return {
|
|
342
|
-
title: `${toolName} needs your confirmation`,
|
|
343
|
-
body: `${toolName} requires your confirmation`,
|
|
344
|
-
};
|
|
345
|
-
},
|
|
346
|
-
|
|
347
334
|
// Titled by what was done rather than by the kind of event: the summary's
|
|
348
335
|
// first sentence is the outcome ("Finished the fuel-system diagnostic app"),
|
|
349
336
|
// which is what a reader scanning the bell wants to see. A producer that
|
|
@@ -385,16 +372,6 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
385
372
|
body: summary !== "" ? summary : describeUnclassifiedFailure(payload),
|
|
386
373
|
};
|
|
387
374
|
},
|
|
388
|
-
|
|
389
|
-
"quick_chat.response_ready": (payload) => ({
|
|
390
|
-
title: "Quick chat reply ready",
|
|
391
|
-
body: str(payload.preview, "Your quick chat response is ready"),
|
|
392
|
-
}),
|
|
393
|
-
|
|
394
|
-
"voice.response_ready": (payload) => ({
|
|
395
|
-
title: "Voice reply ready",
|
|
396
|
-
body: str(payload.preview, "A voice response is ready"),
|
|
397
|
-
}),
|
|
398
375
|
};
|
|
399
376
|
|
|
400
377
|
/**
|
|
@@ -254,7 +254,7 @@ function checkDedupe(
|
|
|
254
254
|
/**
|
|
255
255
|
* Fail-closed check that the rendered copy is real text and not an
|
|
256
256
|
* accidental fallback leak (empty body, or body that is just the raw
|
|
257
|
-
* source event name like "
|
|
257
|
+
* source event name like "assistant.share").
|
|
258
258
|
*
|
|
259
259
|
* Only validates channels that the decision engine actually emitted
|
|
260
260
|
* copy for. Channels appended after the decision (urgency-forced
|
|
@@ -459,12 +459,10 @@ const EVENT_CATEGORY_MAP: Record<string, FeedItemCategory> = {
|
|
|
459
459
|
|
|
460
460
|
/**
|
|
461
461
|
* Map a signal's source event to a feed category, or nothing when the event
|
|
462
|
-
* has no entry. An unmapped event
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* category
|
|
466
|
-
* carries none: readers that filter by category skip it, and nothing has
|
|
467
|
-
* to guess.
|
|
462
|
+
* has no entry. An unmapped event, such as a deliberate assistant
|
|
463
|
+
* notification (`assistant.share`), carries no category rather than a
|
|
464
|
+
* catch-all bucket named for our architecture: readers that filter by
|
|
465
|
+
* category skip it, and nothing has to guess.
|
|
468
466
|
*/
|
|
469
467
|
function deriveCategory(
|
|
470
468
|
signal: NotificationSignal,
|
|
@@ -59,8 +59,9 @@ export function isNotificationSourceChannel(
|
|
|
59
59
|
|
|
60
60
|
export const NOTIFICATION_SOURCE_EVENT_NAMES = [
|
|
61
61
|
{
|
|
62
|
-
id: "
|
|
63
|
-
description:
|
|
62
|
+
id: "assistant.share",
|
|
63
|
+
description:
|
|
64
|
+
"Assistant chose to tell the user something (`assistant notifications send`)",
|
|
64
65
|
},
|
|
65
66
|
{
|
|
66
67
|
id: "schedule.notify",
|
|
@@ -113,28 +114,12 @@ export const NOTIFICATION_SOURCE_EVENT_NAMES = [
|
|
|
113
114
|
id: "watcher.notification",
|
|
114
115
|
description: "Watcher detected a notable event",
|
|
115
116
|
},
|
|
116
|
-
{
|
|
117
|
-
id: "watcher.escalation",
|
|
118
|
-
description: "Watcher event requiring immediate attention",
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
id: "tool_confirmation.required_action",
|
|
122
|
-
description: "Tool requires user confirmation before executing",
|
|
123
|
-
},
|
|
124
117
|
{ id: "activity.complete", description: "Background activity finished" },
|
|
125
118
|
{
|
|
126
119
|
id: "activity.failed",
|
|
127
120
|
description:
|
|
128
121
|
"Background job execution failed (model_provider, exception, or timeout)",
|
|
129
122
|
},
|
|
130
|
-
{
|
|
131
|
-
id: "quick_chat.response_ready",
|
|
132
|
-
description: "Quick chat response ready for review",
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
id: "voice.response_ready",
|
|
136
|
-
description: "Voice response ready for playback",
|
|
137
|
-
},
|
|
138
123
|
{
|
|
139
124
|
id: "credential.health_alert",
|
|
140
125
|
description:
|
package/src/runtime/AGENTS.md
CHANGED
|
@@ -277,6 +277,8 @@ Verification SESSION state (sessions, secrets, rate limits, validate+consume) AN
|
|
|
277
277
|
|
|
278
278
|
The daemon relays session lifecycle operations over the `verification_sessions_*` IPC routes via `assistant/src/channels/gateway-verification-sessions.ts` and keeps what is presentation: message composition and channel delivery (`channel-verification-routes.ts`, `verification-outbound-actions.ts`). `channel-verification-service.ts` retains only guardian-delivery reads (`getGuardianBinding`, `isGuardian`, `isGuardianBoundForChannel`).
|
|
279
279
|
|
|
280
|
+
The gateway answers a code (or an invite) it consumed at ingress with a reply it composes itself, and delivers it through the daemon's IPC-only `deliver_gateway_reply` method (`ipc/routes/channel-reply-ipc-routes.ts`), which hands it to the channel transport the inbound message's callback URL names. The gateway never sends to a provider itself: the transports are the one outbound path per channel, whoever composed the text. The reply is not recorded in a conversation, since the message it answers never entered one.
|
|
281
|
+
|
|
280
282
|
The verified outcome is written in-process by the gateway: the HTTP guardian-attest handler calls `ContactStore.markChannelVerified` directly (verifiedVia "manual"); the code-match paths (text and the `verification_sessions_validate_consume` engine route) apply role side effects in-engine — guardian phone binding commits in the same gateway transaction as the consume. The revoke/downgrade outcome is relayed from the daemon via `ipcCallPersistent("mark_channel_revoked", …)` to `ContactStore.markChannelRevoked`.
|
|
281
283
|
|
|
282
284
|
## Rate Limiting & Diagnostics
|