@vellumai/vellum-gateway 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +36 -1
- package/src/__tests__/credential-reader.test.ts +1 -4
- package/src/__tests__/dedup-cache.test.ts +94 -9
- package/src/config.ts +37 -3
- package/src/dedup-cache.ts +37 -9
- package/src/http/routes/runtime-proxy.ts +10 -3
- package/src/http/routes/sms-deliver.test.ts +154 -2
- package/src/http/routes/sms-deliver.ts +81 -10
- package/src/http/routes/telegram-webhook.test.ts +277 -0
- package/src/http/routes/telegram-webhook.ts +102 -29
- package/src/http/routes/twilio-sms-webhook.test.ts +108 -5
- package/src/http/routes/twilio-sms-webhook.ts +81 -26
- package/src/index.ts +4 -0
- package/src/runtime/client.ts +1 -0
- package/src/schema.ts +6 -5
- package/src/twilio/send-sms.ts +14 -4
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ These fields are forwarded to the runtime in the `/channels/inbound` payload alo
|
|
|
138
138
|
|
|
139
139
|
**Normalization constraints:** Only DM-only (`private` chat type) callback queries are processed. Group and channel callbacks are dropped and acknowledged with `answerCallbackQuery` so the Telegram button spinner clears. Callback queries with no `data` field or no associated `message` are also dropped.
|
|
140
140
|
|
|
141
|
-
**Stale callback blocking:** When the runtime receives `callbackData` that does not match any pending approval (e.g., a button from an old prompt), it returns `stale_ignored` and does not process the payload as a regular message. This is enforced regardless of whether the callback has non-empty content. The gateway
|
|
141
|
+
**Stale callback blocking:** When the runtime receives `callbackData` that does not match any pending approval (e.g., a button from an old prompt), it returns `stale_ignored` and does not process the payload as a regular message. This is enforced regardless of whether the callback has non-empty content. The gateway sends a best-effort `answerCallbackQuery` acknowledgment for normalized callback updates (including stale, rejected, and forward-failure paths) so the button spinner clears promptly. Transient forwarding failures may still return `500` so Telegram retries update delivery.
|
|
142
142
|
|
|
143
143
|
## Approval Buttons and Inline Keyboard
|
|
144
144
|
|
|
@@ -370,7 +370,7 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
|
|
|
370
370
|
| Symptom | Cause | Resolution |
|
|
371
371
|
|---------|-------|------------|
|
|
372
372
|
| `/guardian_verify` command gets no reply | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
|
|
373
|
-
| Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel
|
|
373
|
+
| Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The runtime is fail-closed for unverified channels. | Set up a guardian by running the verification flow from the desktop UI. |
|
|
374
374
|
| Approval prompt not delivered to guardian | The `replyCallbackUrl` may be unreachable, or the guardian's chat ID is stale | Verify `GATEWAY_INTERNAL_BASE_URL` is set correctly (especially in containerized deployments). Re-verify the guardian if the chat ID has changed. |
|
|
375
375
|
| Guardian approval expired | The 30-minute TTL elapsed without a decision. A proactive sweep (every 60s) auto-denied the approval and notified both the requester and guardian. | The non-guardian user must re-trigger the action. |
|
|
376
376
|
| "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `externalUserId` matches the approval request can approve or deny. |
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { writeFileSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { describe, test, expect } from "bun:test";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import { loadConfig } from "../config.js";
|
|
4
6
|
|
|
5
7
|
const BASE_ENV = {
|
|
@@ -31,6 +33,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void)
|
|
|
31
33
|
"GATEWAY_MAX_ATTACHMENT_CONCURRENCY",
|
|
32
34
|
"GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS",
|
|
33
35
|
"VELLUM_HTTP_TOKEN_PATH",
|
|
36
|
+
"BASE_DATA_DIR",
|
|
34
37
|
];
|
|
35
38
|
|
|
36
39
|
for (const key of allKeys) {
|
|
@@ -259,3 +262,35 @@ describe("config: runtime bearer token", () => {
|
|
|
259
262
|
);
|
|
260
263
|
});
|
|
261
264
|
});
|
|
265
|
+
|
|
266
|
+
describe("config: twilio assistant phone number mapping", () => {
|
|
267
|
+
test("loads assistantPhoneNumbers from workspace config on startup", () => {
|
|
268
|
+
const testBaseDir = mkdtempSync(join(tmpdir(), "gateway-config-test-"));
|
|
269
|
+
try {
|
|
270
|
+
const workspaceDir = join(testBaseDir, ".vellum", "workspace");
|
|
271
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
272
|
+
writeFileSync(
|
|
273
|
+
join(workspaceDir, "config.json"),
|
|
274
|
+
JSON.stringify({
|
|
275
|
+
sms: {
|
|
276
|
+
phoneNumber: "+15550001111",
|
|
277
|
+
assistantPhoneNumbers: {
|
|
278
|
+
"asst-alpha": "+15550002222",
|
|
279
|
+
"asst-beta": "+15550003333",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
withEnv({ BASE_DATA_DIR: testBaseDir }, () => {
|
|
286
|
+
const config = loadConfig();
|
|
287
|
+
expect(config.assistantPhoneNumbers).toEqual({
|
|
288
|
+
"asst-alpha": "+15550002222",
|
|
289
|
+
"asst-beta": "+15550003333",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
} finally {
|
|
293
|
+
rmSync(testBaseDir, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach, mock
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
2
2
|
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -35,10 +35,7 @@ mock.module("node:child_process", () => {
|
|
|
35
35
|
|
|
36
36
|
import {
|
|
37
37
|
readTelegramCredentials,
|
|
38
|
-
readCredential,
|
|
39
38
|
readKeychainCredential,
|
|
40
|
-
getMetadataPath,
|
|
41
|
-
getRootDir,
|
|
42
39
|
} from "../credential-reader.js";
|
|
43
40
|
|
|
44
41
|
// ---------------------------------------------------------------------------
|
|
@@ -62,21 +62,48 @@ describe("DedupCache", () => {
|
|
|
62
62
|
expect(cache.size).toBe(2);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
test("reserve returns
|
|
66
|
-
expect(cache.reserve(50)).toBe(
|
|
65
|
+
test("reserve returns 'reserved' for unseen update_id and creates processing entry", () => {
|
|
66
|
+
expect(cache.reserve(50)).toBe("reserved");
|
|
67
67
|
expect(cache.size).toBe(1);
|
|
68
68
|
// get() returns undefined for processing entries (not yet finalized)
|
|
69
69
|
expect(cache.get(50)).toBeUndefined();
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
test("reserve returns
|
|
72
|
+
test("reserve returns 'duplicate' for already-reserved update_id", () => {
|
|
73
73
|
cache.reserve(60);
|
|
74
|
-
expect(cache.reserve(60)).toBe(
|
|
74
|
+
expect(cache.reserve(60)).toBe("duplicate");
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test("reserve returns
|
|
77
|
+
test("reserve returns 'duplicate' for already-cached update_id still in cache", () => {
|
|
78
|
+
// set() advances the high-water mark, but the cache entry is checked
|
|
79
|
+
// first — since it's still within TTL, reserve() returns "duplicate"
|
|
78
80
|
cache.set(70, '{"done":true}', 200);
|
|
79
|
-
expect(cache.reserve(70)).toBe(
|
|
81
|
+
expect(cache.reserve(70)).toBe("duplicate");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("reserve returns 'already_processed' for finalized update_id after TTL expires", () => {
|
|
85
|
+
const shortCache = new DedupCache(1, 100);
|
|
86
|
+
shortCache.set(70, '{"done":true}', 200);
|
|
87
|
+
|
|
88
|
+
// Wait for the TTL entry to expire
|
|
89
|
+
const start = Date.now();
|
|
90
|
+
while (Date.now() - start < 5) {
|
|
91
|
+
// busy-wait
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cache entry expired, but high-water mark still blocks replay
|
|
95
|
+
expect(shortCache.reserve(70)).toBe("already_processed");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("reserve returns 'duplicate' for in-cache entry above high-water mark", () => {
|
|
99
|
+
// reserve() without set() doesn't advance the high-water mark,
|
|
100
|
+
// so a second reserve() for the same ID returns "duplicate"
|
|
101
|
+
cache.reserve(71);
|
|
102
|
+
cache.set(71, '{"done":true}', 200);
|
|
103
|
+
// Now reserve a higher ID (which won't be finalized)
|
|
104
|
+
cache.reserve(72);
|
|
105
|
+
// 72 is above high-water mark (71) but already in cache
|
|
106
|
+
expect(cache.reserve(72)).toBe("duplicate");
|
|
80
107
|
});
|
|
81
108
|
|
|
82
109
|
test("get returns undefined while entry is still processing", () => {
|
|
@@ -99,12 +126,12 @@ describe("DedupCache", () => {
|
|
|
99
126
|
|
|
100
127
|
test("unreserve allows re-reservation after processing failure", () => {
|
|
101
128
|
cache.reserve(85);
|
|
102
|
-
expect(cache.reserve(85)).toBe(
|
|
129
|
+
expect(cache.reserve(85)).toBe("duplicate");
|
|
103
130
|
// Simulate processing failure — unreserve so Telegram can retry
|
|
104
131
|
cache.unreserve(85);
|
|
105
132
|
expect(cache.size).toBe(0);
|
|
106
133
|
// Now re-reserve should succeed
|
|
107
|
-
expect(cache.reserve(85)).toBe(
|
|
134
|
+
expect(cache.reserve(85)).toBe("reserved");
|
|
108
135
|
});
|
|
109
136
|
|
|
110
137
|
test("unreserve does not remove finalized entries", () => {
|
|
@@ -123,7 +150,65 @@ describe("DedupCache", () => {
|
|
|
123
150
|
// busy-wait for expiry
|
|
124
151
|
}
|
|
125
152
|
|
|
126
|
-
expect(shortCache.reserve(90)).toBe(
|
|
153
|
+
expect(shortCache.reserve(90)).toBe("reserved");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("high-water mark rejects update_ids at or below the max finalized", () => {
|
|
157
|
+
cache.reserve(100);
|
|
158
|
+
cache.set(100, '{"ok":true}', 200);
|
|
159
|
+
|
|
160
|
+
// update_id below the high-water mark is permanently rejected
|
|
161
|
+
expect(cache.reserve(99)).toBe("already_processed");
|
|
162
|
+
// same update_id is still in cache — returns "duplicate" (cache checked first)
|
|
163
|
+
expect(cache.reserve(100)).toBe("duplicate");
|
|
164
|
+
// update_id above the high-water mark is accepted
|
|
165
|
+
expect(cache.reserve(101)).toBe("reserved");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("high-water mark persists after cache TTL expires", () => {
|
|
169
|
+
const shortCache = new DedupCache(1, 100);
|
|
170
|
+
shortCache.reserve(50);
|
|
171
|
+
shortCache.set(50, '{"ok":true}', 200);
|
|
172
|
+
|
|
173
|
+
// Wait for the TTL entry to expire
|
|
174
|
+
const start = Date.now();
|
|
175
|
+
while (Date.now() - start < 5) {
|
|
176
|
+
// busy-wait
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// TTL entry is gone, but high-water mark still blocks replay
|
|
180
|
+
expect(shortCache.get(50)).toBeUndefined();
|
|
181
|
+
expect(shortCache.reserve(50)).toBe("already_processed");
|
|
182
|
+
expect(shortCache.reserve(49)).toBe("already_processed");
|
|
183
|
+
// Higher ID still works
|
|
184
|
+
expect(shortCache.reserve(51)).toBe("reserved");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("high-water mark advances to the max across non-sequential sets", () => {
|
|
188
|
+
cache.set(200, "a", 200);
|
|
189
|
+
cache.set(100, "b", 200);
|
|
190
|
+
cache.set(150, "c", 200);
|
|
191
|
+
|
|
192
|
+
// High-water mark should be 200 (the max), not 150 (the last set)
|
|
193
|
+
expect(cache.reserve(199)).toBe("already_processed");
|
|
194
|
+
// 200 is still in the cache — returns "duplicate" (cache checked first)
|
|
195
|
+
expect(cache.reserve(200)).toBe("duplicate");
|
|
196
|
+
expect(cache.reserve(201)).toBe("reserved");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("in-flight entry returns 'duplicate' even when high-water mark has advanced past it", () => {
|
|
200
|
+
// Simulate concurrent processing: reserve 101, then 102 finalizes first
|
|
201
|
+
cache.reserve(101);
|
|
202
|
+
cache.reserve(102);
|
|
203
|
+
cache.set(102, '{"ok":true}', 200); // advances high-water mark to 102
|
|
204
|
+
|
|
205
|
+
// 101 is still in-flight (reserved but not finalized). A retry for 101
|
|
206
|
+
// should return "duplicate" (not "already_processed") so the caller
|
|
207
|
+
// returns 503 and Telegram retries, rather than returning 200 which
|
|
208
|
+
// would suppress retries if the original 101 handler fails.
|
|
209
|
+
expect(cache.reserve(101)).toBe("duplicate");
|
|
210
|
+
// get() still returns undefined because 101 is in processing state
|
|
211
|
+
expect(cache.get(101)).toBeUndefined();
|
|
127
212
|
});
|
|
128
213
|
});
|
|
129
214
|
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { getLogger, type LogFileConfig } from "./logger.js";
|
|
5
|
+
import { getRootDir, readKeychainCredential, readCredential, readTwilioCredentials } from "./credential-reader.js";
|
|
5
6
|
|
|
6
7
|
const log = getLogger("config");
|
|
7
8
|
|
|
@@ -247,9 +248,40 @@ export function loadConfig(): GatewayConfig {
|
|
|
247
248
|
);
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
const
|
|
251
|
+
// Twilio credentials: env var > credential store (keychain / encrypted file)
|
|
252
|
+
const twilioCreds = readTwilioCredentials();
|
|
253
|
+
const twilioAuthToken = process.env.TWILIO_AUTH_TOKEN || twilioCreds?.authToken || undefined;
|
|
254
|
+
const twilioAccountSid = process.env.TWILIO_ACCOUNT_SID || twilioCreds?.accountSid || undefined;
|
|
255
|
+
|
|
256
|
+
// Phone number: env var > config file sms.phoneNumber > credential store
|
|
257
|
+
let twilioPhoneNumber: string | undefined = process.env.TWILIO_PHONE_NUMBER || undefined;
|
|
258
|
+
let assistantPhoneNumbers: Record<string, string> | undefined;
|
|
259
|
+
try {
|
|
260
|
+
const cfgPath = join(getRootDir(), "workspace", "config.json");
|
|
261
|
+
const raw = readFileSync(cfgPath, "utf-8");
|
|
262
|
+
const data = JSON.parse(raw);
|
|
263
|
+
if (!twilioPhoneNumber && data?.sms?.phoneNumber && typeof data.sms.phoneNumber === "string") {
|
|
264
|
+
twilioPhoneNumber = data.sms.phoneNumber;
|
|
265
|
+
}
|
|
266
|
+
const rawMapping = data?.sms?.assistantPhoneNumbers;
|
|
267
|
+
if (rawMapping && typeof rawMapping === "object" && !Array.isArray(rawMapping)) {
|
|
268
|
+
const normalized: Record<string, string> = {};
|
|
269
|
+
for (const [assistantId, phoneNumber] of Object.entries(rawMapping as Record<string, unknown>)) {
|
|
270
|
+
if (typeof phoneNumber === "string" && phoneNumber.trim().length > 0) {
|
|
271
|
+
normalized[assistantId] = phoneNumber;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
assistantPhoneNumbers = normalized;
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// config file may not exist yet
|
|
278
|
+
}
|
|
279
|
+
if (!twilioPhoneNumber) {
|
|
280
|
+
twilioPhoneNumber =
|
|
281
|
+
readKeychainCredential("credential:twilio:phone_number")
|
|
282
|
+
|| readCredential("credential:twilio:phone_number")
|
|
283
|
+
|| undefined;
|
|
284
|
+
}
|
|
253
285
|
|
|
254
286
|
const smsDeliverAuthBypassRaw = process.env.GATEWAY_SMS_DELIVER_AUTH_BYPASS;
|
|
255
287
|
if (
|
|
@@ -292,6 +324,7 @@ export function loadConfig(): GatewayConfig {
|
|
|
292
324
|
hasTwilioAuthToken: !!twilioAuthToken,
|
|
293
325
|
hasTwilioAccountSid: !!twilioAccountSid,
|
|
294
326
|
hasTwilioPhoneNumber: !!twilioPhoneNumber,
|
|
327
|
+
assistantPhoneNumberCount: assistantPhoneNumbers ? Object.keys(assistantPhoneNumbers).length : 0,
|
|
295
328
|
smsDeliverAuthBypass,
|
|
296
329
|
ingressPublicBaseUrl,
|
|
297
330
|
},
|
|
@@ -327,6 +360,7 @@ export function loadConfig(): GatewayConfig {
|
|
|
327
360
|
twilioAuthToken,
|
|
328
361
|
twilioAccountSid,
|
|
329
362
|
twilioPhoneNumber,
|
|
363
|
+
assistantPhoneNumbers,
|
|
330
364
|
smsDeliverAuthBypass,
|
|
331
365
|
ingressPublicBaseUrl,
|
|
332
366
|
unmappedPolicy,
|
package/src/dedup-cache.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { getLogger } from "./logger.js";
|
|
|
2
2
|
|
|
3
3
|
const log = getLogger("dedup-cache");
|
|
4
4
|
|
|
5
|
+
export type ReserveResult = "reserved" | "duplicate" | "already_processed";
|
|
6
|
+
|
|
5
7
|
interface CacheEntry {
|
|
6
8
|
body: string;
|
|
7
9
|
status: number;
|
|
@@ -19,6 +21,14 @@ export class DedupCache {
|
|
|
19
21
|
private cache = new Map<number, CacheEntry>();
|
|
20
22
|
private readonly ttlMs: number;
|
|
21
23
|
private readonly maxSize: number;
|
|
24
|
+
/**
|
|
25
|
+
* Monotonic high-water mark: the highest update_id that has been fully
|
|
26
|
+
* processed (finalized via set()). Any update_id at or below this value
|
|
27
|
+
* is rejected permanently, even after the TTL cache evicts the entry.
|
|
28
|
+
* This closes the replay window that existed when entries expired from
|
|
29
|
+
* the TTL cache.
|
|
30
|
+
*/
|
|
31
|
+
private highWaterMark = -Infinity;
|
|
22
32
|
|
|
23
33
|
constructor(ttlMs = 5 * 60_000, maxSize = 10_000) {
|
|
24
34
|
this.ttlMs = ttlMs;
|
|
@@ -45,21 +55,33 @@ export class DedupCache {
|
|
|
45
55
|
* Checks whether this update_id is already reserved or cached.
|
|
46
56
|
* If not, immediately reserves it with a "processing" sentinel so that
|
|
47
57
|
* concurrent retries are blocked before the handler finishes.
|
|
48
|
-
* Returns true if a new reservation was created (caller should proceed),
|
|
49
|
-
* false if the update_id was already present (caller should short-circuit).
|
|
50
58
|
*
|
|
51
|
-
*
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
59
|
+
* Returns:
|
|
60
|
+
* - `'reserved'` — new reservation created, caller should proceed
|
|
61
|
+
* - `'duplicate'` — update_id is already in the cache (in-flight or finalized)
|
|
62
|
+
* - `'already_processed'` — update_id is at or below the high-water mark
|
|
63
|
+
* (fully processed, TTL entry may have expired)
|
|
54
64
|
*/
|
|
55
|
-
reserve(updateId: number):
|
|
65
|
+
reserve(updateId: number): ReserveResult {
|
|
66
|
+
// Check existing cache entries FIRST — an active (non-expired) entry
|
|
67
|
+
// takes priority over the high-water mark. This prevents a race where
|
|
68
|
+
// a higher update_id finalizes first (advancing the high-water mark)
|
|
69
|
+
// while a lower update_id is still in-flight: without this ordering,
|
|
70
|
+
// the in-flight entry would be reported as "already_processed" instead
|
|
71
|
+
// of "duplicate", causing the caller to return 200 and suppress retries.
|
|
56
72
|
const existing = this.cache.get(updateId);
|
|
57
73
|
if (existing && Date.now() <= existing.expiresAt) {
|
|
58
|
-
return
|
|
74
|
+
return "duplicate";
|
|
59
75
|
}
|
|
60
76
|
// Clean up expired entry if present
|
|
61
77
|
if (existing) this.cache.delete(updateId);
|
|
62
78
|
|
|
79
|
+
// Reject any update_id at or below the high-water mark — these have
|
|
80
|
+
// already been fully processed and are replay attempts.
|
|
81
|
+
if (updateId <= this.highWaterMark) {
|
|
82
|
+
return "already_processed";
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
// Evict if at capacity
|
|
64
86
|
if (this.cache.size >= this.maxSize) {
|
|
65
87
|
this.evictExpired();
|
|
@@ -77,7 +99,7 @@ export class DedupCache {
|
|
|
77
99
|
expiresAt: Date.now() + this.ttlMs,
|
|
78
100
|
processing: true,
|
|
79
101
|
});
|
|
80
|
-
return
|
|
102
|
+
return "reserved";
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
/** Remove a reserved entry so Telegram can retry. */
|
|
@@ -88,8 +110,14 @@ export class DedupCache {
|
|
|
88
110
|
}
|
|
89
111
|
}
|
|
90
112
|
|
|
91
|
-
/** Store a response for the given update_id. */
|
|
113
|
+
/** Store a response for the given update_id and advance the high-water mark. */
|
|
92
114
|
set(updateId: number, body: string, status: number): void {
|
|
115
|
+
// Advance monotonic high-water mark so this update_id (and all lower
|
|
116
|
+
// ones) are permanently rejected even after the TTL cache evicts them.
|
|
117
|
+
if (updateId > this.highWaterMark) {
|
|
118
|
+
this.highWaterMark = updateId;
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
// Evict expired entries if we're at capacity
|
|
94
122
|
if (this.cache.size >= this.maxSize) {
|
|
95
123
|
this.evictExpired();
|
|
@@ -118,14 +118,21 @@ export function createRuntimeProxyHandler(config: GatewayConfig) {
|
|
|
118
118
|
controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError"));
|
|
119
119
|
}, config.runtimeTimeoutMs);
|
|
120
120
|
|
|
121
|
+
// Buffer the request body instead of streaming req.body to avoid
|
|
122
|
+
// Content-Length mismatches when Bun re-sends a ReadableStream, which
|
|
123
|
+
// can cause the upstream to reject the request with a bare 400.
|
|
124
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
125
|
+
const bodyBuffer = hasBody ? await req.arrayBuffer() : null;
|
|
126
|
+
if (bodyBuffer !== null) {
|
|
127
|
+
reqHeaders.set("content-length", String(bodyBuffer.byteLength));
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
let response: Response;
|
|
122
131
|
try {
|
|
123
132
|
response = await fetch(upstream, {
|
|
124
133
|
method: req.method,
|
|
125
134
|
headers: reqHeaders,
|
|
126
|
-
body:
|
|
127
|
-
// @ts-expect-error Bun supports duplex on Request
|
|
128
|
-
duplex: "half",
|
|
135
|
+
body: bodyBuffer,
|
|
129
136
|
signal: controller.signal,
|
|
130
137
|
});
|
|
131
138
|
clearTimeout(timeoutId);
|
|
@@ -64,9 +64,9 @@ afterEach(() => {
|
|
|
64
64
|
globalThis.fetch = originalFetch;
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
function mockTwilioApi() {
|
|
67
|
+
function mockTwilioApi(overrides?: Record<string, unknown>) {
|
|
68
68
|
globalThis.fetch = mock(async () => {
|
|
69
|
-
return new Response(JSON.stringify({ sid: "SM-sent" }), {
|
|
69
|
+
return new Response(JSON.stringify({ sid: "SM-sent", status: "queued", error_code: null, error_message: null, ...overrides }), {
|
|
70
70
|
status: 201,
|
|
71
71
|
headers: { "content-type": "application/json" },
|
|
72
72
|
});
|
|
@@ -271,4 +271,156 @@ describe("/deliver/sms", () => {
|
|
|
271
271
|
expect(sentParams.get("To")).toBe("+15559876543");
|
|
272
272
|
expect(sentParams.get("Body")).toBe("Test SMS body");
|
|
273
273
|
});
|
|
274
|
+
|
|
275
|
+
it("uses assistant-specific From number when assistantId mapping exists", async () => {
|
|
276
|
+
const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
|
|
277
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
278
|
+
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
|
279
|
+
fetchCalls.push({ url: urlStr, init: init ?? {} });
|
|
280
|
+
return new Response(JSON.stringify({ sid: "SM-sent" }), {
|
|
281
|
+
status: 201,
|
|
282
|
+
headers: { "content-type": "application/json" },
|
|
283
|
+
});
|
|
284
|
+
}) as unknown as typeof fetch;
|
|
285
|
+
|
|
286
|
+
const handler = createSmsDeliverHandler(
|
|
287
|
+
makeConfig({
|
|
288
|
+
runtimeProxyBearerToken: undefined,
|
|
289
|
+
smsDeliverAuthBypass: true,
|
|
290
|
+
assistantPhoneNumbers: { "ast-alpha": "+15550001111" },
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
const req = makeRequest({
|
|
294
|
+
to: "+15559876543",
|
|
295
|
+
text: "assistant scoped",
|
|
296
|
+
assistantId: "ast-alpha",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const res = await handler(req);
|
|
300
|
+
expect(res.status).toBe(200);
|
|
301
|
+
expect(fetchCalls).toHaveLength(1);
|
|
302
|
+
const sentBody = fetchCalls[0].init.body as string;
|
|
303
|
+
const sentParams = new URLSearchParams(sentBody);
|
|
304
|
+
expect(sentParams.get("From")).toBe("+15550001111");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("falls back to global From number when assistant mapping is missing", async () => {
|
|
308
|
+
const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
|
|
309
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
310
|
+
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
|
311
|
+
fetchCalls.push({ url: urlStr, init: init ?? {} });
|
|
312
|
+
return new Response(JSON.stringify({ sid: "SM-sent" }), {
|
|
313
|
+
status: 201,
|
|
314
|
+
headers: { "content-type": "application/json" },
|
|
315
|
+
});
|
|
316
|
+
}) as unknown as typeof fetch;
|
|
317
|
+
|
|
318
|
+
const handler = createSmsDeliverHandler(
|
|
319
|
+
makeConfig({
|
|
320
|
+
runtimeProxyBearerToken: undefined,
|
|
321
|
+
smsDeliverAuthBypass: true,
|
|
322
|
+
assistantPhoneNumbers: { "ast-beta": "+15550002222" },
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
const req = makeRequest({
|
|
326
|
+
to: "+15559876543",
|
|
327
|
+
text: "fallback",
|
|
328
|
+
assistantId: "ast-alpha",
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const res = await handler(req);
|
|
332
|
+
expect(res.status).toBe(200);
|
|
333
|
+
expect(fetchCalls).toHaveLength(1);
|
|
334
|
+
const sentBody = fetchCalls[0].init.body as string;
|
|
335
|
+
const sentParams = new URLSearchParams(sentBody);
|
|
336
|
+
expect(sentParams.get("From")).toBe("+15551234567");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("attachment-only request (no text) uses fallback text", async () => {
|
|
340
|
+
const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
|
|
341
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
342
|
+
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
|
343
|
+
fetchCalls.push({ url: urlStr, init: init ?? {} });
|
|
344
|
+
return new Response(JSON.stringify({ sid: "SM-sent" }), {
|
|
345
|
+
status: 201,
|
|
346
|
+
headers: { "content-type": "application/json" },
|
|
347
|
+
});
|
|
348
|
+
}) as unknown as typeof fetch;
|
|
349
|
+
|
|
350
|
+
const handler = createSmsDeliverHandler(
|
|
351
|
+
makeConfig({ runtimeProxyBearerToken: undefined, smsDeliverAuthBypass: true }),
|
|
352
|
+
);
|
|
353
|
+
const req = makeRequest({
|
|
354
|
+
to: "+15559876543",
|
|
355
|
+
attachments: [{ url: "https://example.com/image.png" }],
|
|
356
|
+
});
|
|
357
|
+
const res = await handler(req);
|
|
358
|
+
expect(res.status).toBe(200);
|
|
359
|
+
const body = await res.json();
|
|
360
|
+
expect(body.ok).toBe(true);
|
|
361
|
+
|
|
362
|
+
// Verify the Twilio Messages API was called
|
|
363
|
+
expect(fetchCalls).toHaveLength(1);
|
|
364
|
+
expect(fetchCalls[0].url).toContain("AC-test-sid/Messages.json");
|
|
365
|
+
|
|
366
|
+
// Verify the Body parameter contains the fallback text
|
|
367
|
+
const sentBody = fetchCalls[0].init.body as string;
|
|
368
|
+
const sentParams = new URLSearchParams(sentBody);
|
|
369
|
+
expect(sentParams.get("Body")).toBe(
|
|
370
|
+
"I have a media attachment to share, but SMS currently supports text only.",
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("returns enriched Twilio acceptance details in response", async () => {
|
|
375
|
+
mockTwilioApi({ sid: "SM-enrich-test", status: "queued", error_code: null, error_message: null });
|
|
376
|
+
const handler = createSmsDeliverHandler(
|
|
377
|
+
makeConfig({ runtimeProxyBearerToken: undefined, smsDeliverAuthBypass: true }),
|
|
378
|
+
);
|
|
379
|
+
const req = makeRequest({ to: "+15559876543", text: "enriched" });
|
|
380
|
+
const res = await handler(req);
|
|
381
|
+
expect(res.status).toBe(200);
|
|
382
|
+
const body = await res.json();
|
|
383
|
+
expect(body.ok).toBe(true);
|
|
384
|
+
expect(body.messageSid).toBe("SM-enrich-test");
|
|
385
|
+
expect(body.status).toBe("queued");
|
|
386
|
+
expect(body.errorCode).toBeNull();
|
|
387
|
+
expect(body.errorMessage).toBeNull();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns Twilio error details in response when error_code is present", async () => {
|
|
391
|
+
mockTwilioApi({ sid: "SM-err-test", status: "failed", error_code: 30003, error_message: "Unreachable" });
|
|
392
|
+
const handler = createSmsDeliverHandler(
|
|
393
|
+
makeConfig({ runtimeProxyBearerToken: undefined, smsDeliverAuthBypass: true }),
|
|
394
|
+
);
|
|
395
|
+
const req = makeRequest({ to: "+15559876543", text: "fail test" });
|
|
396
|
+
const res = await handler(req);
|
|
397
|
+
expect(res.status).toBe(200);
|
|
398
|
+
const body = await res.json();
|
|
399
|
+
expect(body.ok).toBe(true);
|
|
400
|
+
expect(body.messageSid).toBe("SM-err-test");
|
|
401
|
+
expect(body.status).toBe("failed");
|
|
402
|
+
expect(body.errorCode).toBe("30003");
|
|
403
|
+
expect(body.errorMessage).toBe("Unreachable");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("returns 503 when no From number is available", async () => {
|
|
407
|
+
const handler = createSmsDeliverHandler(
|
|
408
|
+
makeConfig({
|
|
409
|
+
runtimeProxyBearerToken: undefined,
|
|
410
|
+
smsDeliverAuthBypass: true,
|
|
411
|
+
twilioPhoneNumber: undefined,
|
|
412
|
+
assistantPhoneNumbers: undefined,
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
const req = makeRequest({
|
|
416
|
+
to: "+15559876543",
|
|
417
|
+
text: "no from",
|
|
418
|
+
assistantId: "ast-alpha",
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const res = await handler(req);
|
|
422
|
+
expect(res.status).toBe(503);
|
|
423
|
+
const body = await res.json();
|
|
424
|
+
expect(body.error).toBe("SMS integration not configured");
|
|
425
|
+
});
|
|
274
426
|
});
|