@vellumai/vellum-gateway 0.1.7
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/.dockerignore +7 -0
- package/.env.example +59 -0
- package/Dockerfile +44 -0
- package/README.md +186 -0
- package/bun.lock +391 -0
- package/eslint.config.mjs +23 -0
- package/knip.json +8 -0
- package/package.json +27 -0
- package/src/__tests__/bearer-auth.test.ts +40 -0
- package/src/__tests__/config.test.ts +236 -0
- package/src/__tests__/dedup-cache.test.ts +101 -0
- package/src/__tests__/load-guards.test.ts +86 -0
- package/src/__tests__/probes.test.ts +94 -0
- package/src/__tests__/reply-path.test.ts +51 -0
- package/src/__tests__/resolve-assistant.test.ts +118 -0
- package/src/__tests__/runtime-client.test.ts +228 -0
- package/src/__tests__/runtime-proxy-auth.test.ts +127 -0
- package/src/__tests__/runtime-proxy.test.ts +262 -0
- package/src/__tests__/schema.test.ts +128 -0
- package/src/__tests__/telegram-normalize.test.ts +303 -0
- package/src/__tests__/telegram-only-default.test.ts +134 -0
- package/src/__tests__/telegram-send-attachments.test.ts +185 -0
- package/src/cli/schema.ts +8 -0
- package/src/config.ts +254 -0
- package/src/dedup-cache.ts +104 -0
- package/src/handlers/handle-inbound.ts +104 -0
- package/src/http/auth/bearer.ts +34 -0
- package/src/http/routes/runtime-proxy.ts +143 -0
- package/src/http/routes/telegram-webhook.ts +272 -0
- package/src/index.ts +117 -0
- package/src/logger.ts +103 -0
- package/src/routing/resolve-assistant.ts +45 -0
- package/src/routing/types.ts +11 -0
- package/src/runtime/client.ts +212 -0
- package/src/schema.ts +383 -0
- package/src/telegram/api.ts +153 -0
- package/src/telegram/download.ts +63 -0
- package/src/telegram/normalize.ts +118 -0
- package/src/telegram/send.ts +107 -0
- package/src/telegram/verify.ts +17 -0
- package/src/types.ts +37 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { GatewayConfig } from "../config.js";
|
|
2
|
+
import { getLogger } from "../logger.js";
|
|
3
|
+
|
|
4
|
+
const log = getLogger("telegram-api");
|
|
5
|
+
|
|
6
|
+
interface TelegramApiResponse<T> {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
result?: T;
|
|
9
|
+
description?: string;
|
|
10
|
+
parameters?: { retry_after?: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isRetryable(status: number): boolean {
|
|
14
|
+
return status === 429 || status >= 500;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function computeDelay(
|
|
18
|
+
attempt: number,
|
|
19
|
+
initialBackoffMs: number,
|
|
20
|
+
retryAfterHeader: string | null,
|
|
21
|
+
): number {
|
|
22
|
+
if (retryAfterHeader) {
|
|
23
|
+
// Try parsing as numeric seconds first (e.g., "120")
|
|
24
|
+
const seconds = Number(retryAfterHeader);
|
|
25
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
26
|
+
// Clamp to max 32-bit signed int to prevent setTimeout overflow
|
|
27
|
+
return Math.min(seconds * 1000, 2_147_483_647);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fall back to HTTP-date format (e.g., "Fri, 31 Dec 1999 23:59:59 GMT")
|
|
31
|
+
const targetTime = new Date(retryAfterHeader).getTime();
|
|
32
|
+
if (Number.isFinite(targetTime)) {
|
|
33
|
+
const delayMs = targetTime - Date.now();
|
|
34
|
+
if (delayMs > 0) {
|
|
35
|
+
// Clamp to max 32-bit signed int to prevent setTimeout overflow
|
|
36
|
+
return Math.min(delayMs, 2_147_483_647);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const exponential = initialBackoffMs * Math.pow(2, attempt - 1);
|
|
42
|
+
// Add jitter: 0–50% of the computed delay
|
|
43
|
+
const jitter = Math.random() * exponential * 0.5;
|
|
44
|
+
return exponential + jitter;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function retryableFetch<T>(
|
|
48
|
+
config: GatewayConfig,
|
|
49
|
+
method: string,
|
|
50
|
+
doFetch: () => Promise<Response>,
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
let lastError: Error | null = null;
|
|
53
|
+
let lastRetryAfter: string | null = null;
|
|
54
|
+
|
|
55
|
+
for (let attempt = 0; attempt <= config.telegramMaxRetries; attempt++) {
|
|
56
|
+
if (attempt > 0) {
|
|
57
|
+
const delay = computeDelay(
|
|
58
|
+
attempt,
|
|
59
|
+
config.telegramInitialBackoffMs,
|
|
60
|
+
lastRetryAfter,
|
|
61
|
+
);
|
|
62
|
+
log.debug({ attempt, delay, method }, "Retrying Telegram API call");
|
|
63
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lastRetryAfter = null;
|
|
67
|
+
|
|
68
|
+
let response: Response;
|
|
69
|
+
try {
|
|
70
|
+
response = await doFetch();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
73
|
+
log.warn({ err: lastError, attempt, method }, "Telegram API fetch failed");
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!isRetryable(response.status) && !response.ok) {
|
|
78
|
+
const data = (await response.json().catch(() => ({}))) as TelegramApiResponse<T>;
|
|
79
|
+
throw new Error(
|
|
80
|
+
data.description
|
|
81
|
+
? `Telegram ${method} failed: ${data.description}`
|
|
82
|
+
: `Telegram ${method} failed with status ${response.status}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isRetryable(response.status)) {
|
|
87
|
+
const data = (await response.json().catch(() => ({}))) as TelegramApiResponse<T>;
|
|
88
|
+
lastRetryAfter =
|
|
89
|
+
response.headers.get("retry-after") ??
|
|
90
|
+
(data.parameters?.retry_after != null
|
|
91
|
+
? String(data.parameters.retry_after)
|
|
92
|
+
: null);
|
|
93
|
+
lastError = new Error(
|
|
94
|
+
data.description
|
|
95
|
+
? `Telegram ${method} failed: ${data.description}`
|
|
96
|
+
: `Telegram ${method} failed with status ${response.status}`,
|
|
97
|
+
);
|
|
98
|
+
log.warn(
|
|
99
|
+
{ status: response.status, attempt, method, retryAfter: lastRetryAfter },
|
|
100
|
+
"Telegram API returned retryable error",
|
|
101
|
+
);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const data = (await response.json().catch(() => ({}))) as TelegramApiResponse<T>;
|
|
106
|
+
if (!data.ok || data.result === undefined) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
data.description
|
|
109
|
+
? `Telegram ${method} failed: ${data.description}`
|
|
110
|
+
: `Telegram ${method} failed with status ${response.status}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return data.result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw lastError ?? new Error(`Telegram ${method} failed after retries`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function callTelegramApi<T>(
|
|
121
|
+
config: GatewayConfig,
|
|
122
|
+
method: string,
|
|
123
|
+
body: Record<string, unknown>,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
return retryableFetch<T>(config, method, () =>
|
|
126
|
+
fetch(
|
|
127
|
+
`${config.telegramApiBaseUrl}/bot${config.telegramBotToken}/${method}`,
|
|
128
|
+
{
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "Content-Type": "application/json" },
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal: AbortSignal.timeout(config.telegramTimeoutMs),
|
|
133
|
+
},
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function callTelegramApiMultipart<T>(
|
|
139
|
+
config: GatewayConfig,
|
|
140
|
+
method: string,
|
|
141
|
+
form: FormData,
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
return retryableFetch<T>(config, method, () =>
|
|
144
|
+
fetch(
|
|
145
|
+
`${config.telegramApiBaseUrl}/bot${config.telegramBotToken}/${method}`,
|
|
146
|
+
{
|
|
147
|
+
method: "POST",
|
|
148
|
+
body: form,
|
|
149
|
+
signal: AbortSignal.timeout(config.telegramTimeoutMs),
|
|
150
|
+
},
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
2
|
+
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { callTelegramApi } from "./api.js";
|
|
4
|
+
|
|
5
|
+
interface TelegramFile {
|
|
6
|
+
file_id: string;
|
|
7
|
+
file_unique_id: string;
|
|
8
|
+
file_size?: number;
|
|
9
|
+
file_path?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DownloadedFile {
|
|
13
|
+
filename: string;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
data: string; // base64-encoded
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Download a file from Telegram by its file_id.
|
|
20
|
+
* Calls the getFile API to resolve the file path, then fetches the binary.
|
|
21
|
+
*/
|
|
22
|
+
export async function downloadTelegramFile(
|
|
23
|
+
config: GatewayConfig,
|
|
24
|
+
fileId: string,
|
|
25
|
+
hint?: { fileName?: string; mimeType?: string },
|
|
26
|
+
): Promise<DownloadedFile> {
|
|
27
|
+
const file = await callTelegramApi<TelegramFile>(config, "getFile", {
|
|
28
|
+
file_id: fileId,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!file.file_path) {
|
|
32
|
+
throw new Error(`Telegram getFile returned no file_path for ${fileId}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const downloadUrl = `${config.telegramApiBaseUrl}/file/bot${config.telegramBotToken}/${file.file_path}`;
|
|
36
|
+
const response = await fetch(downloadUrl, {
|
|
37
|
+
signal: AbortSignal.timeout(config.telegramTimeoutMs),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Failed to download Telegram file: ${response.status} ${response.statusText}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const filename =
|
|
47
|
+
hint?.fileName ||
|
|
48
|
+
file.file_path.split("/").pop() ||
|
|
49
|
+
`file_${fileId}`;
|
|
50
|
+
|
|
51
|
+
const buffer = await response.arrayBuffer();
|
|
52
|
+
const detected = await fileTypeFromBuffer(new Uint8Array(buffer));
|
|
53
|
+
|
|
54
|
+
const mimeType =
|
|
55
|
+
hint?.mimeType ||
|
|
56
|
+
detected?.mime ||
|
|
57
|
+
response.headers.get("Content-Type")?.split(";")[0].trim() ||
|
|
58
|
+
"application/octet-stream";
|
|
59
|
+
|
|
60
|
+
const data = Buffer.from(buffer).toString("base64");
|
|
61
|
+
|
|
62
|
+
return { filename, mimeType, data };
|
|
63
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { GatewayInboundEventV1 } from "../types.js";
|
|
2
|
+
|
|
3
|
+
interface TelegramPhotoSize {
|
|
4
|
+
file_id: string;
|
|
5
|
+
file_unique_id: string;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
file_size?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TelegramDocument {
|
|
12
|
+
file_id: string;
|
|
13
|
+
file_unique_id: string;
|
|
14
|
+
file_name?: string;
|
|
15
|
+
mime_type?: string;
|
|
16
|
+
file_size?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TelegramMessage {
|
|
20
|
+
message_id?: number;
|
|
21
|
+
text?: string;
|
|
22
|
+
caption?: string;
|
|
23
|
+
chat?: { id?: number; type?: string };
|
|
24
|
+
from?: {
|
|
25
|
+
id?: number;
|
|
26
|
+
is_bot?: boolean;
|
|
27
|
+
username?: string;
|
|
28
|
+
first_name?: string;
|
|
29
|
+
last_name?: string;
|
|
30
|
+
language_code?: string;
|
|
31
|
+
};
|
|
32
|
+
photo?: TelegramPhotoSize[];
|
|
33
|
+
document?: TelegramDocument;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TelegramUpdate {
|
|
37
|
+
update_id?: number;
|
|
38
|
+
message?: TelegramMessage;
|
|
39
|
+
edited_message?: TelegramMessage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a Telegram webhook payload into a GatewayInboundEventV1.
|
|
44
|
+
* Returns null if the payload is unsupported (non-text, non-private, etc.).
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeTelegramUpdate(
|
|
47
|
+
payload: Record<string, unknown>,
|
|
48
|
+
): Omit<GatewayInboundEventV1, "routing"> | null {
|
|
49
|
+
const update = payload as TelegramUpdate;
|
|
50
|
+
const isEdit = !update.message && !!update.edited_message;
|
|
51
|
+
const message = update.message ?? update.edited_message;
|
|
52
|
+
const updateId = update.update_id;
|
|
53
|
+
|
|
54
|
+
const hasContent = !!(message?.text || message?.photo || message?.document);
|
|
55
|
+
if (!hasContent || !message?.chat?.id || updateId == null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// v1 is DM-only
|
|
60
|
+
if (message.chat.type !== "private") {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const externalUserId = message.from?.id
|
|
65
|
+
? String(message.from.id)
|
|
66
|
+
: String(message.chat.id);
|
|
67
|
+
|
|
68
|
+
const displayName = [message.from?.first_name, message.from?.last_name]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join(" ")
|
|
71
|
+
.trim();
|
|
72
|
+
|
|
73
|
+
const content = message.text || message.caption || "";
|
|
74
|
+
|
|
75
|
+
const attachments: { type: "photo" | "document"; fileId: string; fileName?: string; mimeType?: string; fileSize?: number }[] = [];
|
|
76
|
+
if (message.photo && message.photo.length > 0) {
|
|
77
|
+
// Telegram sends multiple sizes; pick the largest (last in array)
|
|
78
|
+
const largest = message.photo[message.photo.length - 1];
|
|
79
|
+
attachments.push({ type: "photo", fileId: largest.file_id, fileSize: largest.file_size });
|
|
80
|
+
}
|
|
81
|
+
if (message.document) {
|
|
82
|
+
attachments.push({
|
|
83
|
+
type: "document",
|
|
84
|
+
fileId: message.document.file_id,
|
|
85
|
+
fileName: message.document.file_name,
|
|
86
|
+
mimeType: message.document.mime_type,
|
|
87
|
+
fileSize: message.document.file_size,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
version: "v1",
|
|
93
|
+
sourceChannel: "telegram",
|
|
94
|
+
receivedAt: new Date().toISOString(),
|
|
95
|
+
message: {
|
|
96
|
+
content,
|
|
97
|
+
externalChatId: String(message.chat.id),
|
|
98
|
+
externalMessageId: String(updateId),
|
|
99
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
100
|
+
...(isEdit ? { isEdit: true } : {}),
|
|
101
|
+
},
|
|
102
|
+
sender: {
|
|
103
|
+
externalUserId,
|
|
104
|
+
username: message.from?.username,
|
|
105
|
+
displayName: displayName || undefined,
|
|
106
|
+
firstName: message.from?.first_name,
|
|
107
|
+
lastName: message.from?.last_name,
|
|
108
|
+
languageCode: message.from?.language_code,
|
|
109
|
+
isBot: message.from?.is_bot,
|
|
110
|
+
},
|
|
111
|
+
source: {
|
|
112
|
+
updateId: String(updateId),
|
|
113
|
+
messageId: message.message_id != null ? String(message.message_id) : undefined,
|
|
114
|
+
chatType: message.chat.type,
|
|
115
|
+
},
|
|
116
|
+
raw: payload,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { GatewayConfig } from "../config.js";
|
|
2
|
+
import { getLogger } from "../logger.js";
|
|
3
|
+
import { downloadAttachment, type RuntimeAttachmentMeta } from "../runtime/client.js";
|
|
4
|
+
import { callTelegramApi, callTelegramApiMultipart } from "./api.js";
|
|
5
|
+
|
|
6
|
+
const log = getLogger("telegram-send");
|
|
7
|
+
|
|
8
|
+
const TELEGRAM_MAX_MESSAGE_LEN = 4000;
|
|
9
|
+
|
|
10
|
+
const IMAGE_MIME_PREFIXES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
11
|
+
|
|
12
|
+
function splitText(text: string): string[] {
|
|
13
|
+
if (text.length <= TELEGRAM_MAX_MESSAGE_LEN) {
|
|
14
|
+
return [text];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const chunks: string[] = [];
|
|
18
|
+
let cursor = 0;
|
|
19
|
+
while (cursor < text.length) {
|
|
20
|
+
let end = Math.min(cursor + TELEGRAM_MAX_MESSAGE_LEN, text.length);
|
|
21
|
+
// Avoid splitting a surrogate pair
|
|
22
|
+
if (end < text.length && text.charCodeAt(end - 1) >= 0xd800 && text.charCodeAt(end - 1) <= 0xdbff) {
|
|
23
|
+
end--;
|
|
24
|
+
}
|
|
25
|
+
chunks.push(text.slice(cursor, end));
|
|
26
|
+
cursor = end;
|
|
27
|
+
}
|
|
28
|
+
return chunks;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function sendTelegramReply(
|
|
32
|
+
config: GatewayConfig,
|
|
33
|
+
chatId: string,
|
|
34
|
+
text: string,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const chunks = splitText(text);
|
|
37
|
+
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
await callTelegramApi(config, "sendMessage", {
|
|
40
|
+
chat_id: chatId,
|
|
41
|
+
text: chunk,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
log.debug({ chatId, chunks: chunks.length }, "Telegram reply sent");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function sendTelegramAttachments(
|
|
49
|
+
config: GatewayConfig,
|
|
50
|
+
chatId: string,
|
|
51
|
+
assistantId: string,
|
|
52
|
+
attachments: RuntimeAttachmentMeta[],
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const failures: string[] = [];
|
|
55
|
+
|
|
56
|
+
for (const meta of attachments) {
|
|
57
|
+
if (meta.sizeBytes > config.maxAttachmentBytes) {
|
|
58
|
+
log.warn({ attachmentId: meta.id, sizeBytes: meta.sizeBytes }, "Skipping oversized outbound attachment");
|
|
59
|
+
failures.push(meta.filename);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const payload = await downloadAttachment(config, assistantId, meta.id);
|
|
65
|
+
const buffer = Buffer.from(payload.data, "base64");
|
|
66
|
+
const blob = new Blob([buffer], { type: meta.mimeType });
|
|
67
|
+
|
|
68
|
+
const form = new FormData();
|
|
69
|
+
form.set("chat_id", chatId);
|
|
70
|
+
|
|
71
|
+
const isImage = IMAGE_MIME_PREFIXES.some((p) => meta.mimeType.startsWith(p));
|
|
72
|
+
if (isImage) {
|
|
73
|
+
form.set("photo", blob, meta.filename);
|
|
74
|
+
await callTelegramApiMultipart(config, "sendPhoto", form);
|
|
75
|
+
} else {
|
|
76
|
+
form.set("document", blob, meta.filename);
|
|
77
|
+
await callTelegramApiMultipart(config, "sendDocument", form);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log.debug({ chatId, attachmentId: meta.id, filename: meta.filename }, "Attachment sent to Telegram");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
log.error({ err, attachmentId: meta.id, filename: meta.filename }, "Failed to send attachment to Telegram");
|
|
83
|
+
failures.push(meta.filename);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (failures.length > 0) {
|
|
88
|
+
const notice = `⚠️ ${failures.length} attachment(s) could not be delivered: ${failures.join(", ")}`;
|
|
89
|
+
try {
|
|
90
|
+
await sendTelegramReply(config, chatId, notice);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.error({ err, chatId }, "Failed to send attachment failure notice");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function sendTypingIndicator(config: GatewayConfig, chatId: string): Promise<boolean> {
|
|
98
|
+
try {
|
|
99
|
+
await callTelegramApi(config, "sendChatAction", { chat_id: chatId, action: "typing" });
|
|
100
|
+
return true;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
log.debug({ err, chatId }, "Failed to send typing indicator");
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { splitText };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { timingSafeEqual } from "crypto";
|
|
2
|
+
|
|
3
|
+
export function verifyWebhookSecret(
|
|
4
|
+
headers: Headers,
|
|
5
|
+
expectedSecret: string,
|
|
6
|
+
): boolean {
|
|
7
|
+
const provided = headers.get("x-telegram-bot-api-secret-token");
|
|
8
|
+
if (!provided || !expectedSecret) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const a = Buffer.from(provided);
|
|
12
|
+
const b = Buffer.from(expectedSecret);
|
|
13
|
+
if (a.length !== b.length) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return timingSafeEqual(a, b);
|
|
17
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type GatewayInboundEventV1 = {
|
|
2
|
+
version: "v1";
|
|
3
|
+
sourceChannel: "telegram";
|
|
4
|
+
receivedAt: string;
|
|
5
|
+
routing: {
|
|
6
|
+
assistantId: string;
|
|
7
|
+
routeSource: "chat_id" | "user_id" | "default";
|
|
8
|
+
};
|
|
9
|
+
message: {
|
|
10
|
+
content: string;
|
|
11
|
+
externalChatId: string;
|
|
12
|
+
externalMessageId: string;
|
|
13
|
+
isEdit?: boolean;
|
|
14
|
+
attachments?: {
|
|
15
|
+
type: "photo" | "document";
|
|
16
|
+
fileId: string;
|
|
17
|
+
fileName?: string;
|
|
18
|
+
mimeType?: string;
|
|
19
|
+
fileSize?: number;
|
|
20
|
+
}[];
|
|
21
|
+
};
|
|
22
|
+
sender: {
|
|
23
|
+
externalUserId: string;
|
|
24
|
+
username?: string;
|
|
25
|
+
displayName?: string;
|
|
26
|
+
firstName?: string;
|
|
27
|
+
lastName?: string;
|
|
28
|
+
languageCode?: string;
|
|
29
|
+
isBot?: boolean;
|
|
30
|
+
};
|
|
31
|
+
source: {
|
|
32
|
+
updateId: string;
|
|
33
|
+
messageId?: string;
|
|
34
|
+
chatType?: string;
|
|
35
|
+
};
|
|
36
|
+
raw: Record<string, unknown>;
|
|
37
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"rootDir": "./src",
|
|
16
|
+
"types": ["bun-types"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|