@vellumai/vellum-gateway 0.5.11 → 0.5.12
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/package.json +1 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +224 -0
- package/src/credential-watcher.ts +122 -1
- package/src/feature-flag-registry.json +1 -1
- package/src/http/routes/slack-deliver.ts +30 -3
- package/src/http/routes/telegram-webhook.ts +36 -0
- package/src/index.ts +28 -0
- package/src/runtime/client.ts +8 -0
- package/src/schema.ts +1 -1
- package/src/telegram/webhook-manager.ts +15 -1
package/package.json
CHANGED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const TEST_SERVICE_TOKEN = "test-ces-service-token";
|
|
9
|
+
|
|
10
|
+
const testDir = join(tmpdir(), `gw-managed-${Date.now()}-${Math.random()}`);
|
|
11
|
+
|
|
12
|
+
function metadataRecord(
|
|
13
|
+
credentialId: string,
|
|
14
|
+
service: string,
|
|
15
|
+
field: string,
|
|
16
|
+
): Record<string, unknown> {
|
|
17
|
+
return {
|
|
18
|
+
credentialId,
|
|
19
|
+
service,
|
|
20
|
+
field,
|
|
21
|
+
allowedTools: [],
|
|
22
|
+
allowedDomains: [],
|
|
23
|
+
createdAt: Date.now(),
|
|
24
|
+
updatedAt: Date.now(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeCredentialMetadata(
|
|
29
|
+
credentials: Record<string, unknown>[] = [
|
|
30
|
+
metadataRecord("test-bt", "telegram", "bot_token"),
|
|
31
|
+
metadataRecord("test-ws", "telegram", "webhook_secret"),
|
|
32
|
+
],
|
|
33
|
+
): void {
|
|
34
|
+
const dir = join(testDir, ".vellum", "workspace", "data", "credentials");
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
const metadataPath = join(dir, "metadata.json");
|
|
37
|
+
const tmpPath = join(dir, `.tmp-${Date.now()}-metadata.json`);
|
|
38
|
+
writeFileSync(
|
|
39
|
+
tmpPath,
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
version: 2,
|
|
42
|
+
credentials,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
renameSync(tmpPath, metadataPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const gatewayRoot = join(__dirname, "..", "..");
|
|
50
|
+
const gatewayEntry = join(gatewayRoot, "src", "index.ts");
|
|
51
|
+
|
|
52
|
+
let gatewayProc: ChildProcess | null = null;
|
|
53
|
+
let gatewayPort = 0;
|
|
54
|
+
let cesPort = 0;
|
|
55
|
+
let cesServer: ReturnType<typeof Bun.serve> | null = null;
|
|
56
|
+
|
|
57
|
+
function assignPorts(): void {
|
|
58
|
+
if (gatewayPort !== 0 && cesPort !== 0) return;
|
|
59
|
+
gatewayPort = 49152 + Math.floor(Math.random() * 8_192);
|
|
60
|
+
cesPort = gatewayPort + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function startGateway(): Promise<void> {
|
|
64
|
+
assignPorts();
|
|
65
|
+
|
|
66
|
+
gatewayProc = spawn("bun", ["run", gatewayEntry], {
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
BASE_DATA_DIR: testDir,
|
|
70
|
+
GATEWAY_PORT: String(gatewayPort),
|
|
71
|
+
CES_CREDENTIAL_URL: `http://127.0.0.1:${cesPort}`,
|
|
72
|
+
CES_SERVICE_TOKEN: TEST_SERVICE_TOKEN,
|
|
73
|
+
TELEGRAM_BOT_TOKEN: "",
|
|
74
|
+
TELEGRAM_WEBHOOK_SECRET: "",
|
|
75
|
+
},
|
|
76
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const deadline = Date.now() + 5_000;
|
|
80
|
+
while (Date.now() < deadline) {
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`http://localhost:${gatewayPort}/healthz`);
|
|
83
|
+
if (res.ok) return;
|
|
84
|
+
} catch {
|
|
85
|
+
// Gateway not ready yet.
|
|
86
|
+
}
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
88
|
+
}
|
|
89
|
+
throw new Error("Gateway failed to start within 5 seconds");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function startFakeCes(opts: {
|
|
93
|
+
accounts?: string[];
|
|
94
|
+
credentials?: Record<string, string>;
|
|
95
|
+
resolveValue?: (account: string) => string | undefined;
|
|
96
|
+
}): void {
|
|
97
|
+
assignPorts();
|
|
98
|
+
const accounts = opts.accounts ?? Object.keys(opts.credentials ?? {});
|
|
99
|
+
const credentials = opts.credentials ?? {};
|
|
100
|
+
cesServer = Bun.serve({
|
|
101
|
+
port: cesPort,
|
|
102
|
+
fetch(req) {
|
|
103
|
+
const authHeader = req.headers.get("authorization");
|
|
104
|
+
if (authHeader !== `Bearer ${TEST_SERVICE_TOKEN}`) {
|
|
105
|
+
return Response.json(
|
|
106
|
+
{ error: "Invalid service token" },
|
|
107
|
+
{ status: 403 },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const url = new URL(req.url);
|
|
112
|
+
if (req.method === "GET" && url.pathname === "/v1/credentials") {
|
|
113
|
+
return Response.json({ accounts });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (req.method === "GET" && url.pathname.startsWith("/v1/credentials/")) {
|
|
117
|
+
const account = decodeURIComponent(
|
|
118
|
+
url.pathname.slice("/v1/credentials/".length),
|
|
119
|
+
);
|
|
120
|
+
const value = opts.resolveValue?.(account) ?? credentials[account];
|
|
121
|
+
if (!value) {
|
|
122
|
+
return Response.json(
|
|
123
|
+
{ error: "Credential not found", account },
|
|
124
|
+
{ status: 404 },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return Response.json({ account, value });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new Response("Not Found", { status: 404 });
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
cesServer?.stop(true);
|
|
137
|
+
cesServer = null;
|
|
138
|
+
gatewayPort = 0;
|
|
139
|
+
cesPort = 0;
|
|
140
|
+
|
|
141
|
+
if (gatewayProc) {
|
|
142
|
+
gatewayProc.kill();
|
|
143
|
+
gatewayProc = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("gateway managed credential bootstrap retry", () => {
|
|
150
|
+
test("reloads Telegram credentials after CES becomes reachable without a metadata rewrite", async () => {
|
|
151
|
+
mkdirSync(testDir, { recursive: true });
|
|
152
|
+
writeCredentialMetadata();
|
|
153
|
+
|
|
154
|
+
await startGateway();
|
|
155
|
+
|
|
156
|
+
const base = `http://localhost:${gatewayPort}`;
|
|
157
|
+
const before = await fetch(`${base}/webhooks/telegram`, { method: "POST" });
|
|
158
|
+
expect(before.status).toBe(503);
|
|
159
|
+
|
|
160
|
+
startFakeCes({
|
|
161
|
+
credentials: {
|
|
162
|
+
"credential/telegram/bot_token": "fake-bot-token:ABC123",
|
|
163
|
+
"credential/telegram/webhook_secret": "fake-webhook-secret",
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const deadline = Date.now() + 5_000;
|
|
168
|
+
let status = before.status;
|
|
169
|
+
while (Date.now() < deadline) {
|
|
170
|
+
const resp = await fetch(`${base}/webhooks/telegram`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
});
|
|
173
|
+
status = resp.status;
|
|
174
|
+
if (status === 401) break;
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
expect(status).toBe(401);
|
|
179
|
+
}, 15_000);
|
|
180
|
+
|
|
181
|
+
test("keeps retrying until configured credential reads succeed after CES list is already available", async () => {
|
|
182
|
+
mkdirSync(testDir, { recursive: true });
|
|
183
|
+
writeCredentialMetadata();
|
|
184
|
+
|
|
185
|
+
let readsReady = false;
|
|
186
|
+
startFakeCes({
|
|
187
|
+
accounts: [
|
|
188
|
+
"credential/telegram/bot_token",
|
|
189
|
+
"credential/telegram/webhook_secret",
|
|
190
|
+
],
|
|
191
|
+
resolveValue(account) {
|
|
192
|
+
if (!readsReady) return undefined;
|
|
193
|
+
if (account === "credential/telegram/bot_token") {
|
|
194
|
+
return "fake-bot-token:ABC123";
|
|
195
|
+
}
|
|
196
|
+
if (account === "credential/telegram/webhook_secret") {
|
|
197
|
+
return "fake-webhook-secret";
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await startGateway();
|
|
204
|
+
|
|
205
|
+
const base = `http://localhost:${gatewayPort}`;
|
|
206
|
+
const before = await fetch(`${base}/webhooks/telegram`, { method: "POST" });
|
|
207
|
+
expect(before.status).toBe(503);
|
|
208
|
+
|
|
209
|
+
readsReady = true;
|
|
210
|
+
|
|
211
|
+
const deadline = Date.now() + 5_000;
|
|
212
|
+
let status = before.status;
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
const resp = await fetch(`${base}/webhooks/telegram`, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
});
|
|
217
|
+
status = resp.status;
|
|
218
|
+
if (status === 401) break;
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
expect(status).toBe(401);
|
|
223
|
+
}, 15_000);
|
|
224
|
+
});
|
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* causing later credential changes to be missed until restart.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
watch,
|
|
17
|
+
type FSWatcher,
|
|
18
|
+
} from "node:fs";
|
|
13
19
|
import { dirname, join } from "node:path";
|
|
14
20
|
import { getLogger } from "./logger.js";
|
|
15
21
|
import {
|
|
@@ -22,6 +28,8 @@ import {
|
|
|
22
28
|
const log = getLogger("credential-watcher");
|
|
23
29
|
|
|
24
30
|
const DEBOUNCE_MS = 500;
|
|
31
|
+
const MANAGED_BOOTSTRAP_POLL_MS = 1_000;
|
|
32
|
+
const MANAGED_BOOTSTRAP_TIMEOUT_MS = 1_000;
|
|
25
33
|
|
|
26
34
|
export type CredentialChangeEvent = {
|
|
27
35
|
/** Map from service name to resolved credentials (null if unavailable) */
|
|
@@ -35,6 +43,10 @@ export type CredentialChangeCallback = (event: CredentialChangeEvent) => void;
|
|
|
35
43
|
export class CredentialWatcher {
|
|
36
44
|
private watchers: FSWatcher[] = [];
|
|
37
45
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
46
|
+
private managedBootstrapTimer: ReturnType<typeof setInterval> | null = null;
|
|
47
|
+
private managedBootstrapPollInFlight = false;
|
|
48
|
+
private lastConfiguredServices = new Set<string>();
|
|
49
|
+
private lastReadyServices = new Set<string>();
|
|
38
50
|
private lastSerialized: Map<string, string> = new Map();
|
|
39
51
|
private polling = false;
|
|
40
52
|
private pendingPoll = false;
|
|
@@ -70,6 +82,8 @@ export class CredentialWatcher {
|
|
|
70
82
|
// but the encrypted ciphertext will (new IV). Force a full reload so
|
|
71
83
|
// channel listeners restart even when the plaintext values match.
|
|
72
84
|
this.startWatcher(protectedDir, "keys.enc", { forceChanged: true });
|
|
85
|
+
|
|
86
|
+
this.startManagedBootstrapRetry();
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
private startWatcher(
|
|
@@ -101,6 +115,11 @@ export class CredentialWatcher {
|
|
|
101
115
|
clearTimeout(this.debounceTimer);
|
|
102
116
|
this.debounceTimer = null;
|
|
103
117
|
}
|
|
118
|
+
if (this.managedBootstrapTimer) {
|
|
119
|
+
clearInterval(this.managedBootstrapTimer);
|
|
120
|
+
this.managedBootstrapTimer = null;
|
|
121
|
+
}
|
|
122
|
+
this.managedBootstrapPollInFlight = false;
|
|
104
123
|
this.pendingPoll = false;
|
|
105
124
|
for (const watcher of this.watchers) {
|
|
106
125
|
watcher.close();
|
|
@@ -108,6 +127,63 @@ export class CredentialWatcher {
|
|
|
108
127
|
this.watchers = [];
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
private startManagedBootstrapRetry(): void {
|
|
131
|
+
const baseUrl = process.env.CES_CREDENTIAL_URL?.trim();
|
|
132
|
+
const serviceToken = process.env.CES_SERVICE_TOKEN?.trim();
|
|
133
|
+
if (!baseUrl || !serviceToken) return;
|
|
134
|
+
|
|
135
|
+
const poll = (): void => {
|
|
136
|
+
void this.pollManagedBootstrap(baseUrl, serviceToken);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
this.managedBootstrapTimer = setInterval(poll, MANAGED_BOOTSTRAP_POLL_MS);
|
|
140
|
+
this.managedBootstrapTimer.unref?.();
|
|
141
|
+
poll();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async pollManagedBootstrap(
|
|
145
|
+
baseUrl: string,
|
|
146
|
+
serviceToken: string,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
if (this.managedBootstrapPollInFlight) return;
|
|
149
|
+
this.managedBootstrapPollInFlight = true;
|
|
150
|
+
try {
|
|
151
|
+
const resp = await fetch(`${baseUrl}/v1/credentials`, {
|
|
152
|
+
method: "GET",
|
|
153
|
+
headers: {
|
|
154
|
+
Authorization: `Bearer ${serviceToken}`,
|
|
155
|
+
Accept: "application/json",
|
|
156
|
+
},
|
|
157
|
+
signal: AbortSignal.timeout(MANAGED_BOOTSTRAP_TIMEOUT_MS),
|
|
158
|
+
});
|
|
159
|
+
if (resp.status === 401 || resp.status === 403 || resp.status === 404) {
|
|
160
|
+
if (this.managedBootstrapTimer) {
|
|
161
|
+
clearInterval(this.managedBootstrapTimer);
|
|
162
|
+
this.managedBootstrapTimer = null;
|
|
163
|
+
}
|
|
164
|
+
log.warn(
|
|
165
|
+
{ status: resp.status },
|
|
166
|
+
"Stopping managed credential bootstrap retry due to non-retryable CES response",
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!resp.ok) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await this.pollOnce();
|
|
175
|
+
|
|
176
|
+
if (this.allConfiguredServicesReady() && this.managedBootstrapTimer) {
|
|
177
|
+
clearInterval(this.managedBootstrapTimer);
|
|
178
|
+
this.managedBootstrapTimer = null;
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// CES isn't reachable yet. Keep retrying until the sidecar is ready.
|
|
182
|
+
} finally {
|
|
183
|
+
this.managedBootstrapPollInFlight = false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
111
187
|
/** Whether the next scheduled poll should treat all services as changed. */
|
|
112
188
|
private pendingForceChanged = false;
|
|
113
189
|
|
|
@@ -135,9 +211,16 @@ export class CredentialWatcher {
|
|
|
135
211
|
this.polling = true;
|
|
136
212
|
try {
|
|
137
213
|
const credentials = new Map<string, Record<string, string> | null>();
|
|
214
|
+
const configuredServices = this.loadConfiguredServices();
|
|
138
215
|
for (const spec of ALL_CREDENTIAL_SPECS) {
|
|
139
216
|
credentials.set(spec.service, await readServiceCredentials(spec));
|
|
140
217
|
}
|
|
218
|
+
this.lastConfiguredServices = configuredServices;
|
|
219
|
+
this.lastReadyServices = new Set(
|
|
220
|
+
[...credentials.entries()]
|
|
221
|
+
.filter(([, creds]) => creds !== null)
|
|
222
|
+
.map(([service]) => service),
|
|
223
|
+
);
|
|
141
224
|
|
|
142
225
|
const changedServices = new Set<string>();
|
|
143
226
|
for (const [service, creds] of credentials) {
|
|
@@ -166,4 +249,42 @@ export class CredentialWatcher {
|
|
|
166
249
|
}
|
|
167
250
|
}
|
|
168
251
|
}
|
|
252
|
+
|
|
253
|
+
private loadConfiguredServices(): Set<string> {
|
|
254
|
+
if (!existsSync(this.metadataPath)) return new Set();
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const raw = readFileSync(this.metadataPath, "utf-8");
|
|
258
|
+
const data = JSON.parse(raw) as {
|
|
259
|
+
credentials?: Array<{ service?: string; field?: string }>;
|
|
260
|
+
};
|
|
261
|
+
if (!Array.isArray(data.credentials)) return new Set();
|
|
262
|
+
|
|
263
|
+
const configured = new Set<string>();
|
|
264
|
+
for (const spec of ALL_CREDENTIAL_SPECS) {
|
|
265
|
+
const hasAllRequiredFields = spec.requiredFields.every((field) =>
|
|
266
|
+
data.credentials?.some(
|
|
267
|
+
(credential) =>
|
|
268
|
+
credential.service === spec.service && credential.field === field,
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
if (hasAllRequiredFields) {
|
|
272
|
+
configured.add(spec.service);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return configured;
|
|
277
|
+
} catch {
|
|
278
|
+
return new Set();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private allConfiguredServicesReady(): boolean {
|
|
283
|
+
for (const service of this.lastConfiguredServices) {
|
|
284
|
+
if (!this.lastReadyServices.has(service)) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
169
290
|
}
|
|
@@ -255,7 +255,7 @@
|
|
|
255
255
|
"key": "managed-google-oauth",
|
|
256
256
|
"label": "Managed Google OAuth",
|
|
257
257
|
"description": "Show the Google OAuth service card in Models & Services settings",
|
|
258
|
-
"defaultEnabled":
|
|
258
|
+
"defaultEnabled": true
|
|
259
259
|
},
|
|
260
260
|
{
|
|
261
261
|
"id": "settings-embedding-provider",
|
|
@@ -330,6 +330,10 @@ export function createSlackDeliverHandler(
|
|
|
330
330
|
config: GatewayConfig,
|
|
331
331
|
onThreadReply?: (threadTs: string) => void,
|
|
332
332
|
caches?: { credentials?: CredentialCache; configFile?: ConfigFileCache },
|
|
333
|
+
pendingApprovalReplacements?: Map<
|
|
334
|
+
string,
|
|
335
|
+
{ messageTs: string; expiresAt: number }
|
|
336
|
+
>,
|
|
333
337
|
) {
|
|
334
338
|
return async (req: Request): Promise<Response> => {
|
|
335
339
|
const traceId = req.headers.get("x-trace-id") ?? undefined;
|
|
@@ -541,8 +545,31 @@ export function createSlackDeliverHandler(
|
|
|
541
545
|
|
|
542
546
|
// Support threading via query param
|
|
543
547
|
const threadTs = new URL(req.url).searchParams.get("threadTs") ?? undefined;
|
|
544
|
-
|
|
545
|
-
|
|
548
|
+
let messageTs = body.messageTs ?? updateTs;
|
|
549
|
+
let isUpdate = typeof messageTs === "string" && messageTs.length > 0;
|
|
550
|
+
|
|
551
|
+
// Check for pending approval message replacement: if this is a new message
|
|
552
|
+
// (not already an update) to a thread with a pending approval replacement,
|
|
553
|
+
// convert it to an update of the approval message so the follow-up content
|
|
554
|
+
// replaces the original approval prompt.
|
|
555
|
+
let isApprovalReplacement = false;
|
|
556
|
+
if (threadTs && !isUpdate && !isEphemeral && !chatAction && text) {
|
|
557
|
+
const replacementKey = `${chatId}:${threadTs}`;
|
|
558
|
+
const pending = pendingApprovalReplacements?.get(replacementKey);
|
|
559
|
+
if (pending && pending.expiresAt > Date.now()) {
|
|
560
|
+
messageTs = pending.messageTs;
|
|
561
|
+
isUpdate = true;
|
|
562
|
+
isApprovalReplacement = true;
|
|
563
|
+
pendingApprovalReplacements!.delete(replacementKey);
|
|
564
|
+
tlog.info(
|
|
565
|
+
{ chatId, threadTs, approvalMessageTs: messageTs },
|
|
566
|
+
"Converting delivery to approval message replacement",
|
|
567
|
+
);
|
|
568
|
+
} else if (pending) {
|
|
569
|
+
// Expired — clean up stale entry
|
|
570
|
+
pendingApprovalReplacements!.delete(replacementKey);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
546
573
|
|
|
547
574
|
// Resolve Block Kit blocks: use provided blocks, approval prompt, or auto-format text
|
|
548
575
|
const blocks: Block[] =
|
|
@@ -804,7 +831,7 @@ export function createSlackDeliverHandler(
|
|
|
804
831
|
text,
|
|
805
832
|
ts: messageTs,
|
|
806
833
|
};
|
|
807
|
-
if (blocks.length > 0) {
|
|
834
|
+
if (blocks.length > 0 || isApprovalReplacement) {
|
|
808
835
|
updateBody.blocks = blocks;
|
|
809
836
|
}
|
|
810
837
|
result = await callSlackApiWithRetries(
|
|
@@ -436,6 +436,27 @@ export function createTelegramWebhookHandler(
|
|
|
436
436
|
});
|
|
437
437
|
} else {
|
|
438
438
|
tlog.info({ status: "forwarded" }, "Forwarded /start to runtime");
|
|
439
|
+
|
|
440
|
+
// Fallback: if the runtime denied the message and could not
|
|
441
|
+
// deliver the rejection reply via callback, send it directly.
|
|
442
|
+
const startRuntimeResp = result.runtimeResponse;
|
|
443
|
+
if (startRuntimeResp?.denied && startRuntimeResp.replyText) {
|
|
444
|
+
sendTelegramReply(
|
|
445
|
+
config,
|
|
446
|
+
normalized.message.conversationExternalId,
|
|
447
|
+
startRuntimeResp.replyText,
|
|
448
|
+
undefined,
|
|
449
|
+
{
|
|
450
|
+
credentials: caches?.credentials,
|
|
451
|
+
configFile: caches?.configFile,
|
|
452
|
+
},
|
|
453
|
+
).catch((err) => {
|
|
454
|
+
tlog.error(
|
|
455
|
+
{ err, chatId: normalized.message.conversationExternalId },
|
|
456
|
+
"Failed to send ACL denial fallback reply",
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
439
460
|
}
|
|
440
461
|
} catch (err) {
|
|
441
462
|
if (err instanceof CircuitBreakerOpenError) {
|
|
@@ -703,6 +724,21 @@ export function createTelegramWebhookHandler(
|
|
|
703
724
|
|
|
704
725
|
tlog.info({ status: "forwarded" }, "Forwarded to runtime");
|
|
705
726
|
|
|
727
|
+
// Fallback: if the runtime denied the message and could not
|
|
728
|
+
// deliver the rejection reply via callback, send it directly.
|
|
729
|
+
const runtimeResp = result.runtimeResponse;
|
|
730
|
+
if (runtimeResp?.denied && runtimeResp.replyText) {
|
|
731
|
+
sendTelegramReply(config, chatId, runtimeResp.replyText, undefined, {
|
|
732
|
+
credentials: caches?.credentials,
|
|
733
|
+
configFile: caches?.configFile,
|
|
734
|
+
}).catch((err) => {
|
|
735
|
+
tlog.error(
|
|
736
|
+
{ err, chatId },
|
|
737
|
+
"Failed to send ACL denial fallback reply",
|
|
738
|
+
);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
706
742
|
// Acknowledge the callback query to clear the button spinner in the
|
|
707
743
|
// Telegram client. Best-effort — log errors but don't fail the flow.
|
|
708
744
|
if (isCallback)
|
package/src/index.ts
CHANGED
|
@@ -241,12 +241,28 @@ async function main() {
|
|
|
241
241
|
credentials: credentialCache,
|
|
242
242
|
configFile: configFileCache,
|
|
243
243
|
});
|
|
244
|
+
// Map: "channel:threadTs" -> { messageTs, expiresAt } for replacing approval
|
|
245
|
+
// messages with the bot's follow-up content after an approval button click.
|
|
246
|
+
const pendingApprovalReplacements = new Map<
|
|
247
|
+
string,
|
|
248
|
+
{ messageTs: string; expiresAt: number }
|
|
249
|
+
>();
|
|
250
|
+
|
|
251
|
+
// Clean up expired entries every 60s
|
|
252
|
+
setInterval(() => {
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
for (const [key, entry] of pendingApprovalReplacements) {
|
|
255
|
+
if (entry.expiresAt <= now) pendingApprovalReplacements.delete(key);
|
|
256
|
+
}
|
|
257
|
+
}, 60_000);
|
|
258
|
+
|
|
244
259
|
const handleSlackDeliver = createSlackDeliverHandler(
|
|
245
260
|
config,
|
|
246
261
|
(threadTs) => {
|
|
247
262
|
slackSocketClient?.trackThread(threadTs);
|
|
248
263
|
},
|
|
249
264
|
{ credentials: credentialCache, configFile: configFileCache },
|
|
265
|
+
pendingApprovalReplacements,
|
|
250
266
|
);
|
|
251
267
|
const handleOAuthCallback = createOAuthCallbackHandler(config);
|
|
252
268
|
const pairingProxy = createPairingProxyHandler(config);
|
|
@@ -1193,6 +1209,18 @@ async function main() {
|
|
|
1193
1209
|
} else {
|
|
1194
1210
|
forward();
|
|
1195
1211
|
}
|
|
1212
|
+
|
|
1213
|
+
// When an approval button is clicked, store the approval message ts
|
|
1214
|
+
// so the next outbound delivery to this thread replaces the approval
|
|
1215
|
+
// message instead of posting a new one.
|
|
1216
|
+
const callbackData = normalized.event.message.callbackData;
|
|
1217
|
+
if (callbackData?.startsWith("apr:") && messageTs && threadTs) {
|
|
1218
|
+
const key = `${channel}:${threadTs}`;
|
|
1219
|
+
pendingApprovalReplacements.set(key, {
|
|
1220
|
+
messageTs,
|
|
1221
|
+
expiresAt: Date.now() + 60_000, // 60s TTL
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1196
1224
|
},
|
|
1197
1225
|
);
|
|
1198
1226
|
|
package/src/runtime/client.ts
CHANGED
|
@@ -179,6 +179,14 @@ export type RuntimeInboundResponse = {
|
|
|
179
179
|
timestamp: string;
|
|
180
180
|
attachments: RuntimeAttachmentMeta[];
|
|
181
181
|
};
|
|
182
|
+
/** When true, the runtime denied the inbound message (e.g. ACL rejection). */
|
|
183
|
+
denied?: boolean;
|
|
184
|
+
/**
|
|
185
|
+
* A user-facing rejection message that the runtime could not deliver via
|
|
186
|
+
* the callback URL (e.g. due to auth failure). When present, the gateway
|
|
187
|
+
* should deliver it directly to the channel.
|
|
188
|
+
*/
|
|
189
|
+
replyText?: string;
|
|
182
190
|
};
|
|
183
191
|
|
|
184
192
|
export type ForwardOptions = {
|
package/src/schema.ts
CHANGED
|
@@ -1738,7 +1738,7 @@ export function buildSchema(): Record<string, unknown> {
|
|
|
1738
1738
|
required: true,
|
|
1739
1739
|
schema: { type: "string" },
|
|
1740
1740
|
description:
|
|
1741
|
-
"OAuth provider key to filter by, for example `
|
|
1741
|
+
"OAuth provider key to filter by, for example `google`.",
|
|
1742
1742
|
},
|
|
1743
1743
|
],
|
|
1744
1744
|
security: [{ BearerAuth: [] }],
|
|
@@ -148,7 +148,21 @@ export async function reconcileTelegramWebhook(
|
|
|
148
148
|
return;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
let expectedUrl: string | undefined;
|
|
152
|
+
try {
|
|
153
|
+
expectedUrl = await resolveExpectedTelegramWebhookUrl(caches);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// Managed callback route registration failed — this is a platform-side
|
|
156
|
+
// issue. Do not suggest ngrok or other tunnel options; they are not
|
|
157
|
+
// usable in containerized deployments.
|
|
158
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
159
|
+
log.error(
|
|
160
|
+
{ err },
|
|
161
|
+
`Telegram webhook registration failed: managed platform callback route could not be registered. ` +
|
|
162
|
+
`Please contact support. (${detail})`,
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
152
166
|
if (!expectedUrl) {
|
|
153
167
|
log.debug(
|
|
154
168
|
"Skipping webhook reconciliation: no public ingress or managed callback route available",
|