ompclaw 0.3.0
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/NOTICE +10 -0
- package/README.md +152 -0
- package/SECURITY.md +61 -0
- package/config.example.json +45 -0
- package/docs/guide.md +360 -0
- package/docs/rpc-service.md +240 -0
- package/package.json +93 -0
- package/src/api.ts +556 -0
- package/src/gateway-app.ts +393 -0
- package/src/gateway-config.ts +379 -0
- package/src/gateway-core.ts +410 -0
- package/src/gateway-scheduler.ts +425 -0
- package/src/gateway-store.ts +947 -0
- package/src/gateway-tools.ts +443 -0
- package/src/gateway-types.ts +290 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +13 -0
- package/src/markdown.ts +156 -0
- package/src/outbound.ts +353 -0
- package/src/rpc-cli.ts +408 -0
- package/src/rpc-client.ts +308 -0
- package/src/rpc-config.ts +70 -0
- package/src/rpc-profile.ts +215 -0
- package/src/rpc-protocol.ts +326 -0
- package/src/rpc-runtime.ts +875 -0
- package/src/rpc-service.ts +191 -0
- package/src/rpc-ui.ts +218 -0
- package/src/transports/telegram/adapter.ts +829 -0
- package/src/transports/websocket/adapter.ts +704 -0
- package/src/transports/websocket/protocol.ts +256 -0
- package/src/type-guards.ts +4 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { extname, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import {
|
|
8
|
+
Poller,
|
|
9
|
+
acquireLock,
|
|
10
|
+
downloadFileBytes,
|
|
11
|
+
releaseLock,
|
|
12
|
+
startLockHeartbeat,
|
|
13
|
+
tg,
|
|
14
|
+
type Logger,
|
|
15
|
+
type TgCallbackQuery,
|
|
16
|
+
type TgMessage,
|
|
17
|
+
type TgUpdate,
|
|
18
|
+
withRateLimit,
|
|
19
|
+
} from "../../api";
|
|
20
|
+
import type { GatewayStore, PendingInteraction } from "../../gateway-store";
|
|
21
|
+
import type {
|
|
22
|
+
ConversationAddress,
|
|
23
|
+
DeliveryContext,
|
|
24
|
+
InboundEnvelope,
|
|
25
|
+
MessageAttachment,
|
|
26
|
+
OutboundContent,
|
|
27
|
+
OutboundReceipt,
|
|
28
|
+
Reaction,
|
|
29
|
+
TransportAdapter,
|
|
30
|
+
TransportCapabilities,
|
|
31
|
+
TransportStartContext,
|
|
32
|
+
UiRequest,
|
|
33
|
+
UiResponse,
|
|
34
|
+
UiResponseFor,
|
|
35
|
+
} from "../../gateway-types";
|
|
36
|
+
import { INBOX_MAX_FILE_BYTES, storeInboxFile } from "../../inbox";
|
|
37
|
+
import { Outbound, type TelegramCall, type TelegramUpload } from "../../outbound";
|
|
38
|
+
|
|
39
|
+
const execFileAsync = promisify(execFile);
|
|
40
|
+
const DEFAULT_UI_TIMEOUT_MS = 5 * 60 * 1000;
|
|
41
|
+
const CALLBACK_PREFIX = "ompui";
|
|
42
|
+
|
|
43
|
+
export interface TelegramPoller {
|
|
44
|
+
start(
|
|
45
|
+
token: string,
|
|
46
|
+
onUpdate: (update: TgUpdate) => void | Promise<void>,
|
|
47
|
+
onFatal: (reason: string) => void,
|
|
48
|
+
logger?: Logger,
|
|
49
|
+
): void;
|
|
50
|
+
stop(): void;
|
|
51
|
+
done(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TelegramApiSeams {
|
|
55
|
+
readonly poller?: TelegramPoller;
|
|
56
|
+
readonly callTelegram?: TelegramCall;
|
|
57
|
+
readonly uploadTelegram?: TelegramUpload;
|
|
58
|
+
readonly downloadFileBytes?: (token: string, filePath: string) => Promise<Uint8Array>;
|
|
59
|
+
readonly acquireLock?: (lockPath: string) => { readonly ok: true } | { readonly ok: false; readonly holder: number };
|
|
60
|
+
readonly releaseLock?: (lockPath: string) => void;
|
|
61
|
+
readonly startLockHeartbeat?: (lockPath: string) => () => void;
|
|
62
|
+
readonly now?: () => number;
|
|
63
|
+
readonly randomId?: () => string;
|
|
64
|
+
readonly transcribe?: (command: readonly string[], file: string, signal?: AbortSignal) => Promise<string>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TelegramTransportAdapterOptions {
|
|
68
|
+
readonly token: string;
|
|
69
|
+
readonly account?: string;
|
|
70
|
+
readonly stateDir: string;
|
|
71
|
+
readonly store: Pick<GatewayStore, "getCheckpoint" | "setCheckpoint" | "putPendingInteraction" | "deletePendingInteraction">;
|
|
72
|
+
readonly transcribeCommand?: readonly string[];
|
|
73
|
+
readonly logger?: Logger;
|
|
74
|
+
readonly api?: TelegramApiSeams;
|
|
75
|
+
readonly uiTimeoutMs?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type PendingKind = "confirm" | "select" | "input" | "editor";
|
|
79
|
+
|
|
80
|
+
interface PendingUi {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly kind: PendingKind;
|
|
83
|
+
readonly address: ConversationAddress;
|
|
84
|
+
readonly delivery: DeliveryContext;
|
|
85
|
+
readonly options: readonly string[];
|
|
86
|
+
readonly multiSelect: boolean;
|
|
87
|
+
readonly selected: Set<number>;
|
|
88
|
+
readonly resolve: (response: UiResponse) => void;
|
|
89
|
+
message?: OutboundReceipt;
|
|
90
|
+
timer?: NodeJS.Timeout;
|
|
91
|
+
removeAbortListener?: () => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Surface {
|
|
95
|
+
title: string;
|
|
96
|
+
editorText: string;
|
|
97
|
+
readonly statuses: Map<string, string>;
|
|
98
|
+
readonly widgets: Map<string, readonly string[]>;
|
|
99
|
+
message?: OutboundReceipt;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface MediaSpec {
|
|
103
|
+
readonly fileId: string;
|
|
104
|
+
readonly uniqueId: string;
|
|
105
|
+
readonly name: string;
|
|
106
|
+
readonly mediaType: string;
|
|
107
|
+
readonly size?: number;
|
|
108
|
+
readonly voice: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sameAddress(left: ConversationAddress, right: ConversationAddress): boolean {
|
|
112
|
+
return (
|
|
113
|
+
left.transport === right.transport &&
|
|
114
|
+
left.account === right.account &&
|
|
115
|
+
left.channel === right.channel &&
|
|
116
|
+
left.thread === right.thread
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sourceFor(update: TgUpdate): TgMessage | undefined {
|
|
121
|
+
if (update.message) return update.message;
|
|
122
|
+
if (update.edited_message) return { ...update.edited_message, edited_flag: true };
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function addressFor(message: Pick<TgMessage, "chat" | "is_topic_message" | "message_thread_id">, account: string): ConversationAddress {
|
|
127
|
+
return {
|
|
128
|
+
transport: "telegram",
|
|
129
|
+
account,
|
|
130
|
+
channel: String(message.chat.id),
|
|
131
|
+
...(message.is_topic_message && message.message_thread_id !== undefined ? { thread: String(message.message_thread_id) } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function identityFor(userId: number, account: string): InboundEnvelope["identity"] {
|
|
136
|
+
return { transport: "telegram", account, subject: String(userId) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function safeFilename(name: string): string {
|
|
140
|
+
const finalSegment = name.replaceAll("\\", "/").split("/").at(-1) ?? "attachment";
|
|
141
|
+
const normalized = finalSegment.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
|
|
142
|
+
return (normalized || "attachment").slice(0, 120);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mediaFor(message: TgMessage): MediaSpec | undefined {
|
|
146
|
+
const photo = message.photo?.at(-1);
|
|
147
|
+
if (photo) {
|
|
148
|
+
return {
|
|
149
|
+
fileId: photo.file_id,
|
|
150
|
+
uniqueId: photo.file_unique_id,
|
|
151
|
+
name: `photo-${photo.file_unique_id}.jpg`,
|
|
152
|
+
mediaType: "image/jpeg",
|
|
153
|
+
size: photo.file_size,
|
|
154
|
+
voice: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (message.document) {
|
|
158
|
+
return {
|
|
159
|
+
fileId: message.document.file_id,
|
|
160
|
+
uniqueId: message.document.file_unique_id,
|
|
161
|
+
name: message.document.file_name ?? `document-${message.document.file_unique_id}`,
|
|
162
|
+
mediaType: message.document.mime_type ?? "application/octet-stream",
|
|
163
|
+
size: message.document.file_size,
|
|
164
|
+
voice: false,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (message.audio) {
|
|
168
|
+
return {
|
|
169
|
+
fileId: message.audio.file_id,
|
|
170
|
+
uniqueId: message.audio.file_unique_id,
|
|
171
|
+
name: message.audio.file_name ?? `audio-${message.audio.file_unique_id}.mp3`,
|
|
172
|
+
mediaType: message.audio.mime_type ?? "audio/mpeg",
|
|
173
|
+
size: message.audio.file_size,
|
|
174
|
+
voice: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (message.video) {
|
|
178
|
+
return {
|
|
179
|
+
fileId: message.video.file_id,
|
|
180
|
+
uniqueId: message.video.file_unique_id,
|
|
181
|
+
name: message.video.file_name ?? `video-${message.video.file_unique_id}.mp4`,
|
|
182
|
+
mediaType: message.video.mime_type ?? "video/mp4",
|
|
183
|
+
size: message.video.file_size,
|
|
184
|
+
voice: false,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (message.voice) {
|
|
188
|
+
return {
|
|
189
|
+
fileId: message.voice.file_id,
|
|
190
|
+
uniqueId: message.voice.file_unique_id,
|
|
191
|
+
name: `voice-${message.voice.file_unique_id}.ogg`,
|
|
192
|
+
mediaType: message.voice.mime_type ?? "audio/ogg",
|
|
193
|
+
size: message.voice.file_size,
|
|
194
|
+
voice: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (message.video_note) {
|
|
198
|
+
return {
|
|
199
|
+
fileId: message.video_note.file_id,
|
|
200
|
+
uniqueId: message.video_note.file_unique_id,
|
|
201
|
+
name: `video-note-${message.video_note.file_unique_id}.mp4`,
|
|
202
|
+
mediaType: "video/mp4",
|
|
203
|
+
size: message.video_note.file_size,
|
|
204
|
+
voice: false,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (message.sticker) {
|
|
208
|
+
return {
|
|
209
|
+
fileId: message.sticker.file_id,
|
|
210
|
+
uniqueId: message.sticker.file_unique_id,
|
|
211
|
+
name: `sticker-${message.sticker.file_unique_id}.webp`,
|
|
212
|
+
mediaType: "image/webp",
|
|
213
|
+
size: message.sticker.file_size,
|
|
214
|
+
voice: false,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isPublicIdentityCommand(text: string | undefined): boolean {
|
|
221
|
+
return /^\/(?:start|whoami)(?:@[A-Za-z0-9_]+)?(?:\s|$)/.test(text ?? "");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function callbackData(id: string, action: string): string {
|
|
225
|
+
return `${CALLBACK_PREFIX}:${id}:${action}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Transport-neutral gateway adapter for Telegram Bot API long polling. */
|
|
229
|
+
export class TelegramTransportAdapter implements TransportAdapter {
|
|
230
|
+
readonly id: string;
|
|
231
|
+
readonly capabilities: TransportCapabilities = {
|
|
232
|
+
streamingUpdates: true,
|
|
233
|
+
buttons: true,
|
|
234
|
+
multiSelect: true,
|
|
235
|
+
textInput: true,
|
|
236
|
+
attachments: true,
|
|
237
|
+
reactions: true,
|
|
238
|
+
threads: true,
|
|
239
|
+
// Logical input bound; Outbound enforces Telegram's 4096-code-unit native segments.
|
|
240
|
+
maxMessageLength: Number.MAX_SAFE_INTEGER,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
readonly #token: string;
|
|
244
|
+
readonly #account: string;
|
|
245
|
+
readonly #checkpointKey: string;
|
|
246
|
+
readonly #stateDir: string;
|
|
247
|
+
readonly #inboxDir: string;
|
|
248
|
+
readonly #store: TelegramTransportAdapterOptions["store"];
|
|
249
|
+
readonly #logger?: Logger;
|
|
250
|
+
readonly #poller: TelegramPoller;
|
|
251
|
+
readonly #callTelegram: TelegramCall;
|
|
252
|
+
readonly #download: (token: string, filePath: string) => Promise<Uint8Array>;
|
|
253
|
+
readonly #acquireLock: NonNullable<TelegramApiSeams["acquireLock"]>;
|
|
254
|
+
readonly #releaseLock: NonNullable<TelegramApiSeams["releaseLock"]>;
|
|
255
|
+
readonly #startLockHeartbeat: NonNullable<TelegramApiSeams["startLockHeartbeat"]>;
|
|
256
|
+
readonly #now: () => number;
|
|
257
|
+
readonly #randomId: () => string;
|
|
258
|
+
readonly #transcribeCommand?: readonly string[];
|
|
259
|
+
readonly #transcribe?: TelegramApiSeams["transcribe"];
|
|
260
|
+
readonly #uiTimeoutMs: number;
|
|
261
|
+
readonly #outbound: Outbound;
|
|
262
|
+
readonly #pending = new Map<string, PendingUi>();
|
|
263
|
+
readonly #surfaces = new Map<string, Surface>();
|
|
264
|
+
readonly #inflight = new Map<number, Promise<void>>();
|
|
265
|
+
readonly #receivedUpdateIds: number[] = [];
|
|
266
|
+
readonly #completedUpdateIds = new Set<number>();
|
|
267
|
+
#startContext: TransportStartContext | undefined;
|
|
268
|
+
#releaseHeartbeat: (() => void) | undefined;
|
|
269
|
+
#lockPath: string | undefined;
|
|
270
|
+
#stopping = false;
|
|
271
|
+
|
|
272
|
+
constructor(options: TelegramTransportAdapterOptions) {
|
|
273
|
+
if (!options.token) throw new Error("Telegram bot token is required");
|
|
274
|
+
if (!options.stateDir) throw new Error("Telegram stateDir is required");
|
|
275
|
+
this.#token = options.token;
|
|
276
|
+
this.#account = options.account ?? "default";
|
|
277
|
+
this.id = "telegram";
|
|
278
|
+
this.#checkpointKey = this.#account === "default" ? "update_id" : `update_id:${this.#account}`;
|
|
279
|
+
this.#stateDir = resolve(options.stateDir);
|
|
280
|
+
this.#inboxDir = resolve(this.#stateDir, "inbox");
|
|
281
|
+
this.#store = options.store;
|
|
282
|
+
this.#logger = options.logger;
|
|
283
|
+
this.#poller = options.api?.poller ?? new Poller();
|
|
284
|
+
this.#callTelegram =
|
|
285
|
+
options.api?.callTelegram ??
|
|
286
|
+
((method, payload, requestOptions) => tg(this.#token, method, payload, { signal: requestOptions?.signal }));
|
|
287
|
+
this.#download = options.api?.downloadFileBytes ?? downloadFileBytes;
|
|
288
|
+
this.#acquireLock = options.api?.acquireLock ?? acquireLock;
|
|
289
|
+
this.#releaseLock = options.api?.releaseLock ?? releaseLock;
|
|
290
|
+
this.#startLockHeartbeat = options.api?.startLockHeartbeat ?? startLockHeartbeat;
|
|
291
|
+
this.#now = options.api?.now ?? Date.now;
|
|
292
|
+
this.#randomId = options.api?.randomId ?? (() => randomBytes(12).toString("base64url"));
|
|
293
|
+
this.#transcribeCommand = options.transcribeCommand;
|
|
294
|
+
this.#transcribe = options.api?.transcribe;
|
|
295
|
+
this.#uiTimeoutMs = options.uiTimeoutMs ?? DEFAULT_UI_TIMEOUT_MS;
|
|
296
|
+
this.#outbound = new Outbound({
|
|
297
|
+
token: this.#token,
|
|
298
|
+
account: this.#account,
|
|
299
|
+
logger: this.#logger,
|
|
300
|
+
callTelegram: this.#callTelegram,
|
|
301
|
+
uploadTelegram: options.api?.uploadTelegram,
|
|
302
|
+
authorizeAddress: (address, delivery) => this.#authorizes(address, delivery),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async start(context: TransportStartContext): Promise<void> {
|
|
307
|
+
if (this.#startContext !== undefined) throw new Error(`Telegram adapter ${this.id} is already started`);
|
|
308
|
+
await mkdir(this.#stateDir, { recursive: true, mode: 0o700 });
|
|
309
|
+
const lockPath = resolve(this.#stateDir, `telegram-${safeFilename(this.#account)}.poll.lock`);
|
|
310
|
+
const claimed = this.#acquireLock(lockPath);
|
|
311
|
+
if (!claimed.ok) throw new Error(`Telegram account ${this.#account} is already being polled by process ${claimed.holder}`);
|
|
312
|
+
|
|
313
|
+
this.#stopping = false;
|
|
314
|
+
this.#startContext = context;
|
|
315
|
+
this.#lockPath = lockPath;
|
|
316
|
+
this.#releaseHeartbeat = this.#startLockHeartbeat(lockPath);
|
|
317
|
+
context.signal?.addEventListener("abort", () => void this.stop(), { once: true });
|
|
318
|
+
this.#poller.start(
|
|
319
|
+
this.#token,
|
|
320
|
+
(update) => this.handleUpdate(update),
|
|
321
|
+
(reason) => this.#logger?.error(`[telegram] ${reason}`),
|
|
322
|
+
this.#logger,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async stop(): Promise<void> {
|
|
327
|
+
if (this.#stopping) return;
|
|
328
|
+
this.#stopping = true;
|
|
329
|
+
this.#poller.stop();
|
|
330
|
+
await this.#poller.done();
|
|
331
|
+
await Promise.allSettled([...this.#inflight.values()]);
|
|
332
|
+
await Promise.allSettled([...this.#pending.values()].map((pending) => this.#finish(pending, undefined, true)));
|
|
333
|
+
this.#pending.clear();
|
|
334
|
+
this.#releaseHeartbeat?.();
|
|
335
|
+
this.#releaseHeartbeat = undefined;
|
|
336
|
+
if (this.#lockPath) this.#releaseLock(this.#lockPath);
|
|
337
|
+
this.#lockPath = undefined;
|
|
338
|
+
this.#startContext = undefined;
|
|
339
|
+
this.#stopping = false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async send(
|
|
343
|
+
address: ConversationAddress,
|
|
344
|
+
content: OutboundContent,
|
|
345
|
+
context: DeliveryContext,
|
|
346
|
+
signal?: AbortSignal,
|
|
347
|
+
): Promise<OutboundReceipt> {
|
|
348
|
+
return this.#outbound.send(address, content, context, signal);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async update(
|
|
352
|
+
address: ConversationAddress,
|
|
353
|
+
receipt: OutboundReceipt,
|
|
354
|
+
content: OutboundContent,
|
|
355
|
+
context: DeliveryContext,
|
|
356
|
+
signal?: AbortSignal,
|
|
357
|
+
): Promise<OutboundReceipt> {
|
|
358
|
+
return this.#outbound.update(address, receipt, content, context, signal);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async react(
|
|
362
|
+
address: ConversationAddress,
|
|
363
|
+
receipt: OutboundReceipt,
|
|
364
|
+
reaction: Reaction,
|
|
365
|
+
context: DeliveryContext,
|
|
366
|
+
signal?: AbortSignal,
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
await this.#outbound.react(address, receipt, reaction, context, signal);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async presentUi<Request extends UiRequest>(
|
|
372
|
+
address: ConversationAddress,
|
|
373
|
+
request: Request,
|
|
374
|
+
context: DeliveryContext,
|
|
375
|
+
signal?: AbortSignal,
|
|
376
|
+
): Promise<UiResponseFor<Request>> {
|
|
377
|
+
this.#assertStarted();
|
|
378
|
+
if (!this.#authorizes(address, context)) throw new Error("Telegram UI target is not authorized for this delivery context");
|
|
379
|
+
signal?.throwIfAborted();
|
|
380
|
+
|
|
381
|
+
if (request.type === "notify") {
|
|
382
|
+
await this.#outbound.sendMessage(address, request.message, context, {}, signal);
|
|
383
|
+
return { type: "notify", acknowledged: true } as UiResponseFor<Request>;
|
|
384
|
+
}
|
|
385
|
+
if (request.type === "open_url") {
|
|
386
|
+
await this.#outbound.sendMessage(address, request.label ?? "Open the requested URL", context, {
|
|
387
|
+
replyMarkup: { inline_keyboard: [[{ text: request.label ?? "Open URL", url: request.url }]] },
|
|
388
|
+
}, signal);
|
|
389
|
+
return { type: "open_url", opened: true } as UiResponseFor<Request>;
|
|
390
|
+
}
|
|
391
|
+
if (request.type === "status" || request.type === "widget" || request.type === "title" || request.type === "editor_text") {
|
|
392
|
+
await this.#presentSurface(address, request, context, signal);
|
|
393
|
+
return { type: request.type, acknowledged: true } as UiResponseFor<Request>;
|
|
394
|
+
}
|
|
395
|
+
return (await this.#createPending(address, request, context, signal)) as UiResponseFor<Request>;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Handles one Telegram update; public for deterministic adapter tests and webhook bridges. */
|
|
399
|
+
async handleUpdate(update: TgUpdate): Promise<void> {
|
|
400
|
+
const checkpoint = this.#checkpoint();
|
|
401
|
+
if (this.#completedUpdateIds.has(update.update_id)) return;
|
|
402
|
+
if (update.update_id <= checkpoint) return;
|
|
403
|
+
const inFlight = this.#inflight.get(update.update_id);
|
|
404
|
+
if (inFlight) return inFlight;
|
|
405
|
+
|
|
406
|
+
const work = this.#handleUpdate(update);
|
|
407
|
+
this.#inflight.set(update.update_id, work);
|
|
408
|
+
if (!this.#receivedUpdateIds.includes(update.update_id)) {
|
|
409
|
+
const insertAt = this.#receivedUpdateIds.findIndex((id) => id > update.update_id);
|
|
410
|
+
if (insertAt === -1) this.#receivedUpdateIds.push(update.update_id);
|
|
411
|
+
else this.#receivedUpdateIds.splice(insertAt, 0, update.update_id);
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await work;
|
|
415
|
+
this.#completedUpdateIds.add(update.update_id);
|
|
416
|
+
// Persist only the completed prefix: a later success must never skip an
|
|
417
|
+
// earlier failed update when the process restarts.
|
|
418
|
+
this.#checkpointCompletedUpdates();
|
|
419
|
+
} finally {
|
|
420
|
+
this.#inflight.delete(update.update_id);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
statusText(): string {
|
|
425
|
+
const surfaces = [...this.#surfaces.values()];
|
|
426
|
+
if (surfaces.length === 0) return "Telegram UI ready";
|
|
427
|
+
return surfaces.map((surface) => this.#surfaceText(surface)).join("\n\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async #handleUpdate(update: TgUpdate): Promise<void> {
|
|
431
|
+
if (update.callback_query) {
|
|
432
|
+
if (await this.#handleCallback(update.callback_query)) return;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const message = sourceFor(update);
|
|
436
|
+
if (!message || message.from?.is_bot || !message.from) return;
|
|
437
|
+
if (await this.#handleReply(message)) return;
|
|
438
|
+
|
|
439
|
+
const context = this.#assertStarted();
|
|
440
|
+
if (isPublicIdentityCommand(message.text)) {
|
|
441
|
+
const resolved = await context.resolveIdentity(identityFor(message.from.id, this.#account), context.signal);
|
|
442
|
+
if (resolved === undefined) {
|
|
443
|
+
await this.#sendUnknownIdentityGuidance(message);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const attachment = await this.#attachmentFor(message, context.signal);
|
|
449
|
+
const transcript = attachment?.voice ? await this.#transcript(attachment.path, context.signal) : undefined;
|
|
450
|
+
const text = [message.text ?? message.caption, transcript].filter((value): value is string => Boolean(value)).join("\n\n") || undefined;
|
|
451
|
+
const address = addressFor(message, this.#account);
|
|
452
|
+
const envelope: InboundEnvelope = {
|
|
453
|
+
id: `telegram:${this.#account}:${message.chat.id}:${message.message_id}`,
|
|
454
|
+
sentAt: message.date * 1000,
|
|
455
|
+
identity: identityFor(message.from.id, this.#account),
|
|
456
|
+
address,
|
|
457
|
+
content: {
|
|
458
|
+
...(text === undefined ? {} : { text }),
|
|
459
|
+
...(attachment === undefined ? {} : { attachments: [attachment.attachment] }),
|
|
460
|
+
},
|
|
461
|
+
...(message.reply_to_message === undefined ? {} : { replyTo: { transport: "telegram", messageId: String(message.reply_to_message.message_id) } }),
|
|
462
|
+
edited: message.edited_flag === true,
|
|
463
|
+
};
|
|
464
|
+
await context.receive(envelope, context.signal);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async #handleCallback(query: TgCallbackQuery): Promise<boolean> {
|
|
468
|
+
const data = query.data;
|
|
469
|
+
if (!data?.startsWith(`${CALLBACK_PREFIX}:`)) return false;
|
|
470
|
+
const [, id, action] = data.split(":", 3);
|
|
471
|
+
const pending = id === undefined ? undefined : this.#pending.get(id);
|
|
472
|
+
if (!pending || !query.message || !pending.message) {
|
|
473
|
+
await this.#answerCallback(query.id, "This control has expired.", true);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
const address = addressFor(query.message, this.#account);
|
|
477
|
+
if (!sameAddress(address, pending.address) || query.message.message_id !== Number(pending.message.messageId)) {
|
|
478
|
+
await this.#answerCallback(query.id, "This control is unavailable.", true);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const context = this.#assertStarted();
|
|
483
|
+
const resolved = await context.resolveIdentity(identityFor(query.from.id, this.#account), context.signal);
|
|
484
|
+
if (resolved === undefined || resolved.id !== pending.delivery.principal.id) {
|
|
485
|
+
await this.#answerCallback(query.id, "This control belongs to another user.", true);
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (pending.kind === "confirm") {
|
|
490
|
+
if (action !== "yes" && action !== "no") {
|
|
491
|
+
await this.#answerCallback(query.id, "Invalid response.", true);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
await this.#answerCallback(query.id);
|
|
495
|
+
await this.#finish(pending, { type: "confirm", confirmed: action === "yes" });
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
if (pending.kind !== "select") {
|
|
499
|
+
await this.#answerCallback(query.id, "Reply to the prompt instead.", true);
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
if (pending.multiSelect && action === "done") {
|
|
503
|
+
await this.#answerCallback(query.id, "Saved");
|
|
504
|
+
await this.#finish(pending, {
|
|
505
|
+
type: "select",
|
|
506
|
+
selected: [...pending.selected].sort((left, right) => left - right).map((index) => pending.options[index]),
|
|
507
|
+
});
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
if (pending.multiSelect && action === "cancel") {
|
|
511
|
+
await this.#answerCallback(query.id, "Cancelled");
|
|
512
|
+
await this.#finish(pending, undefined, true);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
const index = Number(action);
|
|
516
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= pending.options.length) {
|
|
517
|
+
await this.#answerCallback(query.id, "Invalid option.", true);
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
if (!pending.multiSelect) {
|
|
521
|
+
await this.#answerCallback(query.id);
|
|
522
|
+
await this.#finish(pending, { type: "select", selected: [pending.options[index]] });
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
if (pending.selected.has(index)) pending.selected.delete(index);
|
|
526
|
+
else pending.selected.add(index);
|
|
527
|
+
await this.#answerCallback(query.id, pending.selected.has(index) ? "Selected" : "Removed");
|
|
528
|
+
await this.#outbound.setReplyMarkup(pending.address, pending.message, this.#keyboard(pending), pending.delivery, context.signal);
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async #handleReply(message: TgMessage): Promise<boolean> {
|
|
533
|
+
const replyId = message.reply_to_message?.message_id;
|
|
534
|
+
if (replyId === undefined || !message.from) return false;
|
|
535
|
+
const address = addressFor(message, this.#account);
|
|
536
|
+
const pending = [...this.#pending.values()].find(
|
|
537
|
+
(candidate) =>
|
|
538
|
+
(candidate.kind === "input" || candidate.kind === "editor") &&
|
|
539
|
+
candidate.message?.messageId === String(replyId) &&
|
|
540
|
+
sameAddress(candidate.address, address),
|
|
541
|
+
);
|
|
542
|
+
if (!pending) return false;
|
|
543
|
+
|
|
544
|
+
const context = this.#assertStarted();
|
|
545
|
+
const resolved = await context.resolveIdentity(identityFor(message.from.id, this.#account), context.signal);
|
|
546
|
+
if (resolved === undefined) return false;
|
|
547
|
+
if (resolved.id !== pending.delivery.principal.id) {
|
|
548
|
+
await this.#outbound.sendMessage(address, "This prompt belongs to another authorized user.", pending.delivery, {}, context.signal);
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
const value = message.text ?? message.caption;
|
|
552
|
+
if (!value) return true;
|
|
553
|
+
await this.#finish(
|
|
554
|
+
pending,
|
|
555
|
+
pending.kind === "input"
|
|
556
|
+
? { type: "input", cancelled: false, value }
|
|
557
|
+
: { type: "editor", cancelled: false, value },
|
|
558
|
+
);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async #createPending(
|
|
563
|
+
address: ConversationAddress,
|
|
564
|
+
request: Extract<UiRequest, { readonly type: PendingKind }>,
|
|
565
|
+
delivery: DeliveryContext,
|
|
566
|
+
signal?: AbortSignal,
|
|
567
|
+
): Promise<UiResponse> {
|
|
568
|
+
if (request.type === "select" && request.options.length === 0) return { type: "select", selected: [] };
|
|
569
|
+
const deferred = Promise.withResolvers<UiResponse>();
|
|
570
|
+
const pending: PendingUi = {
|
|
571
|
+
id: this.#randomId(),
|
|
572
|
+
kind: request.type,
|
|
573
|
+
address,
|
|
574
|
+
delivery,
|
|
575
|
+
options: request.type === "select" ? request.options.map((option) => option.value) : [],
|
|
576
|
+
multiSelect: request.type === "select" && request.multiSelect === true,
|
|
577
|
+
selected: new Set(),
|
|
578
|
+
resolve: deferred.resolve,
|
|
579
|
+
};
|
|
580
|
+
const body = this.#pendingText(request);
|
|
581
|
+
const replyMarkup =
|
|
582
|
+
pending.kind === "input" || pending.kind === "editor"
|
|
583
|
+
? { force_reply: true, selective: true, input_field_placeholder: body.slice(0, 64) }
|
|
584
|
+
: this.#keyboard(pending, request.type === "select" ? request.options.map((option) => option.label) : undefined);
|
|
585
|
+
|
|
586
|
+
const sent = await this.#outbound.sendMessage(address, body, delivery, { replyMarkup }, signal);
|
|
587
|
+
pending.message = sent;
|
|
588
|
+
this.#store.putPendingInteraction(this.#storedPending(pending));
|
|
589
|
+
this.#pending.set(pending.id, pending);
|
|
590
|
+
if (this.#uiTimeoutMs > 0) {
|
|
591
|
+
pending.timer = setTimeout(() => void this.#finish(pending, undefined, true), this.#uiTimeoutMs);
|
|
592
|
+
pending.timer.unref?.();
|
|
593
|
+
}
|
|
594
|
+
const onAbort = () => void this.#finish(pending, undefined, true);
|
|
595
|
+
if (signal?.aborted) void this.#finish(pending, undefined, true);
|
|
596
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
597
|
+
pending.removeAbortListener = () => signal?.removeEventListener("abort", onAbort);
|
|
598
|
+
return deferred.promise;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async #presentSurface(
|
|
602
|
+
address: ConversationAddress,
|
|
603
|
+
request: Extract<UiRequest, { readonly type: "status" | "widget" | "title" | "editor_text" }>,
|
|
604
|
+
delivery: DeliveryContext,
|
|
605
|
+
signal?: AbortSignal,
|
|
606
|
+
): Promise<void> {
|
|
607
|
+
const key = `${address.channel}:${address.thread ?? ""}`;
|
|
608
|
+
let surface = this.#surfaces.get(key);
|
|
609
|
+
if (!surface) {
|
|
610
|
+
surface = { title: "OMP", editorText: "", statuses: new Map(), widgets: new Map() };
|
|
611
|
+
this.#surfaces.set(key, surface);
|
|
612
|
+
}
|
|
613
|
+
if (request.type === "status") {
|
|
614
|
+
if (request.text === undefined) surface.statuses.delete(request.key);
|
|
615
|
+
else surface.statuses.set(request.key, request.text);
|
|
616
|
+
} else if (request.type === "widget") {
|
|
617
|
+
if (request.lines === undefined) surface.widgets.delete(request.key);
|
|
618
|
+
else surface.widgets.set(request.key, request.lines);
|
|
619
|
+
} else if (request.type === "title") surface.title = request.title;
|
|
620
|
+
else surface.editorText = request.text;
|
|
621
|
+
|
|
622
|
+
const text = this.#surfaceText(surface);
|
|
623
|
+
if (surface.message) {
|
|
624
|
+
try {
|
|
625
|
+
surface.message = await this.#outbound.update(address, surface.message, { text, format: "text" }, delivery, signal);
|
|
626
|
+
return;
|
|
627
|
+
} catch (error) {
|
|
628
|
+
this.#logger?.warn(`[telegram] could not edit UI surface: ${error instanceof Error ? error.message : String(error)}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
surface.message = await this.#outbound.sendMessage(address, text, delivery, {}, signal);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
#surfaceText(surface: Surface): string {
|
|
635
|
+
const lines = [`Surface: ${surface.title}`];
|
|
636
|
+
for (const [key, text] of surface.statuses) lines.push(`${key}: ${text}`);
|
|
637
|
+
for (const [key, widget] of surface.widgets) lines.push(`${key}: ${widget.join(" | ")}`);
|
|
638
|
+
if (surface.editorText) lines.push(`Suggested input: ${surface.editorText}`);
|
|
639
|
+
return lines.join("\n").slice(0, 4096);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#pendingText(request: Extract<UiRequest, { readonly type: PendingKind }>): string {
|
|
643
|
+
if (request.type === "confirm") return [request.title, request.message].filter(Boolean).join("\n\n");
|
|
644
|
+
if (request.type === "select") {
|
|
645
|
+
return [
|
|
646
|
+
request.title,
|
|
647
|
+
...request.options.map((option, index) => `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`),
|
|
648
|
+
].join("\n\n");
|
|
649
|
+
}
|
|
650
|
+
if (request.type === "input") {
|
|
651
|
+
return [request.title, request.prompt ?? request.placeholder ?? "Reply to this message.", request.initialValue].filter(Boolean).join("\n\n");
|
|
652
|
+
}
|
|
653
|
+
return [request.title, request.initialValue, "Reply to this message with the edited text."].filter(Boolean).join("\n\n");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
#keyboard(pending: PendingUi, labels?: readonly string[]): Record<string, unknown> {
|
|
657
|
+
if (pending.kind === "confirm") {
|
|
658
|
+
return {
|
|
659
|
+
inline_keyboard: [
|
|
660
|
+
[
|
|
661
|
+
{ text: "Confirm", callback_data: callbackData(pending.id, "yes") },
|
|
662
|
+
{ text: "Cancel", callback_data: callbackData(pending.id, "no") },
|
|
663
|
+
],
|
|
664
|
+
],
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const rows = pending.options.map((value, index) => [
|
|
668
|
+
{
|
|
669
|
+
text: `${pending.multiSelect && pending.selected.has(index) ? "✓ " : ""}${(labels?.[index] ?? value).slice(0, 48)}`,
|
|
670
|
+
callback_data: callbackData(pending.id, String(index)),
|
|
671
|
+
},
|
|
672
|
+
]);
|
|
673
|
+
if (pending.multiSelect) {
|
|
674
|
+
rows.push([
|
|
675
|
+
{ text: "Done", callback_data: callbackData(pending.id, "done") },
|
|
676
|
+
{ text: "Cancel", callback_data: callbackData(pending.id, "cancel") },
|
|
677
|
+
]);
|
|
678
|
+
}
|
|
679
|
+
return { inline_keyboard: rows };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async #finish(pending: PendingUi, response: UiResponse | undefined, cancelled = false): Promise<void> {
|
|
683
|
+
if (!this.#pending.delete(pending.id)) return;
|
|
684
|
+
clearTimeout(pending.timer);
|
|
685
|
+
pending.removeAbortListener?.();
|
|
686
|
+
this.#store.deletePendingInteraction(pending.id);
|
|
687
|
+
if (pending.message) {
|
|
688
|
+
await this.#outbound
|
|
689
|
+
.setReplyMarkup(pending.address, pending.message, { inline_keyboard: [] }, pending.delivery)
|
|
690
|
+
.catch(() => undefined);
|
|
691
|
+
}
|
|
692
|
+
if (cancelled) {
|
|
693
|
+
if (pending.kind === "confirm") pending.resolve({ type: "confirm", confirmed: false });
|
|
694
|
+
else if (pending.kind === "select") pending.resolve({ type: "select", selected: [] });
|
|
695
|
+
else if (pending.kind === "input") pending.resolve({ type: "input", cancelled: true });
|
|
696
|
+
else pending.resolve({ type: "editor", cancelled: true });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (response) pending.resolve(response);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
#storedPending(pending: PendingUi): PendingInteraction {
|
|
703
|
+
const createdAt = this.#now();
|
|
704
|
+
return {
|
|
705
|
+
id: pending.id,
|
|
706
|
+
address: pending.address,
|
|
707
|
+
kind: pending.kind,
|
|
708
|
+
payload: {
|
|
709
|
+
principalId: pending.delivery.principal.id,
|
|
710
|
+
messageId: pending.message?.messageId ?? "",
|
|
711
|
+
options: [...pending.options],
|
|
712
|
+
multiSelect: pending.multiSelect,
|
|
713
|
+
},
|
|
714
|
+
createdAt,
|
|
715
|
+
expiresAt: this.#uiTimeoutMs > 0 ? createdAt + this.#uiTimeoutMs : undefined,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async #attachmentFor(
|
|
720
|
+
message: TgMessage,
|
|
721
|
+
signal?: AbortSignal,
|
|
722
|
+
): Promise<{ readonly attachment: MessageAttachment; readonly path: string; readonly voice: boolean } | undefined> {
|
|
723
|
+
const media = mediaFor(message);
|
|
724
|
+
if (!media || (media.size !== undefined && media.size > INBOX_MAX_FILE_BYTES)) {
|
|
725
|
+
if (media) this.#logger?.warn(`[telegram] ignored oversized ${media.name}`);
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
signal?.throwIfAborted();
|
|
730
|
+
const rawFile = await this.#request("getFile", { file_id: media.fileId }, signal);
|
|
731
|
+
if (!rawFile || typeof rawFile !== "object" || !("file_path" in rawFile) || typeof rawFile.file_path !== "string") {
|
|
732
|
+
throw new Error("Telegram getFile returned no file_path");
|
|
733
|
+
}
|
|
734
|
+
const bytes = await this.#download(this.#token, rawFile.file_path);
|
|
735
|
+
const extension = extname(media.name) || extname(rawFile.file_path) || ".bin";
|
|
736
|
+
const name = `${this.#now()}-${safeFilename(media.uniqueId)}${extension}`;
|
|
737
|
+
const path = await storeInboxFile(this.#inboxDir, name, bytes);
|
|
738
|
+
return {
|
|
739
|
+
path,
|
|
740
|
+
voice: media.voice,
|
|
741
|
+
attachment: { url: pathToFileURL(path).href, name: safeFilename(media.name), mediaType: media.mediaType },
|
|
742
|
+
};
|
|
743
|
+
} catch (error) {
|
|
744
|
+
this.#logger?.warn(`[telegram] media download failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async #transcript(path: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
750
|
+
if (!this.#transcribeCommand || this.#transcribeCommand.length === 0) return undefined;
|
|
751
|
+
try {
|
|
752
|
+
const output = this.#transcribe
|
|
753
|
+
? await this.#transcribe(this.#transcribeCommand, path, signal)
|
|
754
|
+
: await this.#runTranscription(this.#transcribeCommand, path, signal);
|
|
755
|
+
const trimmed = output.trim();
|
|
756
|
+
return trimmed ? `[Voice transcript: ${trimmed}]` : undefined;
|
|
757
|
+
} catch (error) {
|
|
758
|
+
this.#logger?.warn(`[telegram] voice transcription failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async #runTranscription(command: readonly string[], file: string, signal?: AbortSignal): Promise<string> {
|
|
764
|
+
const [executable, ...args] = command.map((part) => part.replaceAll("{file}", file));
|
|
765
|
+
if (!executable) throw new Error("Telegram transcription command is empty");
|
|
766
|
+
const result = await execFileAsync(executable, args, {
|
|
767
|
+
encoding: "utf8",
|
|
768
|
+
timeout: 120_000,
|
|
769
|
+
maxBuffer: 1024 * 1024,
|
|
770
|
+
signal,
|
|
771
|
+
});
|
|
772
|
+
return result.stdout;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async #sendUnknownIdentityGuidance(message: TgMessage): Promise<void> {
|
|
776
|
+
const address = addressFor(message, this.#account);
|
|
777
|
+
const context: DeliveryContext = {
|
|
778
|
+
principal: { id: `telegram-unresolved:${this.#account}:${message.from!.id}`, roles: [] },
|
|
779
|
+
origin: address,
|
|
780
|
+
};
|
|
781
|
+
await this.#outbound.sendMessage(
|
|
782
|
+
address,
|
|
783
|
+
`This gateway identifies Telegram users by numeric ID. Your user_id: ${message.from!.id}. Ask an administrator to authorize that ID.`,
|
|
784
|
+
context,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async #answerCallback(id: string, text?: string, showAlert = false): Promise<void> {
|
|
789
|
+
await this.#request("answerCallbackQuery", {
|
|
790
|
+
callback_query_id: id,
|
|
791
|
+
...(text === undefined ? {} : { text }),
|
|
792
|
+
...(showAlert ? { show_alert: true } : {}),
|
|
793
|
+
}).catch(() => undefined);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async #request(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
797
|
+
signal?.throwIfAborted();
|
|
798
|
+
return withRateLimit(() => this.#callTelegram(method, payload, { signal }), { signal, log: this.#logger });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
#checkpointCompletedUpdates(): void {
|
|
802
|
+
let completedCount = 0;
|
|
803
|
+
let latest: number | undefined;
|
|
804
|
+
for (const updateId of this.#receivedUpdateIds) {
|
|
805
|
+
if (!this.#completedUpdateIds.has(updateId)) break;
|
|
806
|
+
completedCount += 1;
|
|
807
|
+
latest = updateId;
|
|
808
|
+
}
|
|
809
|
+
if (latest === undefined) return;
|
|
810
|
+
this.#store.setCheckpoint(this.id, this.#checkpointKey, latest);
|
|
811
|
+
const persisted = this.#receivedUpdateIds.splice(0, completedCount);
|
|
812
|
+
for (const updateId of persisted) this.#completedUpdateIds.delete(updateId);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
#checkpoint(): number {
|
|
816
|
+
const value = this.#store.getCheckpoint(this.id, this.#checkpointKey);
|
|
817
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return value;
|
|
818
|
+
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
|
|
819
|
+
return -1;
|
|
820
|
+
}
|
|
821
|
+
#authorizes(address: ConversationAddress, delivery: DeliveryContext): boolean {
|
|
822
|
+
return address.transport === "telegram" && address.account === this.#account && sameAddress(address, delivery.origin);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
#assertStarted(): TransportStartContext {
|
|
826
|
+
if (!this.#startContext) throw new Error(`Telegram adapter ${this.id} is not started`);
|
|
827
|
+
return this.#startContext;
|
|
828
|
+
}
|
|
829
|
+
}
|