tg-agent 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/dist/index.js ADDED
@@ -0,0 +1,954 @@
1
+ import TelegramBot from "node-telegram-bot-api";
2
+ import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
3
+ import { getOAuthProviders } from "@mariozechner/pi-ai";
4
+ import { config, assertConfig } from "./config.js";
5
+ import { resolveApiKeyForProvider, resolveProxyInfo } from "./auth.js";
6
+ import { applyFetchProxy } from "./proxy.js";
7
+ import { runPiAgentPrompt } from "./piAgentRunner.js";
8
+ import { readCodexOAuth } from "./codexAuth.js";
9
+ import { formatMcpTarget, loadMcpServers, loadMcpServersSync, probeMcpServer } from "./mcp.js";
10
+ import { appendMessage, closeSession, createSession, deleteSessionFile, getActiveSession, listSessions, loadUserState, pruneExpiredSessions, resetSession, saveUserState, setActiveSession, } from "./sessionStore.js";
11
+ import { chunkText, createQueueMap, createSemaphore, ensureDir, nowMs } from "./utils.js";
12
+ assertConfig();
13
+ const bot = new TelegramBot(config.telegramToken, { polling: true });
14
+ const enqueueUser = createQueueMap();
15
+ const withSemaphore = createSemaphore(config.maxConcurrent);
16
+ const activeRuns = new Map();
17
+ function registerActiveRun(userId, run) {
18
+ activeRuns.set(userId, run);
19
+ }
20
+ function clearActiveRun(userId) {
21
+ activeRuns.delete(userId);
22
+ }
23
+ function resolveTelegramParseMode(value) {
24
+ const trimmed = value.trim();
25
+ if (!trimmed) {
26
+ return undefined;
27
+ }
28
+ if (trimmed === "Markdown" || trimmed === "MarkdownV2" || trimmed === "HTML") {
29
+ return trimmed;
30
+ }
31
+ console.warn(`[tg-agent] invalid TELEGRAM_PARSE_MODE=${trimmed}, ignoring`);
32
+ return undefined;
33
+ }
34
+ const MARKDOWN_V2_RESERVED = new Set([
35
+ "_",
36
+ "*",
37
+ "[",
38
+ "]",
39
+ "(",
40
+ ")",
41
+ "~",
42
+ "`",
43
+ ">",
44
+ "#",
45
+ "+",
46
+ "-",
47
+ "=",
48
+ "|",
49
+ "{",
50
+ "}",
51
+ ".",
52
+ "!",
53
+ ]);
54
+ function countUnescaped(text, target) {
55
+ let count = 0;
56
+ for (let i = 0; i < text.length; i += 1) {
57
+ const char = text[i];
58
+ if (char === "\\") {
59
+ i += 1;
60
+ continue;
61
+ }
62
+ if (char === target) {
63
+ count += 1;
64
+ }
65
+ }
66
+ return count;
67
+ }
68
+ function isMarkdownSafe(text) {
69
+ const stars = countUnescaped(text, "*");
70
+ if (stars % 2 !== 0)
71
+ return false;
72
+ const underscores = countUnescaped(text, "_");
73
+ if (underscores % 2 !== 0)
74
+ return false;
75
+ const backticks = countUnescaped(text, "`");
76
+ if (backticks % 2 !== 0)
77
+ return false;
78
+ const left = countUnescaped(text, "[");
79
+ const right = countUnescaped(text, "]");
80
+ if (left !== right)
81
+ return false;
82
+ return true;
83
+ }
84
+ function isMarkdownV2Safe(text) {
85
+ for (let i = 0; i < text.length; i += 1) {
86
+ const char = text[i];
87
+ if (char === "\\") {
88
+ i += 1;
89
+ continue;
90
+ }
91
+ if (MARKDOWN_V2_RESERVED.has(char)) {
92
+ return false;
93
+ }
94
+ }
95
+ return true;
96
+ }
97
+ function resolveBestParseModes(text) {
98
+ const configured = resolveTelegramParseMode(config.telegramParseMode);
99
+ if (configured) {
100
+ return [configured];
101
+ }
102
+ if (isMarkdownV2Safe(text)) {
103
+ return ["MarkdownV2", "Markdown"];
104
+ }
105
+ if (isMarkdownSafe(text)) {
106
+ return ["Markdown"];
107
+ }
108
+ return [];
109
+ }
110
+ const AUTH_PROMPT_TIMEOUT_MS = 10 * 60 * 1000;
111
+ const authPromptState = new Map();
112
+ const authInFlight = new Set();
113
+ const PROVIDER_ALIASES = new Map([
114
+ ["codex", "openai-codex"],
115
+ ["antigravity", "google-antigravity"],
116
+ ["gemini", "google-gemini-cli"],
117
+ ["gemini-cli", "google-gemini-cli"],
118
+ ]);
119
+ const CODEX_MODEL_ALIASES = {
120
+ "codex-mini-latest": "gpt-5.1-codex-mini",
121
+ "codex-max-latest": "gpt-5.1-codex-max",
122
+ "codex-latest": "gpt-5.2-codex",
123
+ };
124
+ function normalizeProviderId(input) {
125
+ const trimmed = input.trim().toLowerCase();
126
+ return PROVIDER_ALIASES.get(trimmed) ?? trimmed;
127
+ }
128
+ function normalizeModelId(provider, modelId) {
129
+ if (provider !== "openai-codex") {
130
+ return modelId;
131
+ }
132
+ return CODEX_MODEL_ALIASES[modelId] ?? modelId;
133
+ }
134
+ async function loadModelRegistry() {
135
+ await ensureDir(config.agentDir);
136
+ const authStorage = discoverAuthStorage(config.agentDir);
137
+ const codex = readCodexOAuth();
138
+ if (codex) {
139
+ authStorage.setRuntimeApiKey("openai-codex", codex.accessToken);
140
+ }
141
+ const modelRegistry = discoverModels(authStorage, config.agentDir);
142
+ return { authStorage, modelRegistry };
143
+ }
144
+ function clearAuthPrompt(userId) {
145
+ const pending = authPromptState.get(userId);
146
+ if (!pending)
147
+ return;
148
+ clearTimeout(pending.timeoutId);
149
+ authPromptState.delete(userId);
150
+ }
151
+ function cancelAuthPrompt(userId, reason) {
152
+ const pending = authPromptState.get(userId);
153
+ if (!pending)
154
+ return;
155
+ clearTimeout(pending.timeoutId);
156
+ authPromptState.delete(userId);
157
+ pending.reject(new Error(reason));
158
+ }
159
+ async function promptForAuthInput(userId, chatId, prompt) {
160
+ if (authPromptState.has(userId)) {
161
+ throw new Error("Login is already awaiting input.");
162
+ }
163
+ const text = prompt.placeholder ? `${prompt.message} (${prompt.placeholder})` : prompt.message;
164
+ await sendLongMessage(chatId, text);
165
+ return await new Promise((resolve, reject) => {
166
+ const timeoutId = setTimeout(() => {
167
+ authPromptState.delete(userId);
168
+ reject(new Error("Login prompt timed out."));
169
+ }, AUTH_PROMPT_TIMEOUT_MS);
170
+ authPromptState.set(userId, { resolve, reject, timeoutId, chatId });
171
+ });
172
+ }
173
+ async function handleStopCommand(chatId, userId) {
174
+ const active = activeRuns.get(userId);
175
+ if (!active) {
176
+ await sendLongMessage(chatId, "No active request to stop.");
177
+ return;
178
+ }
179
+ active.cancelRequested = true;
180
+ active.abortRequested = true;
181
+ if (active.status) {
182
+ active.status.update("Cancelled by user.", true);
183
+ }
184
+ if (active.abort) {
185
+ active.abort();
186
+ }
187
+ if (!active.status || active.chatId !== chatId) {
188
+ await sendLongMessage(chatId, "Stopping current request...");
189
+ }
190
+ }
191
+ function logStartupInfo() {
192
+ const proxyVars = {
193
+ https_proxy: process.env.https_proxy ?? process.env.HTTPS_PROXY ?? "",
194
+ http_proxy: process.env.http_proxy ?? process.env.HTTP_PROXY ?? "",
195
+ all_proxy: process.env.all_proxy ?? process.env.ALL_PROXY ?? "",
196
+ no_proxy: process.env.no_proxy ?? process.env.NO_PROXY ?? "",
197
+ };
198
+ const tlsVars = {
199
+ NODE_EXTRA_CA_CERTS: process.env.NODE_EXTRA_CA_CERTS ?? "",
200
+ NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED ?? "",
201
+ };
202
+ console.log(`[tg-agent] modelProvider=${config.modelProvider} modelRef=${config.modelRef} sessionDir=${config.sessionDir} maxConcurrent=${config.maxConcurrent}`);
203
+ console.log(`[tg-agent] agentDir=${config.agentDir} workspaceDir=${config.workspaceDir}`);
204
+ const mcpServers = loadMcpServersSync(config.agentDir);
205
+ const mcpStatus = mcpServers.length > 0 ? `on (${mcpServers.length})` : "off";
206
+ console.log(`[tg-agent] tools fetchMaxBytes=${config.fetchMaxBytes} fetchTimeoutMs=${config.fetchTimeoutMs} mcp=${mcpStatus}`);
207
+ console.log(`[tg-agent] proxy https=${proxyVars.https_proxy || "(empty)"} http=${proxyVars.http_proxy || "(empty)"} all=${proxyVars.all_proxy || "(empty)"} no=${proxyVars.no_proxy || "(empty)"}`);
208
+ console.log(`[tg-agent] tls extra_ca=${tlsVars.NODE_EXTRA_CA_CERTS || "(empty)"} reject_unauthorized=${tlsVars.NODE_TLS_REJECT_UNAUTHORIZED || "(empty)"}`);
209
+ try {
210
+ const { source } = resolveApiKeyForProvider(config.modelProvider);
211
+ console.log(`[tg-agent] authSource=${source}`);
212
+ }
213
+ catch (error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ console.warn(`[tg-agent] authSource=missing (${message})`);
216
+ }
217
+ const proxyInfo = resolveProxyInfo();
218
+ if (proxyInfo) {
219
+ console.log(`[tg-agent] proxyUrl=${proxyInfo.url} kind=${proxyInfo.kind} source=${proxyInfo.source}`);
220
+ }
221
+ else {
222
+ console.log("[tg-agent] proxyUrl=(none)");
223
+ }
224
+ }
225
+ logStartupInfo();
226
+ void ensureDir(config.agentDir).catch((error) => {
227
+ console.warn(`[tg-agent] ensure agentDir failed: ${error instanceof Error ? error.message : String(error)}`);
228
+ });
229
+ const appliedProxy = applyFetchProxy();
230
+ if (appliedProxy) {
231
+ console.log(`[tg-agent] fetchProxy=${appliedProxy.url} kind=${appliedProxy.kind} source=${appliedProxy.source}`);
232
+ }
233
+ else {
234
+ console.log("[tg-agent] fetchProxy=(none)");
235
+ }
236
+ function parseCommand(text) {
237
+ if (!text.startsWith("/")) {
238
+ return null;
239
+ }
240
+ const trimmed = text.trim();
241
+ const [rawCommand, ...rest] = trimmed.split(" ");
242
+ const command = rawCommand.split("@")[0].slice(1).toLowerCase();
243
+ return { command, args: rest.join(" ").trim() };
244
+ }
245
+ async function ensureActiveSessionForCommand(state, chatId) {
246
+ let session = getActiveSession(state);
247
+ if (!session) {
248
+ session = createSession(state, "");
249
+ await saveUserState(state);
250
+ await sendLongMessage(chatId, `Created session ${session.id}.`);
251
+ }
252
+ return session;
253
+ }
254
+ function formatProviders(modelRegistry, authStorage) {
255
+ const models = modelRegistry.getAll();
256
+ const providerStats = new Map();
257
+ for (const model of models) {
258
+ const entry = providerStats.get(model.provider) ?? { count: 0, auth: authStorage.hasAuth(model.provider) };
259
+ entry.count += 1;
260
+ providerStats.set(model.provider, entry);
261
+ }
262
+ if (providerStats.size === 0) {
263
+ return "No providers found.";
264
+ }
265
+ const lines = Array.from(providerStats.entries())
266
+ .sort((a, b) => a[0].localeCompare(b[0]))
267
+ .map(([provider, info]) => `- ${provider} (models: ${info.count}, auth: ${info.auth ? "ok" : "missing"})`);
268
+ return ["Providers:", ...lines, "", "Use /models <provider> to list models."].join("\n");
269
+ }
270
+ function formatModelsForProvider(modelRegistry, provider, authOk) {
271
+ const models = modelRegistry.getAll().filter((model) => model.provider === provider);
272
+ if (models.length === 0) {
273
+ return `No models found for provider ${provider}.`;
274
+ }
275
+ const lines = models.map((model) => `- ${model.id} | ${model.name}`);
276
+ return [`Models for ${provider} (auth: ${authOk ? "ok" : "missing"}):`, ...lines].join("\n");
277
+ }
278
+ function parseModelSelector(raw) {
279
+ const trimmed = raw.trim();
280
+ if (!trimmed)
281
+ return null;
282
+ if (trimmed.includes("/")) {
283
+ const [provider, modelId] = trimmed.split("/", 2);
284
+ if (!provider || !modelId)
285
+ return null;
286
+ return { provider: normalizeProviderId(provider), modelId: modelId.trim() };
287
+ }
288
+ return { modelId: trimmed };
289
+ }
290
+ const unauthorizedLogWindowMs = 60_000;
291
+ const unauthorizedLogState = new Map();
292
+ function logUnauthorizedUser(userId, chatId) {
293
+ const now = Date.now();
294
+ const lastLoggedAt = unauthorizedLogState.get(userId) ?? 0;
295
+ if (now - lastLoggedAt < unauthorizedLogWindowMs) {
296
+ return;
297
+ }
298
+ unauthorizedLogState.set(userId, now);
299
+ console.warn(`[tg-agent] unauthorized user=${userId} chat=${chatId}`);
300
+ }
301
+ function isUserAllowed(userId) {
302
+ const allowed = config.telegramAllowedUsers;
303
+ if (!allowed || allowed.size === 0) {
304
+ return true;
305
+ }
306
+ return allowed.has(userId);
307
+ }
308
+ function formatSessions(stateSessions) {
309
+ if (stateSessions.length === 0) {
310
+ return "No sessions found.";
311
+ }
312
+ const lines = stateSessions.map((session) => {
313
+ const updated = new Date(session.updatedAt).toISOString();
314
+ return `- ${session.id} | ${session.title} | updated ${updated}`;
315
+ });
316
+ return ["Sessions:", ...lines].join("\n");
317
+ }
318
+ async function sendTelegramMessage(chatId, text, parseMode) {
319
+ if (parseMode) {
320
+ return await bot.sendMessage(chatId, text, { parse_mode: parseMode });
321
+ }
322
+ return await bot.sendMessage(chatId, text);
323
+ }
324
+ async function editTelegramMessage(chatId, messageId, text, parseMode) {
325
+ if (parseMode) {
326
+ await bot.editMessageText(text, { chat_id: chatId, message_id: messageId, parse_mode: parseMode });
327
+ return;
328
+ }
329
+ await bot.editMessageText(text, { chat_id: chatId, message_id: messageId });
330
+ }
331
+ async function sendWithParseModes(attempt, parseModes) {
332
+ let lastError;
333
+ for (const mode of parseModes) {
334
+ try {
335
+ return await attempt(mode);
336
+ }
337
+ catch (error) {
338
+ lastError = error;
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ console.warn(`[tg-agent] parse_mode ${mode} failed, trying fallback: ${message}`);
341
+ }
342
+ }
343
+ try {
344
+ return await attempt(undefined);
345
+ }
346
+ catch (error) {
347
+ if (lastError) {
348
+ const message = error instanceof Error ? error.message : String(error);
349
+ console.warn(`[tg-agent] parse_mode fallback to plain failed: ${message}`);
350
+ }
351
+ throw error;
352
+ }
353
+ }
354
+ async function sendTelegramMessageBest(chatId, text) {
355
+ return await sendWithParseModes((parseMode) => sendTelegramMessage(chatId, text, parseMode), resolveBestParseModes(text));
356
+ }
357
+ async function editTelegramMessageBest(chatId, messageId, text) {
358
+ await sendWithParseModes((parseMode) => editTelegramMessage(chatId, messageId, text, parseMode), resolveBestParseModes(text));
359
+ }
360
+ async function sendLongMessage(chatId, text) {
361
+ const chunks = chunkText(text, 3900);
362
+ for (const chunk of chunks) {
363
+ await sendTelegramMessageBest(chatId, chunk);
364
+ }
365
+ }
366
+ const TELEGRAM_MAX_LEN = 3900;
367
+ const STATUS_UPDATE_MIN_MS = 1200;
368
+ function truncateForTelegram(text, maxLen = TELEGRAM_MAX_LEN) {
369
+ if (text.length <= maxLen) {
370
+ return { text, truncated: false };
371
+ }
372
+ const slice = text.slice(0, Math.max(0, maxLen - 15));
373
+ return { text: `${slice}...\n\n[truncated]`, truncated: true };
374
+ }
375
+ async function createStatusReporter(chatId) {
376
+ const initial = "Working...";
377
+ const sent = await sendTelegramMessage(chatId, initial);
378
+ const messageId = sent.message_id;
379
+ let lastText = initial;
380
+ let lastSentAt = 0;
381
+ let chain = Promise.resolve();
382
+ const enqueueEdit = (text, force = false) => {
383
+ const now = Date.now();
384
+ if (!force && now - lastSentAt < STATUS_UPDATE_MIN_MS)
385
+ return;
386
+ if (text === lastText)
387
+ return;
388
+ lastText = text;
389
+ lastSentAt = now;
390
+ chain = chain
391
+ .then(async () => {
392
+ await editTelegramMessage(chatId, messageId, text);
393
+ })
394
+ .catch((error) => {
395
+ console.warn("[tg-agent] status edit failed", error);
396
+ });
397
+ };
398
+ const enqueueEditBest = (text) => {
399
+ lastText = text;
400
+ lastSentAt = Date.now();
401
+ chain = chain
402
+ .then(async () => {
403
+ await editTelegramMessageBest(chatId, messageId, text);
404
+ })
405
+ .catch((error) => {
406
+ console.warn("[tg-agent] status edit failed", error);
407
+ });
408
+ };
409
+ return {
410
+ update: (text, force = false) => {
411
+ const trimmed = truncateForTelegram(text).text;
412
+ enqueueEdit(trimmed, force);
413
+ },
414
+ finalize: async (text) => {
415
+ const trimmed = truncateForTelegram(text).text;
416
+ enqueueEditBest(trimmed);
417
+ await chain;
418
+ },
419
+ fail: async (text) => {
420
+ const trimmed = truncateForTelegram(text).text;
421
+ enqueueEditBest(trimmed);
422
+ await chain;
423
+ },
424
+ };
425
+ }
426
+ function formatStatusEvent(event) {
427
+ switch (event.type) {
428
+ case "agent_start":
429
+ return "Working...";
430
+ case "tool_start":
431
+ return `Running tool: ${event.name}\nargs: ${summarizeArgs(event.args)}`;
432
+ case "tool_end":
433
+ return event.ok
434
+ ? `Tool finished: ${event.name} (${event.durationMs}ms)`
435
+ : `Tool failed: ${event.name} (${event.durationMs}ms)`;
436
+ case "message_start":
437
+ return event.role === "assistant" ? "Generating reply..." : null;
438
+ case "message_end":
439
+ return event.role === "assistant" ? "Finalizing reply..." : null;
440
+ case "heartbeat": {
441
+ const seconds = Math.max(1, Math.round(event.elapsedMs / 1000));
442
+ return `Working... ${seconds}s`;
443
+ }
444
+ default:
445
+ return null;
446
+ }
447
+ }
448
+ function summarizeArgs(args) {
449
+ try {
450
+ const seen = new WeakSet();
451
+ const maxString = 200;
452
+ const maxKeys = 12;
453
+ const maxArray = 12;
454
+ const maxDepth = 3;
455
+ const normalize = (value, depth) => {
456
+ if (value === null || value === undefined)
457
+ return value;
458
+ if (typeof value === "string") {
459
+ return value.length > maxString ? `${value.slice(0, maxString)}...` : value;
460
+ }
461
+ if (typeof value === "number" || typeof value === "boolean")
462
+ return value;
463
+ if (typeof value === "bigint")
464
+ return value.toString();
465
+ if (typeof value === "function")
466
+ return "[function]";
467
+ if (typeof value !== "object")
468
+ return String(value);
469
+ if (seen.has(value))
470
+ return "[circular]";
471
+ if (depth >= maxDepth)
472
+ return "[truncated]";
473
+ seen.add(value);
474
+ if (Array.isArray(value)) {
475
+ const items = value.slice(0, maxArray).map((item) => normalize(item, depth + 1));
476
+ if (value.length > maxArray)
477
+ items.push("[truncated]");
478
+ return items;
479
+ }
480
+ const entries = Object.entries(value);
481
+ const result = {};
482
+ for (const [key, val] of entries.slice(0, maxKeys)) {
483
+ result[key] = normalize(val, depth + 1);
484
+ }
485
+ if (entries.length > maxKeys) {
486
+ result._truncated = true;
487
+ }
488
+ return result;
489
+ };
490
+ const normalized = normalize(args, 0);
491
+ const json = JSON.stringify(normalized);
492
+ if (!json)
493
+ return "null";
494
+ if (json.length > 700) {
495
+ return `${json.slice(0, 700)}...`;
496
+ }
497
+ return json;
498
+ }
499
+ catch (error) {
500
+ return "[unavailable]";
501
+ }
502
+ }
503
+ function formatErrorMessage(error) {
504
+ const base = error instanceof Error ? error.message : String(error);
505
+ const cause = error?.cause;
506
+ if (cause instanceof Error) {
507
+ return `${base} (${cause.message})`;
508
+ }
509
+ if (cause) {
510
+ return `${base} (${String(cause)})`;
511
+ }
512
+ return base;
513
+ }
514
+ function truncateLine(text, maxLen = 140) {
515
+ if (text.length <= maxLen) {
516
+ return text;
517
+ }
518
+ return `${text.slice(0, maxLen - 3)}...`;
519
+ }
520
+ async function formatMcpStatus() {
521
+ const servers = await loadMcpServers(config.agentDir);
522
+ if (servers.length === 0) {
523
+ return "No MCP servers configured. Add [mcp_servers.*] to ~/.tg-agent/config.toml.";
524
+ }
525
+ const probes = await Promise.all(servers.map((server) => probeMcpServer(server)));
526
+ const lines = ["MCP servers:"];
527
+ for (let i = 0; i < servers.length; i += 1) {
528
+ const server = servers[i];
529
+ const probe = probes[i];
530
+ const status = probe.ok ? "ok" : `error: ${truncateLine(probe.error ?? "unknown")}`;
531
+ lines.push(`- ${server.name} (${server.type}) ${formatMcpTarget(server)} status=${status} (${probe.durationMs}ms)`);
532
+ }
533
+ return lines.join("\n");
534
+ }
535
+ async function handleCommand(chatId, userId, command, args) {
536
+ const state = await loadUserState(userId);
537
+ const removed = pruneExpiredSessions(state);
538
+ if (removed.length > 0) {
539
+ await saveUserState(state);
540
+ await Promise.all(removed.map((sessionId) => deleteSessionFile(userId, sessionId).catch((error) => {
541
+ console.warn(`[tg-agent] cleanup session file failed id=${sessionId}`, error);
542
+ })));
543
+ }
544
+ switch (command) {
545
+ case "start":
546
+ case "help": {
547
+ const helpText = [
548
+ "Commands:",
549
+ "/new [title] - create a new session",
550
+ "/list - list sessions",
551
+ "/use <id> - switch active session",
552
+ "/close [id] - close a session (default: active)",
553
+ "/reset - clear active session history",
554
+ "/providers - list available providers",
555
+ "/models [provider] - list models for provider",
556
+ "/provider <name> - set provider for current session",
557
+ "/model <provider>/<model> - set model for current session",
558
+ "/mcp - list configured MCP servers",
559
+ "/status - show session model settings",
560
+ "/login <provider> - login to OAuth provider",
561
+ "/logout <provider> - logout from provider",
562
+ "/stop - stop the current running request",
563
+ ].join("\n");
564
+ await sendLongMessage(chatId, helpText);
565
+ return;
566
+ }
567
+ case "new": {
568
+ try {
569
+ const title = args || "";
570
+ const session = createSession(state, title);
571
+ await saveUserState(state);
572
+ await sendLongMessage(chatId, `Created session ${session.id} (${session.title}).`);
573
+ }
574
+ catch (error) {
575
+ await sendLongMessage(chatId, error.message);
576
+ }
577
+ return;
578
+ }
579
+ case "list": {
580
+ await saveUserState(state);
581
+ await sendLongMessage(chatId, formatSessions(listSessions(state)));
582
+ return;
583
+ }
584
+ case "use": {
585
+ if (!args) {
586
+ await sendLongMessage(chatId, "Usage: /use <id>");
587
+ return;
588
+ }
589
+ const ok = setActiveSession(state, args);
590
+ if (!ok) {
591
+ await sendLongMessage(chatId, `Session not found: ${args}`);
592
+ return;
593
+ }
594
+ await saveUserState(state);
595
+ await sendLongMessage(chatId, `Active session set to ${args}.`);
596
+ return;
597
+ }
598
+ case "close": {
599
+ const target = args || state.activeSessionId;
600
+ if (!target) {
601
+ await sendLongMessage(chatId, "No active session to close.");
602
+ return;
603
+ }
604
+ const ok = closeSession(state, target);
605
+ if (!ok) {
606
+ await sendLongMessage(chatId, `Session not found: ${target}`);
607
+ return;
608
+ }
609
+ await saveUserState(state);
610
+ await deleteSessionFile(userId, target).catch((error) => {
611
+ console.warn(`[tg-agent] delete session file failed id=${target}`, error);
612
+ });
613
+ await sendLongMessage(chatId, `Closed session ${target}.`);
614
+ return;
615
+ }
616
+ case "reset": {
617
+ const active = getActiveSession(state);
618
+ if (!active) {
619
+ await sendLongMessage(chatId, "No active session.");
620
+ return;
621
+ }
622
+ resetSession(active);
623
+ await saveUserState(state);
624
+ await deleteSessionFile(userId, active.id).catch((error) => {
625
+ console.warn(`[tg-agent] reset session file failed id=${active.id}`, error);
626
+ });
627
+ await sendLongMessage(chatId, `Reset session ${active.id}.`);
628
+ return;
629
+ }
630
+ case "providers": {
631
+ const { authStorage, modelRegistry } = await loadModelRegistry();
632
+ const error = modelRegistry.getError();
633
+ const text = formatProviders(modelRegistry, authStorage);
634
+ const message = error ? `Warning: ${error}\n\n${text}` : text;
635
+ await sendLongMessage(chatId, message);
636
+ return;
637
+ }
638
+ case "models": {
639
+ const { authStorage, modelRegistry } = await loadModelRegistry();
640
+ let provider = args ? normalizeProviderId(args) : "";
641
+ if (!provider) {
642
+ const active = getActiveSession(state);
643
+ if (!active?.modelProvider) {
644
+ await sendLongMessage(chatId, "Usage: /models <provider>");
645
+ return;
646
+ }
647
+ provider = active.modelProvider;
648
+ }
649
+ const authOk = authStorage.hasAuth(provider);
650
+ await sendLongMessage(chatId, formatModelsForProvider(modelRegistry, provider, authOk));
651
+ return;
652
+ }
653
+ case "provider": {
654
+ if (!args) {
655
+ await sendLongMessage(chatId, "Usage: /provider <name>");
656
+ return;
657
+ }
658
+ const provider = normalizeProviderId(args);
659
+ const { modelRegistry } = await loadModelRegistry();
660
+ const exists = modelRegistry.getAll().some((model) => model.provider === provider);
661
+ if (!exists) {
662
+ await sendLongMessage(chatId, `Unknown provider: ${provider}`);
663
+ return;
664
+ }
665
+ const session = await ensureActiveSessionForCommand(state, chatId);
666
+ session.modelProvider = provider;
667
+ session.modelId = undefined;
668
+ session.updatedAt = nowMs();
669
+ await saveUserState(state);
670
+ await sendLongMessage(chatId, `Session ${session.id} provider set to ${provider}.`);
671
+ return;
672
+ }
673
+ case "model": {
674
+ const selector = parseModelSelector(args);
675
+ if (!selector) {
676
+ await sendLongMessage(chatId, "Usage: /model <provider>/<model>");
677
+ return;
678
+ }
679
+ const session = await ensureActiveSessionForCommand(state, chatId);
680
+ const provider = normalizeProviderId(selector.provider ?? session.modelProvider ?? "");
681
+ if (!provider) {
682
+ await sendLongMessage(chatId, "Usage: /model <provider>/<model>");
683
+ return;
684
+ }
685
+ const { modelRegistry } = await loadModelRegistry();
686
+ const normalizedModelId = normalizeModelId(provider, selector.modelId);
687
+ const model = modelRegistry.find(provider, normalizedModelId);
688
+ if (!model) {
689
+ await sendLongMessage(chatId, `Model not found: ${provider}/${normalizedModelId}`);
690
+ return;
691
+ }
692
+ session.modelProvider = provider;
693
+ session.modelId = normalizedModelId;
694
+ session.updatedAt = nowMs();
695
+ await saveUserState(state);
696
+ await sendLongMessage(chatId, `Session ${session.id} model set to ${provider}/${normalizedModelId}.`);
697
+ return;
698
+ }
699
+ case "status": {
700
+ const active = getActiveSession(state);
701
+ if (!active) {
702
+ await sendLongMessage(chatId, "No active session.");
703
+ return;
704
+ }
705
+ const defaultModel = config.modelRef.includes("/")
706
+ ? config.modelRef
707
+ : `${config.modelProvider}/${config.modelRef}`;
708
+ const overrides = active.modelProvider || active.modelId
709
+ ? `Session model: ${active.modelProvider ?? "?"}/${active.modelId ?? "?"}`
710
+ : `Default model: ${defaultModel}`;
711
+ const message = [
712
+ `Session: ${active.id} (${active.title})`,
713
+ overrides,
714
+ ].join("\n");
715
+ await sendLongMessage(chatId, message);
716
+ return;
717
+ }
718
+ case "mcp": {
719
+ const message = await formatMcpStatus();
720
+ await sendLongMessage(chatId, message);
721
+ return;
722
+ }
723
+ case "login": {
724
+ const providers = getOAuthProviders();
725
+ if (!args) {
726
+ const lines = providers.map((p) => `- ${p.id} (${p.name})`);
727
+ await sendLongMessage(chatId, ["OAuth providers:", ...lines].join("\n"));
728
+ return;
729
+ }
730
+ const provider = normalizeProviderId(args);
731
+ if (authInFlight.has(userId)) {
732
+ await sendLongMessage(chatId, "Login already in progress.");
733
+ return;
734
+ }
735
+ const providerInfo = providers.find((p) => p.id === provider);
736
+ if (!providerInfo) {
737
+ await sendLongMessage(chatId, `Unknown OAuth provider: ${provider}`);
738
+ return;
739
+ }
740
+ authInFlight.add(userId);
741
+ try {
742
+ const { authStorage } = await loadModelRegistry();
743
+ await sendLongMessage(chatId, `Starting login for ${providerInfo.id}...`);
744
+ await authStorage.login(providerInfo.id, {
745
+ onAuth: ({ url, instructions }) => {
746
+ const lines = [
747
+ "Open this URL in your browser:",
748
+ url,
749
+ ];
750
+ if (instructions) {
751
+ lines.push("", instructions);
752
+ }
753
+ void sendLongMessage(chatId, lines.join("\n"));
754
+ },
755
+ onPrompt: (prompt) => promptForAuthInput(userId, chatId, prompt),
756
+ onProgress: (message) => {
757
+ void sendLongMessage(chatId, message);
758
+ },
759
+ onManualCodeInput: () => promptForAuthInput(userId, chatId, { message: "Paste the authorization code:" }),
760
+ });
761
+ await sendLongMessage(chatId, `Login completed for ${providerInfo.id}.`);
762
+ }
763
+ catch (error) {
764
+ const message = formatErrorMessage(error);
765
+ await sendLongMessage(chatId, `Login failed: ${message}`);
766
+ }
767
+ finally {
768
+ authInFlight.delete(userId);
769
+ clearAuthPrompt(userId);
770
+ }
771
+ return;
772
+ }
773
+ case "logout": {
774
+ if (!args) {
775
+ await sendLongMessage(chatId, "Usage: /logout <provider>");
776
+ return;
777
+ }
778
+ const provider = normalizeProviderId(args);
779
+ const { authStorage } = await loadModelRegistry();
780
+ authStorage.logout(provider);
781
+ await sendLongMessage(chatId, `Logged out from ${provider}.`);
782
+ return;
783
+ }
784
+ default: {
785
+ await sendLongMessage(chatId, `Unknown command: ${command}`);
786
+ }
787
+ }
788
+ }
789
+ async function handleMessage(chatId, userId, text) {
790
+ const command = parseCommand(text);
791
+ if (command) {
792
+ await handleCommand(chatId, userId, command.command, command.args);
793
+ return;
794
+ }
795
+ const state = await loadUserState(userId);
796
+ const expired = pruneExpiredSessions(state);
797
+ if (expired.length > 0) {
798
+ await saveUserState(state);
799
+ await Promise.all(expired.map((sessionId) => deleteSessionFile(userId, sessionId).catch((error) => {
800
+ console.warn(`[tg-agent] cleanup session file failed id=${sessionId}`, error);
801
+ })));
802
+ }
803
+ let session = getActiveSession(state);
804
+ if (!session) {
805
+ try {
806
+ session = createSession(state, "");
807
+ await saveUserState(state);
808
+ await sendLongMessage(chatId, `Created session ${session.id}.`);
809
+ }
810
+ catch (error) {
811
+ await sendLongMessage(chatId, error.message);
812
+ return;
813
+ }
814
+ }
815
+ const userMessage = {
816
+ role: "user",
817
+ content: text,
818
+ ts: nowMs(),
819
+ };
820
+ appendMessage(session, userMessage, config.maxHistoryMessages);
821
+ await saveUserState(state);
822
+ console.log(`[tg-agent] request user=${userId} session=${session.id} messages=${session.messages.length} textLen=${text.length} provider=${session.modelProvider ?? "-"} model=${session.modelId ?? "-"}`);
823
+ let statusReporter = null;
824
+ try {
825
+ statusReporter = await createStatusReporter(chatId);
826
+ }
827
+ catch (error) {
828
+ console.warn("[tg-agent] status message failed", error);
829
+ }
830
+ const activeRun = {
831
+ sessionId: session.id,
832
+ chatId,
833
+ status: statusReporter,
834
+ abortRequested: false,
835
+ cancelRequested: false,
836
+ };
837
+ registerActiveRun(userId, activeRun);
838
+ try {
839
+ const assistantText = await withSemaphore(async () => {
840
+ const result = await runPiAgentPrompt({
841
+ userId,
842
+ sessionId: session.id,
843
+ prompt: text,
844
+ systemPrompt: config.systemPrompt,
845
+ modelProvider: session.modelProvider,
846
+ modelId: session.modelId,
847
+ onAbortReady: (abort) => {
848
+ activeRun.abort = abort;
849
+ if (activeRun.abortRequested) {
850
+ abort();
851
+ }
852
+ },
853
+ onStatus: statusReporter
854
+ ? (event) => {
855
+ const statusText = formatStatusEvent(event);
856
+ if (statusText) {
857
+ statusReporter?.update(statusText);
858
+ }
859
+ }
860
+ : undefined,
861
+ });
862
+ console.log(`[tg-agent] response session=${session.id} model=${result.modelProvider}/${result.modelId} file=${result.sessionFile}`);
863
+ return result.text;
864
+ });
865
+ if (activeRun.cancelRequested) {
866
+ if (statusReporter) {
867
+ await statusReporter.finalize("Cancelled by user.");
868
+ }
869
+ else {
870
+ await sendLongMessage(chatId, "Cancelled by user.");
871
+ }
872
+ return;
873
+ }
874
+ const assistantMessage = {
875
+ role: "assistant",
876
+ content: assistantText,
877
+ ts: nowMs(),
878
+ };
879
+ appendMessage(session, assistantMessage, config.maxHistoryMessages);
880
+ await saveUserState(state);
881
+ if (statusReporter) {
882
+ await statusReporter.finalize(assistantText);
883
+ }
884
+ else {
885
+ await sendLongMessage(chatId, assistantText);
886
+ }
887
+ }
888
+ catch (error) {
889
+ console.error("[tg-agent] runModel error", error);
890
+ if (activeRun.cancelRequested) {
891
+ if (statusReporter) {
892
+ await statusReporter.fail("Cancelled by user.");
893
+ }
894
+ else {
895
+ await sendLongMessage(chatId, "Cancelled by user.");
896
+ }
897
+ return;
898
+ }
899
+ const message = formatErrorMessage(error);
900
+ if (statusReporter) {
901
+ await statusReporter.fail(`Error: ${message}`);
902
+ }
903
+ else {
904
+ await sendLongMessage(chatId, `Error: ${message}`);
905
+ }
906
+ }
907
+ finally {
908
+ clearActiveRun(userId);
909
+ }
910
+ }
911
+ bot.on("message", (msg) => {
912
+ if (!msg.text) {
913
+ return;
914
+ }
915
+ if (msg.chat.type !== "private") {
916
+ void bot.sendMessage(msg.chat.id, "This bot only supports direct messages.");
917
+ return;
918
+ }
919
+ if (!msg.from?.id) {
920
+ return;
921
+ }
922
+ const userId = String(msg.from.id);
923
+ const chatId = msg.chat.id;
924
+ if (!isUserAllowed(userId)) {
925
+ logUnauthorizedUser(userId, chatId);
926
+ return;
927
+ }
928
+ const text = msg.text.trim();
929
+ const pendingAuth = authPromptState.get(userId);
930
+ if (pendingAuth) {
931
+ if (text === "/stop" || text === "/cancel") {
932
+ cancelAuthPrompt(userId, "Login cancelled by user.");
933
+ void sendLongMessage(chatId, "Login cancelled.");
934
+ return;
935
+ }
936
+ clearAuthPrompt(userId);
937
+ pendingAuth.resolve(text);
938
+ return;
939
+ }
940
+ const command = parseCommand(text);
941
+ if (command?.command === "stop") {
942
+ void handleStopCommand(chatId, userId);
943
+ return;
944
+ }
945
+ enqueueUser(userId, () => handleMessage(chatId, userId, text)).catch(async (error) => {
946
+ console.error("[tg-agent] runModel error", error);
947
+ const message = formatErrorMessage(error);
948
+ await sendLongMessage(chatId, `Error: ${message}`);
949
+ });
950
+ });
951
+ bot.on("polling_error", (error) => {
952
+ console.error("Polling error", error);
953
+ });
954
+ console.log("tg-agent started");