anorion 0.1.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/README.md +87 -0
- package/agents/001.yaml +32 -0
- package/agents/example.yaml +6 -0
- package/bin/anorion.js +8093 -0
- package/package.json +72 -0
- package/scripts/cli.ts +182 -0
- package/scripts/postinstall.js +6 -0
- package/scripts/setup.ts +255 -0
- package/src/agents/pipeline.ts +231 -0
- package/src/agents/registry.ts +153 -0
- package/src/agents/runtime.ts +593 -0
- package/src/agents/session.ts +338 -0
- package/src/agents/subagent.ts +185 -0
- package/src/bridge/client.ts +221 -0
- package/src/bridge/federator.ts +221 -0
- package/src/bridge/protocol.ts +88 -0
- package/src/bridge/server.ts +221 -0
- package/src/channels/base.ts +43 -0
- package/src/channels/router.ts +122 -0
- package/src/channels/telegram.ts +592 -0
- package/src/channels/webhook.ts +143 -0
- package/src/cli/index.ts +1036 -0
- package/src/cli/interactive.ts +26 -0
- package/src/gateway/routes-v2.ts +165 -0
- package/src/gateway/server.ts +512 -0
- package/src/gateway/ws.ts +75 -0
- package/src/index.ts +182 -0
- package/src/llm/provider.ts +243 -0
- package/src/llm/providers.ts +381 -0
- package/src/memory/context.ts +125 -0
- package/src/memory/store.ts +214 -0
- package/src/scheduler/cron.ts +239 -0
- package/src/shared/audit.ts +231 -0
- package/src/shared/config.ts +129 -0
- package/src/shared/db/index.ts +165 -0
- package/src/shared/db/prepared.ts +111 -0
- package/src/shared/db/schema.ts +84 -0
- package/src/shared/events.ts +79 -0
- package/src/shared/logger.ts +10 -0
- package/src/shared/metrics.ts +190 -0
- package/src/shared/rbac.ts +151 -0
- package/src/shared/token-budget.ts +157 -0
- package/src/shared/types.ts +166 -0
- package/src/tools/builtin/echo.ts +19 -0
- package/src/tools/builtin/file-read.ts +78 -0
- package/src/tools/builtin/file-write.ts +64 -0
- package/src/tools/builtin/http-request.ts +63 -0
- package/src/tools/builtin/memory.ts +71 -0
- package/src/tools/builtin/shell.ts +94 -0
- package/src/tools/builtin/web-search.ts +22 -0
- package/src/tools/executor.ts +126 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/skill-manager.ts +252 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { Bot, type Context, type RawApi, InlineKeyboard as GrammyInlineKeyboard, InputFile } from 'grammy';
|
|
2
|
+
import type {
|
|
3
|
+
ChannelAdapter,
|
|
4
|
+
InlineKeyboard,
|
|
5
|
+
MediaAttachment,
|
|
6
|
+
} from './base';
|
|
7
|
+
import type { MessageEnvelope } from '../shared/types';
|
|
8
|
+
import { logger } from '../shared/logger';
|
|
9
|
+
import { nanoid } from 'nanoid';
|
|
10
|
+
|
|
11
|
+
interface TelegramConfig {
|
|
12
|
+
botToken: string;
|
|
13
|
+
allowedUsers: string[];
|
|
14
|
+
defaultAgent: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── MarkdownV2 escaping ──────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const MDV2_SPECIAL = /[_*[\]()~`>#+\-=|{}.!]/g;
|
|
20
|
+
|
|
21
|
+
export function escapeMarkdownV2(text: string): string {
|
|
22
|
+
return text.replace(MDV2_SPECIAL, (ch) => `\\${ch}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function escapeHtml(text: string): string {
|
|
26
|
+
return text
|
|
27
|
+
.replace(/&/g, '&')
|
|
28
|
+
.replace(/</g, '<')
|
|
29
|
+
.replace(/>/g, '>');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Message splitter ─────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export function splitMessage(text: string, maxLen = 4096): string[] {
|
|
35
|
+
if (text.length <= maxLen) return [text];
|
|
36
|
+
|
|
37
|
+
const chunks: string[] = [];
|
|
38
|
+
let remaining = text;
|
|
39
|
+
|
|
40
|
+
while (remaining.length > 0) {
|
|
41
|
+
if (remaining.length <= maxLen) {
|
|
42
|
+
chunks.push(remaining);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try to respect code blocks
|
|
47
|
+
const lastTriple = remaining.lastIndexOf('```', maxLen);
|
|
48
|
+
const prevTriple = remaining.lastIndexOf('```', maxLen - 3);
|
|
49
|
+
if (lastTriple > 0 && prevTriple < lastTriple) {
|
|
50
|
+
// We'd cut inside an open code block; split and balance
|
|
51
|
+
chunks.push(remaining.slice(0, lastTriple) + '\n```');
|
|
52
|
+
remaining = '```\n' + remaining.slice(lastTriple);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Try newline
|
|
57
|
+
let cutAt = -1;
|
|
58
|
+
const nl = remaining.lastIndexOf('\n', maxLen);
|
|
59
|
+
if (nl > maxLen * 0.3) {
|
|
60
|
+
cutAt = nl + 1;
|
|
61
|
+
} else {
|
|
62
|
+
const sp = remaining.lastIndexOf(' ', maxLen);
|
|
63
|
+
if (sp > maxLen * 0.3) {
|
|
64
|
+
cutAt = sp + 1;
|
|
65
|
+
} else {
|
|
66
|
+
cutAt = maxLen;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
chunks.push(remaining.slice(0, cutAt));
|
|
71
|
+
remaining = remaining.slice(cutAt);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return chunks;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Typing indicator manager ─────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
class TypingManager {
|
|
80
|
+
private timers = new Map<string, ReturnType<typeof setInterval>>();
|
|
81
|
+
private api: RawApi | null = null;
|
|
82
|
+
|
|
83
|
+
setApi(api: RawApi) {
|
|
84
|
+
this.api = api;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async start(chatId: string | number): Promise<void> {
|
|
88
|
+
const key = String(chatId);
|
|
89
|
+
if (!this.api || this.timers.has(key)) return;
|
|
90
|
+
try {
|
|
91
|
+
await this.api.sendChatAction({ chat_id: chatId, action: 'typing' });
|
|
92
|
+
} catch { /* ignore */ }
|
|
93
|
+
const timer = setInterval(async () => {
|
|
94
|
+
try {
|
|
95
|
+
await this.api!.sendChatAction({ chat_id: chatId, action: 'typing' });
|
|
96
|
+
} catch { /* ignore */ }
|
|
97
|
+
}, 4500);
|
|
98
|
+
this.timers.set(key, timer);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
stop(chatId: string | number): void {
|
|
102
|
+
const key = String(chatId);
|
|
103
|
+
const timer = this.timers.get(key);
|
|
104
|
+
if (timer) {
|
|
105
|
+
clearInterval(timer);
|
|
106
|
+
this.timers.delete(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
stopAll(): void {
|
|
111
|
+
for (const key of Array.from(this.timers.keys())) {
|
|
112
|
+
this.stop(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Send queue ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
class SendQueue {
|
|
120
|
+
private queue: (() => Promise<void>)[] = [];
|
|
121
|
+
private processing = false;
|
|
122
|
+
private minDelayMs: number;
|
|
123
|
+
|
|
124
|
+
constructor(minDelayMs = 35) {
|
|
125
|
+
this.minDelayMs = minDelayMs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
enqueue(fn: () => Promise<void>): void {
|
|
129
|
+
this.queue.push(fn);
|
|
130
|
+
void this.drain();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async drain(): Promise<void> {
|
|
134
|
+
if (this.processing) return;
|
|
135
|
+
this.processing = true;
|
|
136
|
+
while (this.queue.length > 0) {
|
|
137
|
+
const fn = this.queue.shift()!;
|
|
138
|
+
try {
|
|
139
|
+
await fn();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
logger.debug({ error: (err as Error).message }, 'SendQueue item failed');
|
|
142
|
+
}
|
|
143
|
+
await new Promise((r) => setTimeout(r, this.minDelayMs));
|
|
144
|
+
}
|
|
145
|
+
this.processing = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Tracked message (for streaming edits) ────────────────────────────
|
|
150
|
+
|
|
151
|
+
interface TrackedMessage {
|
|
152
|
+
chatId: string | number;
|
|
153
|
+
messageId: number;
|
|
154
|
+
lastEditAt: number;
|
|
155
|
+
currentText: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── TelegramChannel ──────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export class TelegramChannel implements ChannelAdapter {
|
|
161
|
+
name = 'telegram';
|
|
162
|
+
private bot: Bot | null = null;
|
|
163
|
+
private config: TelegramConfig;
|
|
164
|
+
private handlers: ((envelope: MessageEnvelope) => void)[] = [];
|
|
165
|
+
private running = false;
|
|
166
|
+
private typing = new TypingManager();
|
|
167
|
+
private sendQueue = new SendQueue();
|
|
168
|
+
private trackedMessages = new Map<string, TrackedMessage>();
|
|
169
|
+
|
|
170
|
+
private static STREAM_EDIT_INTERVAL_MS = 800;
|
|
171
|
+
private static STREAM_MIN_DELTA = 15;
|
|
172
|
+
|
|
173
|
+
constructor(config: TelegramConfig) {
|
|
174
|
+
this.config = config;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async start(): Promise<void> {
|
|
178
|
+
if (!this.config.botToken) {
|
|
179
|
+
logger.warn('Telegram bot token not configured, skipping');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.bot = new Bot(this.config.botToken);
|
|
184
|
+
this.typing.setApi(this.bot.api.raw);
|
|
185
|
+
|
|
186
|
+
this.bot.on('message:text', async (ctx) => {
|
|
187
|
+
await this.handleTextMessage(ctx);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Handle photos
|
|
191
|
+
this.bot.on('message:photo', async (ctx) => {
|
|
192
|
+
if (!this.isAllowed(ctx)) return;
|
|
193
|
+
const caption = ctx.message?.caption || '';
|
|
194
|
+
const photo = ctx.message?.photo;
|
|
195
|
+
const largest = photo?.[photo.length - 1];
|
|
196
|
+
let fileUrl: string | undefined;
|
|
197
|
+
try {
|
|
198
|
+
const f = await ctx.getFile();
|
|
199
|
+
if (f.file_path) {
|
|
200
|
+
fileUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${f.file_path}`;
|
|
201
|
+
}
|
|
202
|
+
} catch { /* ignore */ }
|
|
203
|
+
|
|
204
|
+
this.dispatchMessage(ctx, caption ? `[photo] ${caption}` : '[photo]', {
|
|
205
|
+
mediaType: 'photo',
|
|
206
|
+
fileUrl,
|
|
207
|
+
fileId: largest?.file_id,
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Handle documents
|
|
212
|
+
this.bot.on('message:document', async (ctx) => {
|
|
213
|
+
if (!this.isAllowed(ctx)) return;
|
|
214
|
+
const doc = ctx.message?.document;
|
|
215
|
+
let fileUrl: string | undefined;
|
|
216
|
+
try {
|
|
217
|
+
const f = await ctx.getFile();
|
|
218
|
+
if (f.file_path) {
|
|
219
|
+
fileUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${f.file_path}`;
|
|
220
|
+
}
|
|
221
|
+
} catch { /* ignore */ }
|
|
222
|
+
|
|
223
|
+
this.dispatchMessage(ctx, ctx.message?.caption || `[document: ${doc?.file_name || 'file'}]`, {
|
|
224
|
+
mediaType: 'document',
|
|
225
|
+
fileName: doc?.file_name,
|
|
226
|
+
mimeType: doc?.mime_type,
|
|
227
|
+
fileSize: doc?.file_size,
|
|
228
|
+
fileId: doc?.file_id,
|
|
229
|
+
fileUrl,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Handle voice
|
|
234
|
+
this.bot.on('message:voice', async (ctx) => {
|
|
235
|
+
if (!this.isAllowed(ctx)) return;
|
|
236
|
+
const voice = ctx.message?.voice;
|
|
237
|
+
let fileUrl: string | undefined;
|
|
238
|
+
try {
|
|
239
|
+
const f = await ctx.getFile();
|
|
240
|
+
if (f.file_path) {
|
|
241
|
+
fileUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${f.file_path}`;
|
|
242
|
+
}
|
|
243
|
+
} catch { /* ignore */ }
|
|
244
|
+
|
|
245
|
+
this.dispatchMessage(ctx, '[voice message]', {
|
|
246
|
+
mediaType: 'voice',
|
|
247
|
+
duration: voice?.duration,
|
|
248
|
+
fileId: voice?.file_id,
|
|
249
|
+
fileUrl,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this.bot.on('edited_message:text', async () => {
|
|
254
|
+
logger.info('Telegram message edited (noted, not re-processed)');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this.bot.on('message_reaction', async (ctx) => {
|
|
258
|
+
logger.info(
|
|
259
|
+
{ userId: ctx.update.message_reaction?.user?.id },
|
|
260
|
+
'Telegram reaction received',
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Handle callback queries (inline keyboard presses)
|
|
265
|
+
this.bot.on('callback_query:data', async (ctx) => {
|
|
266
|
+
const data = ctx.callbackQuery.data;
|
|
267
|
+
const chatId = ctx.chat?.id;
|
|
268
|
+
if (!chatId) return;
|
|
269
|
+
const userId = String(ctx.from.id);
|
|
270
|
+
|
|
271
|
+
await ctx.answerCallbackQuery();
|
|
272
|
+
|
|
273
|
+
const envelope: MessageEnvelope = {
|
|
274
|
+
id: nanoid(12),
|
|
275
|
+
from: userId,
|
|
276
|
+
text: data,
|
|
277
|
+
channelId: `telegram:${chatId}`,
|
|
278
|
+
metadata: {
|
|
279
|
+
channelType: 'telegram',
|
|
280
|
+
chatId: String(chatId),
|
|
281
|
+
userId,
|
|
282
|
+
username: ctx.from.username,
|
|
283
|
+
isCallback: true,
|
|
284
|
+
callbackQueryId: ctx.callbackQuery.id,
|
|
285
|
+
},
|
|
286
|
+
timestamp: Date.now(),
|
|
287
|
+
};
|
|
288
|
+
for (const h of this.handlers) h(envelope);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
this.bot.catch((err) => {
|
|
292
|
+
logger.error({ error: err.message }, 'Telegram bot error');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
logger.info('Starting Telegram bot (long polling)...');
|
|
296
|
+
await this.bot.start({
|
|
297
|
+
onStart: (info) => {
|
|
298
|
+
logger.info({ username: info.username }, 'Telegram bot started');
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
this.running = true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private isAllowed(ctx: Context): boolean {
|
|
306
|
+
const userId = String(ctx.from?.id);
|
|
307
|
+
if (this.config.allowedUsers.length > 0 && !this.config.allowedUsers.includes(userId)) {
|
|
308
|
+
logger.warn({ userId }, 'Unauthorized Telegram user, ignoring');
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private dispatchMessage(
|
|
315
|
+
ctx: Context,
|
|
316
|
+
text: string,
|
|
317
|
+
extraMeta: Record<string, unknown> = {},
|
|
318
|
+
): void {
|
|
319
|
+
const userId = String(ctx.from!.id);
|
|
320
|
+
const chatId = String(ctx.chat!.id);
|
|
321
|
+
|
|
322
|
+
const envelope: MessageEnvelope = {
|
|
323
|
+
id: nanoid(12),
|
|
324
|
+
from: userId,
|
|
325
|
+
text,
|
|
326
|
+
channelId: `telegram:${chatId}`,
|
|
327
|
+
metadata: {
|
|
328
|
+
channelType: 'telegram',
|
|
329
|
+
chatId,
|
|
330
|
+
userId,
|
|
331
|
+
username: ctx.from?.username,
|
|
332
|
+
messageId: ctx.message?.message_id,
|
|
333
|
+
...extraMeta,
|
|
334
|
+
},
|
|
335
|
+
timestamp: Date.now(),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
for (const h of this.handlers) h(envelope);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private async handleTextMessage(ctx: Context): Promise<void> {
|
|
342
|
+
if (!this.isAllowed(ctx)) return;
|
|
343
|
+
this.dispatchMessage(ctx, ctx.message?.text || '');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
stop(): Promise<void> {
|
|
347
|
+
this.running = false;
|
|
348
|
+
this.typing.stopAll();
|
|
349
|
+
if (this.bot) {
|
|
350
|
+
this.bot.stop();
|
|
351
|
+
this.bot = null;
|
|
352
|
+
}
|
|
353
|
+
return Promise.resolve();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
onMessage(handler: (envelope: MessageEnvelope) => void): void {
|
|
357
|
+
this.handlers.push(handler);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Typing indicators ───────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
async startTyping(chatId: string): Promise<void> {
|
|
363
|
+
await this.typing.start(chatId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
stopTyping(chatId: string): void {
|
|
367
|
+
this.typing.stop(chatId);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
private resolveChatId(envelope: MessageEnvelope): string | number | undefined {
|
|
373
|
+
const raw = (envelope.metadata?.chatId || envelope.channelId?.replace('telegram:', '')) as string | undefined;
|
|
374
|
+
if (!raw) return undefined;
|
|
375
|
+
const num = Number(raw);
|
|
376
|
+
return Number.isNaN(num) ? raw : num;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Core send ───────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
async send(envelope: MessageEnvelope, response: string): Promise<void> {
|
|
382
|
+
if (!this.bot) {
|
|
383
|
+
logger.error('Telegram bot not running, cannot send');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const chatId = this.resolveChatId(envelope);
|
|
388
|
+
if (!chatId) {
|
|
389
|
+
logger.error({ envelope: envelope.id }, 'No chatId in envelope');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const replyToId = envelope.metadata?.messageId as number | undefined;
|
|
394
|
+
const chunks = splitMessage(response, 4096);
|
|
395
|
+
|
|
396
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
397
|
+
await this.sendChunk(chatId, chunks[i], i === 0 ? replyToId : undefined);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async sendChunk(
|
|
402
|
+
chatId: string | number,
|
|
403
|
+
text: string,
|
|
404
|
+
replyToId?: number,
|
|
405
|
+
replyMarkup?: GrammyInlineKeyboard,
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
if (!this.bot) return;
|
|
408
|
+
|
|
409
|
+
const baseOpts = {
|
|
410
|
+
...(replyToId ? { reply_to_message_id: replyToId } : {}),
|
|
411
|
+
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
|
412
|
+
link_preview_options: { is_disabled: true } as const,
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Try MarkdownV2 → HTML → plain
|
|
416
|
+
for (const parse_mode of ['MarkdownV2', 'HTML', undefined] as const) {
|
|
417
|
+
try {
|
|
418
|
+
await this.bot.api.sendMessage(chatId, text, {
|
|
419
|
+
...baseOpts,
|
|
420
|
+
...(parse_mode ? { parse_mode } : {}),
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
} catch {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
logger.error({ chatId }, 'All parse modes failed for message');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Send with inline keyboard ───────────────────────────────────
|
|
432
|
+
|
|
433
|
+
async sendWithKeyboard(
|
|
434
|
+
envelope: MessageEnvelope,
|
|
435
|
+
response: string,
|
|
436
|
+
keyboard: InlineKeyboard,
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
if (!this.bot) return;
|
|
439
|
+
|
|
440
|
+
const chatId = this.resolveChatId(envelope);
|
|
441
|
+
if (!chatId) return;
|
|
442
|
+
|
|
443
|
+
const replyMarkup = new GrammyInlineKeyboard(
|
|
444
|
+
keyboard.buttons.map((row) =>
|
|
445
|
+
row.map((btn) => {
|
|
446
|
+
if (btn.url) return { text: btn.text, url: btn.url };
|
|
447
|
+
return { text: btn.text, callback_data: btn.callback_data || btn.text };
|
|
448
|
+
}),
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const replyToId = envelope.metadata?.messageId as number | undefined;
|
|
453
|
+
|
|
454
|
+
for (const parse_mode of ['MarkdownV2', 'HTML', undefined] as const) {
|
|
455
|
+
try {
|
|
456
|
+
await this.bot.api.sendMessage(chatId, response, {
|
|
457
|
+
...(parse_mode ? { parse_mode } : {}),
|
|
458
|
+
reply_markup: replyMarkup,
|
|
459
|
+
link_preview_options: { is_disabled: true },
|
|
460
|
+
...(replyToId ? { reply_to_message_id: replyToId } : {}),
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
} catch {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Send media ──────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
async sendMedia(envelope: MessageEnvelope, media: MediaAttachment): Promise<void> {
|
|
472
|
+
if (!this.bot) return;
|
|
473
|
+
|
|
474
|
+
const chatId = this.resolveChatId(envelope);
|
|
475
|
+
if (!chatId) return;
|
|
476
|
+
|
|
477
|
+
const opts = media.caption ? { caption: media.caption } : {};
|
|
478
|
+
const source: string | InputFile = typeof media.data === 'string'
|
|
479
|
+
? media.data
|
|
480
|
+
: new InputFile(media.data, media.filename);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
switch (media.type) {
|
|
484
|
+
case 'photo':
|
|
485
|
+
await this.bot.api.sendPhoto(chatId, source, opts);
|
|
486
|
+
break;
|
|
487
|
+
case 'document':
|
|
488
|
+
await this.bot.api.sendDocument(chatId, source, {
|
|
489
|
+
...opts,
|
|
490
|
+
...(media.filename ? { filename: media.filename } : {}),
|
|
491
|
+
});
|
|
492
|
+
break;
|
|
493
|
+
case 'voice':
|
|
494
|
+
await this.bot.api.sendVoice(chatId, source, opts);
|
|
495
|
+
break;
|
|
496
|
+
case 'video':
|
|
497
|
+
await this.bot.api.sendVideo(chatId, source, opts);
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
logger.error(
|
|
502
|
+
{ error: (err as Error).message, chatId, type: media.type },
|
|
503
|
+
'Failed to send media',
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Streaming support ───────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
async startStreaming(envelope: MessageEnvelope, initialText = '…'): Promise<string> {
|
|
511
|
+
if (!this.bot) return '';
|
|
512
|
+
|
|
513
|
+
const chatId = this.resolveChatId(envelope);
|
|
514
|
+
if (!chatId) return '';
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const msg = await this.bot.api.sendMessage(chatId, initialText, {
|
|
518
|
+
reply_to_message_id: envelope.metadata?.messageId as number | undefined,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const trackId = `${chatId}:${msg.message_id}`;
|
|
522
|
+
this.trackedMessages.set(trackId, {
|
|
523
|
+
chatId,
|
|
524
|
+
messageId: msg.message_id,
|
|
525
|
+
lastEditAt: 0,
|
|
526
|
+
currentText: initialText,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
return trackId;
|
|
530
|
+
} catch (err) {
|
|
531
|
+
logger.error({ error: (err as Error).message }, 'Failed to start streaming message');
|
|
532
|
+
return '';
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async editStreamingMessage(trackId: string, text: string): Promise<void> {
|
|
537
|
+
const tracked = this.trackedMessages.get(trackId);
|
|
538
|
+
if (!tracked || !this.bot) return;
|
|
539
|
+
|
|
540
|
+
const now = Date.now();
|
|
541
|
+
const delta = Math.abs(text.length - tracked.currentText.length);
|
|
542
|
+
|
|
543
|
+
if (
|
|
544
|
+
now - tracked.lastEditAt < TelegramChannel.STREAM_EDIT_INTERVAL_MS &&
|
|
545
|
+
delta < TelegramChannel.STREAM_MIN_DELTA
|
|
546
|
+
) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Update tracked text even if edit fails (to track delta for next attempt)
|
|
551
|
+
tracked.currentText = text;
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
await this.bot.api.editMessageText(tracked.chatId, tracked.messageId, text, {
|
|
555
|
+
parse_mode: 'MarkdownV2',
|
|
556
|
+
link_preview_options: { is_disabled: true },
|
|
557
|
+
});
|
|
558
|
+
tracked.lastEditAt = now;
|
|
559
|
+
} catch {
|
|
560
|
+
// Silently ignore (message unchanged, rate limit, parse error)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async finishStreaming(trackId: string, finalText: string): Promise<void> {
|
|
565
|
+
const tracked = this.trackedMessages.get(trackId);
|
|
566
|
+
if (!tracked || !this.bot) return;
|
|
567
|
+
|
|
568
|
+
const chunks = splitMessage(finalText, 4096);
|
|
569
|
+
|
|
570
|
+
// Edit tracked message with first chunk
|
|
571
|
+
if (chunks.length > 0) {
|
|
572
|
+
for (const parse_mode of ['MarkdownV2', 'HTML', undefined] as const) {
|
|
573
|
+
try {
|
|
574
|
+
await this.bot.api.editMessageText(tracked.chatId, tracked.messageId, chunks[0], {
|
|
575
|
+
...(parse_mode ? { parse_mode } : {}),
|
|
576
|
+
link_preview_options: { is_disabled: true },
|
|
577
|
+
});
|
|
578
|
+
break;
|
|
579
|
+
} catch {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Send remaining chunks
|
|
586
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
587
|
+
await this.sendChunk(tracked.chatId, chunks[i]);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
this.trackedMessages.delete(trackId);
|
|
591
|
+
}
|
|
592
|
+
}
|