@vellumai/assistant 0.11.5 → 0.11.6-staging.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.
- package/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -9,9 +9,7 @@ const slack = {
|
|
|
9
9
|
Promise.resolve({ ts: "slack-ts" }),
|
|
10
10
|
),
|
|
11
11
|
sendSlackReaction: mock((..._args: unknown[]) => Promise.resolve()),
|
|
12
|
-
|
|
13
|
-
Promise.resolve(),
|
|
14
|
-
),
|
|
12
|
+
sendSlackAgentSessionStatus: mock((..._args: unknown[]) => Promise.resolve()),
|
|
15
13
|
sendSlackAttachments: mock((..._args: unknown[]) =>
|
|
16
14
|
Promise.resolve({ allFailed: false, failureCount: 0 }),
|
|
17
15
|
),
|
|
@@ -23,6 +21,7 @@ const slack = {
|
|
|
23
21
|
),
|
|
24
22
|
};
|
|
25
23
|
const telegram = {
|
|
24
|
+
editTelegramMessage: mock((..._args: unknown[]) => Promise.resolve()),
|
|
26
25
|
sendTelegramReply: mock((..._args: unknown[]) => Promise.resolve()),
|
|
27
26
|
sendTelegramRichReply: mock((..._args: unknown[]) => Promise.resolve()),
|
|
28
27
|
sendTelegramTypingIndicator: mock((..._args: unknown[]) => Promise.resolve()),
|
|
@@ -46,6 +45,7 @@ const discord = {
|
|
|
46
45
|
sendDiscordTypingIndicator: mock((..._args: unknown[]) =>
|
|
47
46
|
Promise.resolve(true),
|
|
48
47
|
),
|
|
48
|
+
editDiscordMessage: mock((..._args: unknown[]) => Promise.resolve()),
|
|
49
49
|
sendDiscordAttachments: mock((..._args: unknown[]) =>
|
|
50
50
|
Promise.resolve({ allFailed: false, failureCount: 0, totalCount: 0 }),
|
|
51
51
|
),
|
|
@@ -63,11 +63,9 @@ mock.module("../../../util/logger.js", () => ({
|
|
|
63
63
|
const {
|
|
64
64
|
deliverDirect,
|
|
65
65
|
editChannelMessage,
|
|
66
|
-
sendChannelReaction,
|
|
67
66
|
sendChannelStreamOp,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
supportsChannelTyping,
|
|
67
|
+
setChannelActivity,
|
|
68
|
+
supportsChannelActivity,
|
|
71
69
|
isDirectDelivery,
|
|
72
70
|
getTransportForCallback,
|
|
73
71
|
} = await import("../index.js");
|
|
@@ -146,18 +144,6 @@ describe("Slack sub-operation selection", () => {
|
|
|
146
144
|
expect(opts.threadTs).toBe("1700.9");
|
|
147
145
|
});
|
|
148
146
|
|
|
149
|
-
test("sendChannelReaction reaches Slack without touching the text path", async () => {
|
|
150
|
-
await sendChannelReaction(`${BASE}/deliver/slack`, {
|
|
151
|
-
chatId: "C1",
|
|
152
|
-
messageId: "1700.5",
|
|
153
|
-
emoji: "white_check_mark",
|
|
154
|
-
action: "add",
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
expect(slack.sendSlackReaction).toHaveBeenCalledTimes(1);
|
|
158
|
-
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
147
|
test("editChannelMessage updates in place instead of posting", async () => {
|
|
162
148
|
await editChannelMessage(`${BASE}/deliver/slack`, {
|
|
163
149
|
chatId: "C1",
|
|
@@ -171,31 +157,22 @@ describe("Slack sub-operation selection", () => {
|
|
|
171
157
|
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
172
158
|
});
|
|
173
159
|
|
|
174
|
-
test("
|
|
175
|
-
await
|
|
160
|
+
test("setChannelActivity reaches Slack without touching the text path", async () => {
|
|
161
|
+
await setChannelActivity(`${BASE}/deliver/slack`, {
|
|
176
162
|
chatId: "C1",
|
|
177
|
-
|
|
178
|
-
status: "is thinking",
|
|
163
|
+
phase: "thinking",
|
|
179
164
|
});
|
|
180
165
|
|
|
181
|
-
expect(slack.
|
|
166
|
+
expect(slack.sendSlackAgentSessionStatus).toHaveBeenCalledTimes(1);
|
|
182
167
|
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
183
168
|
});
|
|
184
169
|
|
|
185
|
-
test("a channel with no
|
|
186
|
-
const result = await
|
|
170
|
+
test("a channel with no activity indicator resolves quietly", async () => {
|
|
171
|
+
const result = await setChannelActivity(`${BASE}/deliver/whatsapp`, {
|
|
187
172
|
chatId: "123",
|
|
188
|
-
|
|
189
|
-
status: "is thinking",
|
|
173
|
+
phase: "thinking",
|
|
190
174
|
});
|
|
191
175
|
|
|
192
|
-
expect(result).toEqual({ ok: true });
|
|
193
|
-
expect(telegram.sendTelegramReply).not.toHaveBeenCalled();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("sendChannelTyping resolves quietly for a channel with no typing capability", async () => {
|
|
197
|
-
const result = await sendChannelTyping(`${BASE}/deliver/slack`, "C1");
|
|
198
|
-
|
|
199
176
|
expect(result).toEqual({ ok: true });
|
|
200
177
|
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
201
178
|
});
|
|
@@ -204,12 +181,22 @@ describe("Slack sub-operation selection", () => {
|
|
|
204
181
|
const result = await sendChannelStreamOp(
|
|
205
182
|
`${BASE}/deliver/slack?threadTs=1700.5`,
|
|
206
183
|
"C1",
|
|
207
|
-
{
|
|
184
|
+
{
|
|
185
|
+
action: "start",
|
|
186
|
+
anchorMessageId: "1700.5",
|
|
187
|
+
text: "hi",
|
|
188
|
+
appended: "hi",
|
|
189
|
+
},
|
|
208
190
|
);
|
|
209
191
|
expect(slack.sendSlackStreamOp).toHaveBeenCalledTimes(1);
|
|
210
192
|
expect(slack.sendSlackStreamOp.mock.calls[0]).toEqual([
|
|
211
193
|
"C1",
|
|
212
|
-
{
|
|
194
|
+
{
|
|
195
|
+
action: "start",
|
|
196
|
+
anchorMessageId: "1700.5",
|
|
197
|
+
text: "hi",
|
|
198
|
+
appended: "hi",
|
|
199
|
+
},
|
|
213
200
|
]);
|
|
214
201
|
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
215
202
|
expect(result).toEqual({ ok: true, ts: "stream-ts" });
|
|
@@ -217,48 +204,115 @@ describe("Slack sub-operation selection", () => {
|
|
|
217
204
|
});
|
|
218
205
|
|
|
219
206
|
describe("capability gating across channels", () => {
|
|
220
|
-
test("a
|
|
221
|
-
|
|
222
|
-
// is Slack's own acknowledgement fallback. A channel without the method
|
|
223
|
-
// is not a failed delivery, so nothing is attempted and nothing throws.
|
|
224
|
-
const target = {
|
|
207
|
+
test("Slack renders a muted edit as its own context block", async () => {
|
|
208
|
+
await editChannelMessage(`${BASE}/deliver/slack`, {
|
|
225
209
|
chatId: "C1",
|
|
226
|
-
messageId: "
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
210
|
+
messageId: "1700.5",
|
|
211
|
+
text: "This approval request has been resolved.",
|
|
212
|
+
emphasis: "muted",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// The producer asked for a settled message and named no markup. Slack
|
|
216
|
+
// decides that means a context block.
|
|
217
|
+
const [, , , options] = slack.updateSlackMessage.mock.calls[0]!;
|
|
218
|
+
expect((options as { blocks?: unknown[] }).blocks).toEqual([
|
|
219
|
+
{
|
|
220
|
+
type: "context",
|
|
221
|
+
elements: [
|
|
222
|
+
{
|
|
223
|
+
type: "mrkdwn",
|
|
224
|
+
text: "This approval request has been resolved.",
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("an edit with no emphasis carries no channel markup", async () => {
|
|
232
|
+
await editChannelMessage(`${BASE}/deliver/slack`, {
|
|
233
|
+
chatId: "C1",
|
|
234
|
+
messageId: "1700.5",
|
|
235
|
+
text: "revised",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const [, , , options] = slack.updateSlackMessage.mock.calls[0]!;
|
|
239
|
+
expect((options as { blocks?: unknown[] }).blocks).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("editChannelMessage now reaches Telegram, not only Slack", async () => {
|
|
243
|
+
// The capability gate is what let Telegram gain this: no caller changed,
|
|
244
|
+
// the transport grew the method.
|
|
245
|
+
await editChannelMessage(`${BASE}/deliver/telegram`, {
|
|
246
|
+
chatId: "123",
|
|
247
|
+
messageId: "456",
|
|
248
|
+
text: "revised",
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(telegram.editTelegramMessage).toHaveBeenCalledTimes(1);
|
|
233
252
|
expect(telegram.sendTelegramReply).not.toHaveBeenCalled();
|
|
234
253
|
});
|
|
235
254
|
|
|
236
255
|
test("a channel that cannot revise a sent message resolves quietly", async () => {
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
256
|
+
// WhatsApp has no `edit`. A channel without the method is not a failed
|
|
257
|
+
// delivery: nothing is attempted, nothing throws, and no fresh message is
|
|
258
|
+
// posted in place of the revision.
|
|
240
259
|
expect(
|
|
241
|
-
await editChannelMessage(`${BASE}/deliver/
|
|
260
|
+
await editChannelMessage(`${BASE}/deliver/whatsapp`, {
|
|
242
261
|
chatId: "C1",
|
|
243
262
|
messageId: "1",
|
|
244
263
|
text: "revised",
|
|
245
264
|
}),
|
|
246
265
|
).toEqual({ ok: true });
|
|
247
|
-
expect(
|
|
266
|
+
expect(whatsapp.sendWhatsAppReply).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("Discord edits in place instead of posting", async () => {
|
|
270
|
+
await editChannelMessage(`${BASE}/deliver/discord`, {
|
|
271
|
+
chatId: "C1",
|
|
272
|
+
messageId: "M9",
|
|
273
|
+
text: "revised",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(discord.editDiscordMessage).toHaveBeenCalledTimes(1);
|
|
277
|
+
// Same distinction the Slack case asserts: an edit never reaches the post
|
|
278
|
+
// path, so a failed edit cannot become a second visible message.
|
|
279
|
+
expect(discord.sendDiscordReply).not.toHaveBeenCalled();
|
|
248
280
|
});
|
|
249
281
|
|
|
250
|
-
test("
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
282
|
+
test("Discord renders a muted edit as subtext", async () => {
|
|
283
|
+
await editChannelMessage(`${BASE}/deliver/discord`, {
|
|
284
|
+
chatId: "C1",
|
|
285
|
+
messageId: "M9",
|
|
286
|
+
text: "This approval request has been resolved.",
|
|
287
|
+
emphasis: "muted",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// `muted` is surface-agnostic and each channel picks its own token. Slack
|
|
291
|
+
// uses a context block; Discord's nearest equivalent is subtext, which it
|
|
292
|
+
// renders at the size and colour of a dismiss line.
|
|
293
|
+
expect(discord.editDiscordMessage.mock.calls[0][3]).toEqual({
|
|
294
|
+
emphasis: "muted",
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("the activity capability is read from the transport, not the channel name", () => {
|
|
299
|
+
// The gate in background-dispatch asks this rather than testing
|
|
300
|
+
// `sourceChannel === "slack"`, so a channel that implements the method
|
|
301
|
+
// starts showing an indicator without a caller being changed. All three
|
|
302
|
+
// answer the same question now, which is the point of the single method:
|
|
303
|
+
// Slack holds its indicator and the other two re-assert theirs, and a
|
|
304
|
+
// caller cannot tell the difference.
|
|
305
|
+
expect(supportsChannelActivity(`${BASE}/deliver/slack`)).toBe(true);
|
|
306
|
+
expect(supportsChannelActivity(`${BASE}/deliver/telegram`)).toBe(true);
|
|
307
|
+
expect(supportsChannelActivity(`${BASE}/deliver/discord`)).toBe(true);
|
|
308
|
+
expect(supportsChannelActivity(`${BASE}/deliver/whatsapp`)).toBe(false);
|
|
258
309
|
});
|
|
259
310
|
|
|
260
|
-
test("
|
|
261
|
-
await
|
|
311
|
+
test("setChannelActivity reaches Telegram's typing indicator", async () => {
|
|
312
|
+
await setChannelActivity(`${BASE}/deliver/telegram`, {
|
|
313
|
+
chatId: "123",
|
|
314
|
+
phase: "thinking",
|
|
315
|
+
});
|
|
262
316
|
|
|
263
317
|
expect(telegram.sendTelegramTypingIndicator).toHaveBeenCalledTimes(1);
|
|
264
318
|
});
|
|
@@ -297,8 +351,11 @@ describe("capability gating across channels", () => {
|
|
|
297
351
|
});
|
|
298
352
|
});
|
|
299
353
|
|
|
300
|
-
test("
|
|
301
|
-
await
|
|
354
|
+
test("setChannelActivity reaches Discord's typing indicator", async () => {
|
|
355
|
+
await setChannelActivity(`${BASE}/deliver/discord`, {
|
|
356
|
+
chatId: "999",
|
|
357
|
+
phase: "thinking",
|
|
358
|
+
});
|
|
302
359
|
|
|
303
360
|
expect(discord.sendDiscordTypingIndicator).toHaveBeenCalledTimes(1);
|
|
304
361
|
});
|
|
@@ -306,8 +363,9 @@ describe("capability gating across channels", () => {
|
|
|
306
363
|
test("a channel that cannot stream resolves quietly, and posts nothing", async () => {
|
|
307
364
|
const result = await sendChannelStreamOp(`${BASE}/deliver/discord`, "999", {
|
|
308
365
|
action: "start",
|
|
309
|
-
|
|
310
|
-
|
|
366
|
+
anchorMessageId: "1700.5",
|
|
367
|
+
text: "hi",
|
|
368
|
+
appended: "hi",
|
|
311
369
|
});
|
|
312
370
|
|
|
313
371
|
expect(result).toEqual({ ok: true });
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { KnownBlock } from "@slack/types";
|
|
2
1
|
import type {
|
|
3
2
|
ChannelDeliveryResult,
|
|
4
3
|
ChannelReplyPayload,
|
|
5
|
-
|
|
4
|
+
StreamOp,
|
|
6
5
|
} from "@vellumai/gateway-client";
|
|
7
6
|
|
|
7
|
+
import type { AssistantActivityPhase } from "../../api/index.js";
|
|
8
8
|
import type { ChannelId } from "../../channels/types.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -17,32 +17,43 @@ export interface CallbackContext {
|
|
|
17
17
|
readonly params: Readonly<Record<string, string>>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* How busy the assistant is in one conversation, for the channel to show.
|
|
22
|
+
*
|
|
23
|
+
* `phase` is the daemon's own activity phase rather than a channel's word for
|
|
24
|
+
* it, so every channel is told the same lifecycle and decides how to render
|
|
25
|
+
* it. A channel whose indicator expires on its own re-sends while the busy
|
|
26
|
+
* phases last; a channel whose indicator holds sets it once and clears it on
|
|
27
|
+
* `idle`. Neither shape leaks into what the caller says.
|
|
28
|
+
*/
|
|
29
|
+
export interface ActivityTarget {
|
|
22
30
|
readonly chatId: string;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
readonly phase: AssistantActivityPhase;
|
|
32
|
+
/**
|
|
33
|
+
* Who started the turn. Channels that attribute a session to a person read
|
|
34
|
+
* it when the session is created, and ignore it afterwards.
|
|
35
|
+
*/
|
|
36
|
+
readonly initiatorUserId?: string;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
40
|
+
* Whether a phase means a turn is actually running.
|
|
41
|
+
*
|
|
42
|
+
* A channel with a plain busy indicator shows it for these and nothing else.
|
|
43
|
+
* `awaiting_confirmation` is deliberately not one: an approval is waiting on a
|
|
44
|
+
* person, and a typing bubble there promises output that is not coming.
|
|
32
45
|
*/
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/** Rotating hints shown under the status while it holds. */
|
|
38
|
-
readonly loadingMessages?: readonly string[];
|
|
46
|
+
export function isBusyActivityPhase(phase: AssistantActivityPhase): boolean {
|
|
47
|
+
return (
|
|
48
|
+
phase === "thinking" || phase === "streaming" || phase === "tool_running"
|
|
49
|
+
);
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/**
|
|
42
53
|
* An existing message to replace in place, and what to replace it with.
|
|
43
54
|
*
|
|
44
55
|
* `messageId` is the target in the channel's own id space, the same way
|
|
45
|
-
* `chatId` is. `
|
|
56
|
+
* `chatId` is. `renderRichly` and `emphasis` are the rendering inputs the edit
|
|
46
57
|
* carries, so a replacement renders the way the original did rather than
|
|
47
58
|
* degrading to plain text.
|
|
48
59
|
*/
|
|
@@ -50,8 +61,14 @@ export interface EditTarget {
|
|
|
50
61
|
readonly chatId: string;
|
|
51
62
|
readonly messageId: string;
|
|
52
63
|
readonly text: string;
|
|
53
|
-
readonly
|
|
54
|
-
|
|
64
|
+
readonly renderRichly?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* How prominent the replacement should read. Surface-agnostic, the way
|
|
67
|
+
* `ApprovalActionOption.emphasis` is: each channel translates it to its own
|
|
68
|
+
* token, and one with no equivalent renders plain text. `muted` marks a
|
|
69
|
+
* message as settled rather than current.
|
|
70
|
+
*/
|
|
71
|
+
readonly emphasis?: "muted";
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
/**
|
|
@@ -91,48 +108,48 @@ export interface ChannelTransport {
|
|
|
91
108
|
): Promise<ChannelDeliveryResult>;
|
|
92
109
|
|
|
93
110
|
/**
|
|
94
|
-
* Show
|
|
95
|
-
*
|
|
96
|
-
* Takes the place and nothing else, because that is all it needs. A channel
|
|
97
|
-
* without the affordance omits the method, so implementing it is the whole
|
|
98
|
-
* of declaring the capability.
|
|
99
|
-
*/
|
|
100
|
-
typing?(ctx: CallbackContext, chatId: string): Promise<ChannelDeliveryResult>;
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Add or remove one of the assistant's own emoji reactions on a message.
|
|
111
|
+
* Show how busy the assistant is, in whatever form the channel has.
|
|
104
112
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
113
|
+
* One capability rather than one per indicator shape: a self-expiring typing
|
|
114
|
+
* bubble and a status that holds until cleared answer the same question, and
|
|
115
|
+
* splitting them by how the channel keeps them alive forces every caller to
|
|
116
|
+
* know which channel it is talking to. A channel without any such affordance
|
|
117
|
+
* omits the method, so implementing it is the whole of declaring it.
|
|
109
118
|
*/
|
|
110
|
-
|
|
119
|
+
setActivity?(
|
|
111
120
|
ctx: CallbackContext,
|
|
112
|
-
target:
|
|
121
|
+
target: ActivityTarget,
|
|
113
122
|
): Promise<ChannelDeliveryResult>;
|
|
114
123
|
|
|
115
124
|
/**
|
|
116
|
-
*
|
|
125
|
+
* How often a busy indicator must be re-asserted to stay visible, for a
|
|
126
|
+
* channel whose indicator expires on its own.
|
|
117
127
|
*
|
|
118
|
-
*
|
|
119
|
-
* a
|
|
120
|
-
*
|
|
128
|
+
* Omitted by a channel whose indicator holds until it is changed, which is
|
|
129
|
+
* how a caller knows to set it once rather than run a timer. Declaring the
|
|
130
|
+
* cadence here keeps the caller from having to know which channel it is.
|
|
121
131
|
*/
|
|
122
|
-
|
|
123
|
-
ctx: CallbackContext,
|
|
124
|
-
status: ThreadStatus,
|
|
125
|
-
): Promise<ChannelDeliveryResult>;
|
|
132
|
+
readonly activityRefreshMs?: number;
|
|
126
133
|
|
|
127
134
|
/**
|
|
128
|
-
* Advance a
|
|
135
|
+
* Advance a reply that grows while the turn runs: open it, add to it, or end
|
|
136
|
+
* it.
|
|
137
|
+
*
|
|
138
|
+
* A channel with no primitive for this omits the method, and the caller
|
|
139
|
+
* sends the finished reply instead. Omitting it is the correct answer for a
|
|
140
|
+
* platform that offers nothing here rather than a gap to paper over: a
|
|
141
|
+
* channel is not made to simulate streaming by rewriting a sent message,
|
|
142
|
+
* which is both a worse experience and, on Telegram, discouraged by the
|
|
143
|
+
* platform that does offer one.
|
|
129
144
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
145
|
+
* What the channel leaves behind when the stream ends is its own business.
|
|
146
|
+
* Slack finalizes the streamed message in place; Telegram's live draft
|
|
147
|
+
* expires and the reply is persisted by an ordinary send. `stop` carries the
|
|
148
|
+
* complete reply so either can be true.
|
|
132
149
|
*/
|
|
133
150
|
streamReply?(
|
|
134
151
|
ctx: CallbackContext,
|
|
135
152
|
chatId: string,
|
|
136
|
-
op:
|
|
153
|
+
op: StreamOp,
|
|
137
154
|
): Promise<ChannelDeliveryResult>;
|
|
138
155
|
}
|
|
@@ -30,7 +30,8 @@ mock.module("../../../util/logger.js", () => ({
|
|
|
30
30
|
getLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }),
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
|
-
const { sendDiscordReply, sendDiscordAttachments } =
|
|
33
|
+
const { sendDiscordReply, sendDiscordAttachments, editDiscordMessage } =
|
|
34
|
+
await import("./send.js");
|
|
34
35
|
|
|
35
36
|
const originalFetch = globalThis.fetch;
|
|
36
37
|
|
|
@@ -177,3 +178,47 @@ describe("sendDiscordAttachments", () => {
|
|
|
177
178
|
expect(calls.every((c) => typeof c.body === "string")).toBe(true);
|
|
178
179
|
});
|
|
179
180
|
});
|
|
181
|
+
|
|
182
|
+
describe("editDiscordMessage", () => {
|
|
183
|
+
const bodyOf = (index = 0): Record<string, unknown> =>
|
|
184
|
+
JSON.parse(String(calls[index]?.body)) as Record<string, unknown>;
|
|
185
|
+
|
|
186
|
+
test("patches the message rather than posting a new one", async () => {
|
|
187
|
+
await editDiscordMessage({ channelId: "C1" }, "M9", "revised");
|
|
188
|
+
|
|
189
|
+
expect(calls).toHaveLength(1);
|
|
190
|
+
expect(calls[0]?.method).toBe("PATCH");
|
|
191
|
+
expect(calls[0]?.url).toContain("/channels/C1/messages/M9");
|
|
192
|
+
expect(bodyOf().content).toBe("revised");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("marks every line of a muted edit, not just the first", async () => {
|
|
196
|
+
// Discord's subtext applies per line, so one marker would render the first
|
|
197
|
+
// line small and leave the rest at body size.
|
|
198
|
+
await editDiscordMessage({ channelId: "C1" }, "M9", "Resolved.\nBy Ada.", {
|
|
199
|
+
emphasis: "muted",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(bodyOf().content).toBe("-# Resolved.\n-# By Ada.");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("leaves a blank line alone", async () => {
|
|
206
|
+
// A bare `-# ` renders an empty subtext line rather than a gap.
|
|
207
|
+
await editDiscordMessage({ channelId: "C1" }, "M9", "One.\n\nTwo.", {
|
|
208
|
+
emphasis: "muted",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(bodyOf().content).toBe("-# One.\n\n-# Two.");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a rejected edit throws rather than posting a replacement", async () => {
|
|
215
|
+
// The reason editing is its own capability: falling back to a post would
|
|
216
|
+
// leave the original standing beside a duplicate.
|
|
217
|
+
stubFetch(400, { message: "Invalid Form Body" });
|
|
218
|
+
|
|
219
|
+
await expect(
|
|
220
|
+
editDiscordMessage({ channelId: "C1" }, "M9", "revised"),
|
|
221
|
+
).rejects.toThrow();
|
|
222
|
+
expect(calls.every((call) => call.method === "PATCH")).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -65,6 +65,57 @@ export interface DiscordSendResult {
|
|
|
65
65
|
lastMessageId?: string;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Discord's rendering of a settled message.
|
|
70
|
+
*
|
|
71
|
+
* `-# ` is Discord's subtext markdown, which it describes as the same size and
|
|
72
|
+
* colour as the dismiss line under a bot message. It is the closest thing
|
|
73
|
+
* Discord has to Slack's context block, and it applies per line, so every line
|
|
74
|
+
* carries the marker rather than only the first.
|
|
75
|
+
*
|
|
76
|
+
* @see https://support.discord.com/hc/en-us/articles/210298617
|
|
77
|
+
*/
|
|
78
|
+
function mutedText(text: string): string {
|
|
79
|
+
return text
|
|
80
|
+
.split("\n")
|
|
81
|
+
.map((line) => (line.trim().length === 0 ? line : `-# ${line}`))
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replace a message the assistant already sent.
|
|
87
|
+
*
|
|
88
|
+
* Never posts. A failed edit throws so the original stands alone rather than
|
|
89
|
+
* gaining a duplicate beside it, which is the whole reason editing is its own
|
|
90
|
+
* capability.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately does not chunk. An edit addresses one message, and a
|
|
93
|
+
* replacement too long for one is a condition the caller has to know about
|
|
94
|
+
* rather than one this can paper over by dropping the tail. Discord rejects an
|
|
95
|
+
* over-length body and that rejection propagates.
|
|
96
|
+
*
|
|
97
|
+
* @see https://discord.com/developers/docs/resources/message#edit-message
|
|
98
|
+
*/
|
|
99
|
+
export async function editDiscordMessage(
|
|
100
|
+
target: DiscordSendTarget,
|
|
101
|
+
messageId: string,
|
|
102
|
+
text: string,
|
|
103
|
+
options?: { emphasis?: "muted" },
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
await callDiscordApi<DiscordMessage>(
|
|
106
|
+
"PATCH",
|
|
107
|
+
`${messagesRoute(target)}/${encodeURIComponent(messageId)}`,
|
|
108
|
+
{
|
|
109
|
+
content: options?.emphasis === "muted" ? mutedText(text) : text,
|
|
110
|
+
allowed_mentions: DISCORD_ALLOWED_MENTIONS,
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
log.debug(
|
|
114
|
+
{ channelId: target.channelId, messageId },
|
|
115
|
+
"Discord message edited",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
68
119
|
/**
|
|
69
120
|
* Send a text reply, split across as many messages as Discord's content cap
|
|
70
121
|
* requires. Chunks post in order so the reply reads top to bottom.
|
|
@@ -5,9 +5,11 @@ import type {
|
|
|
5
5
|
CallbackContext,
|
|
6
6
|
ChannelTransport,
|
|
7
7
|
} from "../channel-transport.js";
|
|
8
|
+
import { isBusyActivityPhase } from "../channel-transport.js";
|
|
8
9
|
import { openDiscordDmChannel } from "./api.js";
|
|
9
10
|
import type { DiscordSendTarget } from "./send.js";
|
|
10
11
|
import {
|
|
12
|
+
editDiscordMessage,
|
|
11
13
|
sendDiscordAttachments,
|
|
12
14
|
sendDiscordReply,
|
|
13
15
|
sendDiscordTypingIndicator,
|
|
@@ -47,9 +49,30 @@ async function sendTarget(
|
|
|
47
49
|
export const discordTransport: ChannelTransport = {
|
|
48
50
|
channel: "discord",
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// Discord clears a typing indicator after ten seconds.
|
|
53
|
+
activityRefreshMs: 8_000,
|
|
54
|
+
|
|
55
|
+
async edit(ctx, target) {
|
|
56
|
+
await editDiscordMessage(
|
|
57
|
+
await sendTarget(ctx, target.chatId),
|
|
58
|
+
target.messageId,
|
|
59
|
+
target.text,
|
|
60
|
+
{ ...(target.emphasis ? { emphasis: target.emphasis } : {}) },
|
|
61
|
+
);
|
|
62
|
+
return { ok: true };
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async setActivity(ctx, target) {
|
|
66
|
+
// Discord's typing indicator expires by itself after ten seconds, so a
|
|
67
|
+
// phase that is not running needs no clearing call.
|
|
68
|
+
if (!isBusyActivityPhase(target.phase)) {
|
|
69
|
+
return { ok: true };
|
|
70
|
+
}
|
|
71
|
+
await sendDiscordTypingIndicator(await sendTarget(ctx, target.chatId));
|
|
72
|
+
log.debug(
|
|
73
|
+
{ chatId: target.chatId, phase: target.phase },
|
|
74
|
+
"Discord typing indicator delivered (direct)",
|
|
75
|
+
);
|
|
53
76
|
return { ok: true };
|
|
54
77
|
},
|
|
55
78
|
|