agentp 0.11.4 → 0.11.6
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/bin/agentp +10 -166
- package/bin/ocmux +33 -0
- package/bin/tgagentp +81 -241
- package/lib/ocmux.js +13 -0
- package/lib/telegram-api.js +139 -0
- package/lib/telegram-format.js +117 -0
- package/package.json +1 -1
package/bin/agentp
CHANGED
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
listSessions,
|
|
11
11
|
createSession,
|
|
12
12
|
getSession,
|
|
13
|
+
selectSession,
|
|
13
14
|
} = require('../lib/opencode');
|
|
14
15
|
|
|
15
16
|
const TGAGENTP_PORT_FILE = '/tmp/tgagentp-port';
|
|
@@ -356,168 +357,6 @@ async function main() {
|
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
359
|
|
|
359
|
-
let answer;
|
|
360
|
-
try {
|
|
361
|
-
answer = await sendToSession(serverBase, sessionId, promptText);
|
|
362
|
-
} catch (err) {
|
|
363
|
-
console.error('Error sending prompt:', err.message);
|
|
364
|
-
process.exit(1);
|
|
365
|
-
}
|
|
366
|
-
const n = parseInt(nStr, 10);
|
|
367
|
-
try {
|
|
368
|
-
const sessions = await listSessions(serverBase);
|
|
369
|
-
if (!sessions || sessions.length === 0) {
|
|
370
|
-
console.error('No sessions found.');
|
|
371
|
-
process.exit(1);
|
|
372
|
-
}
|
|
373
|
-
const sorted = sessions
|
|
374
|
-
.filter(s => s.time && s.time.updated)
|
|
375
|
-
.sort((a, b) => b.time.updated - a.time.updated);
|
|
376
|
-
const sessionId = sorted[0].id;
|
|
377
|
-
|
|
378
|
-
function buildPairsFromMessages(messages) {
|
|
379
|
-
const pairs = [];
|
|
380
|
-
let cur = null;
|
|
381
|
-
for (const raw of messages) {
|
|
382
|
-
// Normalize: export gives {role, parts: [{type, text}]}, SQLite gives {role, text}
|
|
383
|
-
const m = raw.parts ? raw : { role: raw.role, parts: [{ type: 'text', text: raw.text }] };
|
|
384
|
-
const role = m.role;
|
|
385
|
-
const text = (m.parts || []).filter(p => p.type === 'text').map(p => p.text).join('').trim();
|
|
386
|
-
if (!text) continue;
|
|
387
|
-
if (role === 'user') {
|
|
388
|
-
if (cur) pairs.push(cur);
|
|
389
|
-
cur = { prompt: text, answer: '' };
|
|
390
|
-
} else if (role === 'assistant' && cur) {
|
|
391
|
-
if (cur.answer) cur.answer += '\n';
|
|
392
|
-
cur.answer += text;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
// Don't push incomplete pair (no answer yet)
|
|
396
|
-
return pairs;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function formatPairs(pairs) {
|
|
400
|
-
return pairs.map(p =>
|
|
401
|
-
HUMAN_ROW + p.prompt + '\n' + AGENT_ROW + p.answer + '\n' + FINAL_ROW
|
|
402
|
-
).join('\n');
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const formatAsQa = qaMode || args.includes('--qa');
|
|
406
|
-
function extractAnswers(pairs) {
|
|
407
|
-
return pairs.map(p => p.answer);
|
|
408
|
-
}
|
|
409
|
-
let output = '';
|
|
410
|
-
try {
|
|
411
|
-
const raw = child_process.execFileSync('opencode', ['export', sessionId], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
|
|
412
|
-
const jsonStart = raw.indexOf('{');
|
|
413
|
-
const body = jsonStart >= 0 ? raw.slice(jsonStart) : raw;
|
|
414
|
-
const exportData = JSON.parse(body);
|
|
415
|
-
const messages = exportData.messages || [];
|
|
416
|
-
const pairs = buildPairsFromMessages(messages);
|
|
417
|
-
const lastN = pairs.slice(-n);
|
|
418
|
-
output = formatAsQa ? formatPairs(lastN) : extractAnswers(lastN).join('\n');
|
|
419
|
-
} catch {
|
|
420
|
-
// export JSON is malformed (opencode bug);
|
|
421
|
-
// fall back to SQLite
|
|
422
|
-
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
423
|
-
const dbCandidates = [
|
|
424
|
-
`${home}/.local/share/opencode/opencode.db`,
|
|
425
|
-
`${home}/Library/Application Support/opencode/opencode.db`,
|
|
426
|
-
`${home}/.opencode/opencode.db`,
|
|
427
|
-
];
|
|
428
|
-
let dbPath = null;
|
|
429
|
-
for (const p of dbCandidates) {
|
|
430
|
-
try { fs.accessSync(p); dbPath = p; break; } catch {}
|
|
431
|
-
}
|
|
432
|
-
if (dbPath) {
|
|
433
|
-
const sql = `SELECT m.data->>'role' as role, group_concat(p.data->>'text', '') as text FROM message m JOIN part p ON p.message_id = m.id WHERE m.session_id = '${sessionId.replace(/'/g, "''")}' AND p.data->>'type' = 'text' AND m.data->>'role' IN ('user','assistant') GROUP BY m.id ORDER BY m.time_created ASC`;
|
|
434
|
-
const out = child_process.execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
|
|
435
|
-
const messages = JSON.parse(out);
|
|
436
|
-
const pairs = buildPairsFromMessages(messages);
|
|
437
|
-
const lastN = pairs.slice(-n);
|
|
438
|
-
output = formatAsQa ? formatPairs(lastN) : extractAnswers(lastN).join('\n');
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
if (!output) {
|
|
442
|
-
console.error(formatAsQa ? 'No complete prompt-answer pairs found in session history.' : 'No assistant answers found in session history.');
|
|
443
|
-
process.exit(1);
|
|
444
|
-
}
|
|
445
|
-
process.stdout.write(output);
|
|
446
|
-
if (!output.endsWith('\n')) process.stdout.write('\n');
|
|
447
|
-
} catch (err) {
|
|
448
|
-
console.error('Error retrieving session history:', err.message);
|
|
449
|
-
process.exit(1);
|
|
450
|
-
}
|
|
451
|
-
return;
|
|
452
|
-
} else if (args[i] === '--tg') {
|
|
453
|
-
tgMode = true;
|
|
454
|
-
} else if (args[i] === '--no-tg') {
|
|
455
|
-
tgMode = false;
|
|
456
|
-
} else if (args[i].includes('://')) {
|
|
457
|
-
serverBase = args[i].replace(/\/+$/, '');
|
|
458
|
-
} else if (/^\d+$/.test(args[i])) {
|
|
459
|
-
serverBase = 'http://localhost:' + args[i];
|
|
460
|
-
} else if (args[i].startsWith('--')) {
|
|
461
|
-
console.error(`Error: unknown option '${args[i]}'`);
|
|
462
|
-
console.error(`Usage: agentp [options] [url]`);
|
|
463
|
-
console.error(`Try 'agentp --help' for more information.`);
|
|
464
|
-
process.exit(1);
|
|
465
|
-
} else {
|
|
466
|
-
console.error(`Error: unexpected argument '${args[i]}'`);
|
|
467
|
-
process.exit(1);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Resolve default: --qa → auto-detect, standalone → disabled
|
|
472
|
-
if (tgMode === null) {
|
|
473
|
-
tgMode = qaMode ? 'auto' : false;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Pre-send: probe tgagentp gateway if enabled
|
|
477
|
-
let tgPort = null;
|
|
478
|
-
if (tgMode) {
|
|
479
|
-
try {
|
|
480
|
-
tgPort = fs.readFileSync(TGAGENTP_PORT_FILE, 'utf8').trim();
|
|
481
|
-
if (!tgPort || !/^\d+$/.test(tgPort)) tgPort = null;
|
|
482
|
-
} catch {
|
|
483
|
-
tgPort = null;
|
|
484
|
-
}
|
|
485
|
-
if (!tgPort && tgMode === true) {
|
|
486
|
-
console.error('Error: tgagentp gateway not found. Is tgagentp running?');
|
|
487
|
-
process.exit(1);
|
|
488
|
-
}
|
|
489
|
-
// auto mode: tgPort stays null → silently skip tgagentp
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const promptText = await readAllStdin();
|
|
493
|
-
|
|
494
|
-
// Find most recent session or create one
|
|
495
|
-
let sessions;
|
|
496
|
-
try {
|
|
497
|
-
sessions = await listSessions(serverBase);
|
|
498
|
-
} catch (err) {
|
|
499
|
-
console.error('Error connecting to server:', err.message);
|
|
500
|
-
process.exit(1);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
let sessionId = null;
|
|
504
|
-
if (sessions && sessions.length > 0) {
|
|
505
|
-
const sorted = sessions
|
|
506
|
-
.filter(s => s.time && s.time.updated)
|
|
507
|
-
.sort((a, b) => b.time.updated - a.time.updated);
|
|
508
|
-
if (sorted.length > 0) sessionId = sorted[0].id;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (!sessionId) {
|
|
512
|
-
try {
|
|
513
|
-
const session = await createSession(serverBase, 'agentp');
|
|
514
|
-
sessionId = session.id;
|
|
515
|
-
} catch (err) {
|
|
516
|
-
console.error('Error creating session:', err.message);
|
|
517
|
-
process.exit(1);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
360
|
let answer;
|
|
522
361
|
try {
|
|
523
362
|
answer = await sendToSession(serverBase, sessionId, promptText);
|
|
@@ -570,7 +409,12 @@ async function main() {
|
|
|
570
409
|
}
|
|
571
410
|
}
|
|
572
411
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
412
|
+
if (require.main === module) {
|
|
413
|
+
main().catch((err) => {
|
|
414
|
+
console.error('Error:', err.message);
|
|
415
|
+
process.exit(1);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
module.exports = { main, notifyAgentpGateway, readAllStdin };
|
|
420
|
+
|
package/bin/ocmux
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
logfileFor,
|
|
23
23
|
URL_RE,
|
|
24
24
|
resurrectServer,
|
|
25
|
+
getActiveServer,
|
|
25
26
|
} = require('../lib/ocmux');
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
@@ -186,6 +187,7 @@ function printHelp() {
|
|
|
186
187
|
console.error('');
|
|
187
188
|
console.error('Options:');
|
|
188
189
|
console.error(' -l Long output: append " → <dir>" after the URL (serve/default).');
|
|
190
|
+
console.error(' --last Print the URL of the active tmux window (last used server).');
|
|
189
191
|
console.error(' --print-logs Pass --print-logs to opencode serve (prints server logs to stderr).');
|
|
190
192
|
console.error(' --version Show version');
|
|
191
193
|
console.error(' -h Show this help');
|
|
@@ -445,12 +447,15 @@ function main() {
|
|
|
445
447
|
let longMode = false;
|
|
446
448
|
let gitMode = null;
|
|
447
449
|
let printLogs = false;
|
|
450
|
+
let lastMode = false;
|
|
448
451
|
const positional = [];
|
|
449
452
|
let dirIsForced = false;
|
|
450
453
|
|
|
451
454
|
for (const arg of process.argv.slice(2)) {
|
|
452
455
|
if (arg === '-l') {
|
|
453
456
|
longMode = true;
|
|
457
|
+
} else if (arg === '--last') {
|
|
458
|
+
lastMode = true;
|
|
454
459
|
} else if (arg === '--print-logs') {
|
|
455
460
|
printLogs = true;
|
|
456
461
|
} else if (arg === '--git') {
|
|
@@ -516,6 +521,34 @@ function main() {
|
|
|
516
521
|
if (!targetDir) targetDir = process.cwd();
|
|
517
522
|
|
|
518
523
|
// Dispatch
|
|
524
|
+
if (lastMode) {
|
|
525
|
+
if (gitMode) {
|
|
526
|
+
console.error("Error: '--last' cannot be combined with '--git' or '--GIT'.");
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
if (printLogs) {
|
|
530
|
+
console.error("Error: '--last' cannot be combined with '--print-logs'.");
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
if (subcommand) {
|
|
534
|
+
console.error(`Error: '--last' cannot be combined with subcommand '${subcommand}'.`);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
if (targetDir !== process.cwd()) {
|
|
538
|
+
console.error("Error: '--last' does not accept a directory path.");
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
ensureSession();
|
|
542
|
+
const url = getActiveServer();
|
|
543
|
+
if (!url) {
|
|
544
|
+
console.error('Error: no active opencode server found in tmux.');
|
|
545
|
+
console.error("Run 'ocmux serve' to create a server, or switch to an existing one.");
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
outputUrl(url, null, longMode);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
519
552
|
if (subcommand === 'list') {
|
|
520
553
|
if (gitMode) {
|
|
521
554
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
package/bin/tgagentp
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const http = require('http');
|
|
4
|
-
const https = require('https');
|
|
5
4
|
const fs = require('fs');
|
|
6
5
|
const path = require('path');
|
|
7
6
|
const { version } = require('../package.json');
|
|
@@ -21,6 +20,8 @@ const TGAGENTP_DEBOUNCE_MS = parseInt(process.env.TGAGENTP_DEBOUNCE_MS || '5000'
|
|
|
21
20
|
const CONNECTIONS_FILE = '/tmp/tgagentp-connections.json';
|
|
22
21
|
const { listServers, activateServer, resurrectServer, statefileFor, readState, tuiPaneId } = require('../lib/ocmux.js');
|
|
23
22
|
const { executeTuiCommand } = require('../lib/tui-cmd.js');
|
|
23
|
+
const { getUpdates, downloadTelegramFile, sendTelegramDocument } = require('../lib/telegram-api.js');
|
|
24
|
+
const { sendLongMessage } = require('../lib/telegram-format.js');
|
|
24
25
|
|
|
25
26
|
// Persist chat↔server directory mappings across restarts
|
|
26
27
|
function saveConnection(chatId, threadId, dir) {
|
|
@@ -144,123 +145,7 @@ function flushRecorded(chatId, threadId) {
|
|
|
144
145
|
// Telegram Bot API helpers
|
|
145
146
|
// ---------------------------------------------------------------------------
|
|
146
147
|
|
|
147
|
-
function telegramRequest(token, method, params) {
|
|
148
|
-
return new Promise((resolve, reject) => {
|
|
149
|
-
const body = JSON.stringify(params || {});
|
|
150
|
-
const options = {
|
|
151
|
-
hostname: 'api.telegram.org',
|
|
152
|
-
port: 443,
|
|
153
|
-
path: `/bot${token}/${method}`,
|
|
154
|
-
method: 'POST',
|
|
155
|
-
headers: {
|
|
156
|
-
'Content-Type': 'application/json',
|
|
157
|
-
'Content-Length': Buffer.byteLength(body),
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
const req = https.request(options, (res) => {
|
|
161
|
-
let data = '';
|
|
162
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
163
|
-
res.on('end', () => {
|
|
164
|
-
try {
|
|
165
|
-
resolve(JSON.parse(data));
|
|
166
|
-
} catch (e) {
|
|
167
|
-
reject(new Error(`Failed to parse Telegram response: ${data}`));
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
req.on('error', reject);
|
|
172
|
-
req.write(body);
|
|
173
|
-
req.end();
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function getUpdates(token, offset, timeout) {
|
|
178
|
-
return telegramRequest(token, 'getUpdates', {
|
|
179
|
-
offset,
|
|
180
|
-
timeout: timeout != null ? timeout : 30,
|
|
181
|
-
allowed_updates: ['message'],
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function sendTelegramMessage(token, chatId, text, parseMode, replyTo, threadId) {
|
|
186
|
-
const params = { chat_id: chatId, text };
|
|
187
|
-
if (parseMode) params.parse_mode = parseMode;
|
|
188
|
-
if (replyTo) params.reply_to_message_id = replyTo;
|
|
189
|
-
if (threadId) params.message_thread_id = threadId;
|
|
190
|
-
return telegramRequest(token, 'sendMessage', params);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
148
|
// Lightweight Markdown to Telegram HTML conversion.
|
|
194
|
-
function convertMarkdownToHtml(text) {
|
|
195
|
-
const parts = text.split('```');
|
|
196
|
-
|
|
197
|
-
return parts.map((part, i) => {
|
|
198
|
-
if (i % 2 === 1) {
|
|
199
|
-
// Code block ─ strip language tag, escape entities, wrap in <pre>
|
|
200
|
-
const nl = part.indexOf('\n');
|
|
201
|
-
const inner = nl >= 0 ? part.slice(nl + 1) : part;
|
|
202
|
-
return '<pre>' + inner
|
|
203
|
-
.replace(/&/g, '&')
|
|
204
|
-
.replace(/</g, '<')
|
|
205
|
-
.replace(/>/g, '>') + '</pre>';
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Text segment
|
|
209
|
-
|
|
210
|
-
// Phase 1: extract table blocks into placeholders
|
|
211
|
-
const placeholders = [];
|
|
212
|
-
let processed = '';
|
|
213
|
-
const lines = part.split('\n');
|
|
214
|
-
let li = 0;
|
|
215
|
-
while (li < lines.length) {
|
|
216
|
-
if (/^\|/.test(lines[li])) {
|
|
217
|
-
const table = [];
|
|
218
|
-
while (li < lines.length && /^\|/.test(lines[li])) {
|
|
219
|
-
table.push(lines[li]);
|
|
220
|
-
li++;
|
|
221
|
-
}
|
|
222
|
-
const hasSep = table.some(l => /^\|[-:| ]+\|$/.test(l));
|
|
223
|
-
if (table.length >= 2 && hasSep) {
|
|
224
|
-
const cleaned = table
|
|
225
|
-
.map(l => l.replace(/^\|/, '').replace(/\|$/, ''))
|
|
226
|
-
.filter(l => !/^\s*[|:\- ]+\s*$/.test(l));
|
|
227
|
-
if (cleaned.length > 0) {
|
|
228
|
-
const escaped = cleaned.map(l =>
|
|
229
|
-
l.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
230
|
-
);
|
|
231
|
-
const idx = placeholders.length;
|
|
232
|
-
placeholders.push('<pre>' + escaped.join('\n') + '</pre>');
|
|
233
|
-
processed += '\0T' + idx + '\0\n';
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
for (const l of table) { processed += l + '\n'; }
|
|
238
|
-
} else {
|
|
239
|
-
processed += lines[li] + '\n';
|
|
240
|
-
li++;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Phase 2: escape entities and apply markdown on non-table text
|
|
245
|
-
let html = processed
|
|
246
|
-
.replace(/&/g, '&')
|
|
247
|
-
.replace(/</g, '<')
|
|
248
|
-
.replace(/>/g, '>');
|
|
249
|
-
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
250
|
-
html = html.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
|
|
251
|
-
html = html.replace(/^\s*-{3,}\s*$/gm, '────────────────────');
|
|
252
|
-
html = html.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
253
|
-
html = html.replace(/\*(.+?)\*/g, '<i>$1</i>');
|
|
254
|
-
|
|
255
|
-
// Phase 3: restore table placeholders
|
|
256
|
-
for (let j = 0; j < placeholders.length; j++) {
|
|
257
|
-
html = html.split('\0T' + j + '\0').join(placeholders[j]);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return html;
|
|
261
|
-
}).join('');
|
|
262
|
-
}
|
|
263
|
-
|
|
264
149
|
// Parse TGAGENTP_ALLOWED_CHAT_IDS env var (comma-separated chat IDs).
|
|
265
150
|
// Returns null when unset (all chats allowed), or an array of IDs to filter.
|
|
266
151
|
function parseAllowlist(raw) {
|
|
@@ -269,43 +154,6 @@ function parseAllowlist(raw) {
|
|
|
269
154
|
return ids.length > 0 ? ids : null;
|
|
270
155
|
}
|
|
271
156
|
|
|
272
|
-
// Split text into chunks of at most maxLength characters,
|
|
273
|
-
// breaking at newlines when possible.
|
|
274
|
-
function splitMessage(text, maxLength = 4096) {
|
|
275
|
-
if (text.length <= maxLength) return [text];
|
|
276
|
-
const chunks = [];
|
|
277
|
-
let start = 0;
|
|
278
|
-
while (start < text.length) {
|
|
279
|
-
let end = Math.min(start + maxLength, text.length);
|
|
280
|
-
if (end < text.length) {
|
|
281
|
-
const newlinePos = text.lastIndexOf('\n', end);
|
|
282
|
-
if (newlinePos > start) {
|
|
283
|
-
end = newlinePos + 1;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
chunks.push(text.slice(start, end));
|
|
287
|
-
start = end;
|
|
288
|
-
}
|
|
289
|
-
return chunks;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Send a text message to a Telegram chat, splitting into multiple
|
|
293
|
-
// messages if it exceeds the 4096-character limit.
|
|
294
|
-
// Converts Markdown to Telegram HTML formatting.
|
|
295
|
-
// Splits the raw text first, then converts each chunk independently
|
|
296
|
-
// to avoid broken HTML tags across messages.
|
|
297
|
-
async function sendLongMessage(token, chatIdOrState, text, replyTo, threadId) {
|
|
298
|
-
// Accept either (token, chatState, ...) or (token, rawChatId, ...)
|
|
299
|
-
const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
|
|
300
|
-
? chatIdOrState.chatId : chatIdOrState;
|
|
301
|
-
const effectiveThreadId = threadId || (typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined);
|
|
302
|
-
const chunks = splitMessage(text);
|
|
303
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
304
|
-
const html = convertMarkdownToHtml(chunks[i]);
|
|
305
|
-
await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined, effectiveThreadId);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
157
|
// Build a quoted reply context from a Telegram reply_to_message.
|
|
310
158
|
// Returns empty string if the message isn't a reply or has no text.
|
|
311
159
|
// Uses a dynamic code-fence length (max consecutive backticks + 1)
|
|
@@ -332,24 +180,6 @@ function quoteReply(msg) {
|
|
|
332
180
|
// File upload helpers
|
|
333
181
|
// ---------------------------------------------------------------------------
|
|
334
182
|
|
|
335
|
-
// Download a file from Telegram's file storage.
|
|
336
|
-
// Returns a Buffer of the file contents.
|
|
337
|
-
async function downloadTelegramFile(token, fileId) {
|
|
338
|
-
const fileInfo = await telegramRequest(token, 'getFile', { file_id: fileId });
|
|
339
|
-
if (!fileInfo.ok || !fileInfo.result?.file_path) {
|
|
340
|
-
throw new Error('Failed to get file info from Telegram');
|
|
341
|
-
}
|
|
342
|
-
const filePath = fileInfo.result.file_path;
|
|
343
|
-
return new Promise((resolve, reject) => {
|
|
344
|
-
const req = https.get(`https://api.telegram.org/file/bot${token}/${filePath}`, (res) => {
|
|
345
|
-
const chunks = [];
|
|
346
|
-
res.on('data', (chunk) => chunks.push(chunk));
|
|
347
|
-
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
348
|
-
});
|
|
349
|
-
req.on('error', reject);
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
183
|
// Handle an uploaded file from Telegram.
|
|
354
184
|
// Downloads the file, saves it, and returns a notification message.
|
|
355
185
|
async function handleFileUpload(token, msg, projectDir) {
|
|
@@ -1160,69 +990,6 @@ function handleSendFileRequest(req, res, token, serverBaseResolver) {
|
|
|
1160
990
|
});
|
|
1161
991
|
}
|
|
1162
992
|
|
|
1163
|
-
// Send a document to Telegram using multipart/form-data.
|
|
1164
|
-
async function sendTelegramDocument(token, chatIdOrState, fileData, fileName, caption) {
|
|
1165
|
-
const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
|
|
1166
|
-
? chatIdOrState.chatId : chatIdOrState;
|
|
1167
|
-
const threadId = typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined;
|
|
1168
|
-
|
|
1169
|
-
const boundary = '----tgagentpBoundary' + Math.random().toString(36).slice(2);
|
|
1170
|
-
const parts = [];
|
|
1171
|
-
|
|
1172
|
-
// chat_id field
|
|
1173
|
-
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`));
|
|
1174
|
-
|
|
1175
|
-
// message_thread_id field (if present)
|
|
1176
|
-
if (threadId) {
|
|
1177
|
-
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message_thread_id"\r\n\r\n${threadId}\r\n`));
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
// document field (the file)
|
|
1181
|
-
const mimeType = 'application/octet-stream';
|
|
1182
|
-
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${fileName}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
|
|
1183
|
-
parts.push(fileData);
|
|
1184
|
-
parts.push(Buffer.from('\r\n'));
|
|
1185
|
-
|
|
1186
|
-
// caption field (optional)
|
|
1187
|
-
if (caption) {
|
|
1188
|
-
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`));
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// closing boundary
|
|
1192
|
-
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
1193
|
-
|
|
1194
|
-
const body = Buffer.concat(parts);
|
|
1195
|
-
|
|
1196
|
-
return new Promise((resolve, reject) => {
|
|
1197
|
-
const options = {
|
|
1198
|
-
hostname: 'api.telegram.org',
|
|
1199
|
-
port: 443,
|
|
1200
|
-
path: `/bot${token}/sendDocument`,
|
|
1201
|
-
method: 'POST',
|
|
1202
|
-
headers: {
|
|
1203
|
-
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1204
|
-
'Content-Length': body.length,
|
|
1205
|
-
},
|
|
1206
|
-
};
|
|
1207
|
-
const req = https.request(options, (res) => {
|
|
1208
|
-
let data = '';
|
|
1209
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
1210
|
-
res.on('end', () => {
|
|
1211
|
-
try {
|
|
1212
|
-
const result = JSON.parse(data);
|
|
1213
|
-
if (result.ok) resolve(result);
|
|
1214
|
-
else reject(new Error(result.description || 'Telegram API error'));
|
|
1215
|
-
} catch (e) {
|
|
1216
|
-
reject(new Error(`Failed to parse Telegram response: ${data}`));
|
|
1217
|
-
}
|
|
1218
|
-
});
|
|
1219
|
-
});
|
|
1220
|
-
req.on('error', reject);
|
|
1221
|
-
req.write(body);
|
|
1222
|
-
req.end();
|
|
1223
|
-
});
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
993
|
async function cmdAllow(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1227
994
|
const st = getState(chatState.serverBase);
|
|
1228
995
|
if (!st.pendingPermission) {
|
|
@@ -1866,7 +1633,8 @@ async function main() {
|
|
|
1866
1633
|
sendLongMessage(token, chatState, html, replyTo, threadId).catch(() => {});
|
|
1867
1634
|
thinkingBuf = '';
|
|
1868
1635
|
}
|
|
1869
|
-
|
|
1636
|
+
// Keep the SSE listener alive so subsequent permission/question events
|
|
1637
|
+
// that arrive after sendToSession returns are still forwarded to Telegram.
|
|
1870
1638
|
} else {
|
|
1871
1639
|
await sendText(url, `[Telegram] ${text}`);
|
|
1872
1640
|
answer = await listenForFinalAnswer(url, null, cancelRef);
|
|
@@ -1924,7 +1692,10 @@ async function main() {
|
|
|
1924
1692
|
}
|
|
1925
1693
|
} finally {
|
|
1926
1694
|
st.pendingPermission = null;
|
|
1927
|
-
if
|
|
1695
|
+
// Only clear the reference if the SSE listener was destroyed or finished.
|
|
1696
|
+
// If it is still running, keep the reference so the next message call
|
|
1697
|
+
// can destroy it (lines 1557–1559).
|
|
1698
|
+
if (st._cancelRef === cancelRef && !cancelRef.current) st._cancelRef = null;
|
|
1928
1699
|
if (st._sessionReq === sessionCancelRef) st._sessionReq = null;
|
|
1929
1700
|
if (!isCancelled()) {
|
|
1930
1701
|
getState(url).busy = false;
|
|
@@ -2039,8 +1810,24 @@ async function main() {
|
|
|
2039
1810
|
}
|
|
2040
1811
|
st.serverDead = false;
|
|
2041
1812
|
}
|
|
1813
|
+
const intro = 'The following information is for your awareness. Reply with only "Ack" and do not take any action:';
|
|
1814
|
+
const noteText = intro + '\n\n' + text;
|
|
2042
1815
|
if (st.busy) {
|
|
2043
|
-
|
|
1816
|
+
// Experimental: try async injection instead of queuing
|
|
1817
|
+
if (st.activeSessionId) {
|
|
1818
|
+
try {
|
|
1819
|
+
log.debug(` → /note: attempting async injection into session ${st.activeSessionId}`);
|
|
1820
|
+
await sendToSessionAsync(url, st.activeSessionId, noteText, st.activeAgentName);
|
|
1821
|
+
await sendLongMessage(token, chatState, '💉 Note injected into ongoing session.');
|
|
1822
|
+
log.debug(` → /note: async injection succeeded`);
|
|
1823
|
+
return;
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
log.debug(` → /note: async injection failed (${err.message}), falling back to queue`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
1829
|
+
st.messageQueue.push({ text: noteText, replyTo: null });
|
|
1830
|
+
await sendLongMessage(token, chatState, `⏳ Server is busy. Note queued (#${st.messageQueue.length}).`);
|
|
2044
1831
|
return;
|
|
2045
1832
|
}
|
|
2046
1833
|
|
|
@@ -2049,11 +1836,10 @@ async function main() {
|
|
|
2049
1836
|
const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(url));
|
|
2050
1837
|
if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
|
|
2051
1838
|
|
|
2052
|
-
const intro = 'The following information is for your awareness. Reply with only "Ack" and do not take any action:';
|
|
2053
1839
|
st.busy = true;
|
|
2054
1840
|
const myTaskId = ++st.taskId;
|
|
2055
1841
|
log.debug(` → /note: starting background processing (task ${myTaskId})`);
|
|
2056
|
-
processMessageAsync(chatId,
|
|
1842
|
+
processMessageAsync(chatId, noteText, url, !!st.activeSessionId, st.activeSessionId, myTaskId, null, chatState.threadId);
|
|
2057
1843
|
};
|
|
2058
1844
|
|
|
2059
1845
|
const cmds = { ...COMMANDS, status: cmdStatus, cancel: cmdCancel, note: cmdNote };
|
|
@@ -2310,7 +2096,25 @@ async function main() {
|
|
|
2310
2096
|
|
|
2311
2097
|
if (typeof msg.text !== 'string') continue;
|
|
2312
2098
|
|
|
2313
|
-
|
|
2099
|
+
let text = msg.text;
|
|
2100
|
+
|
|
2101
|
+
// !! wildcard: replace with the previous user message text
|
|
2102
|
+
if (text.includes('!!')) {
|
|
2103
|
+
const prevMsg = msg.reply_to_message;
|
|
2104
|
+
if (prevMsg && prevMsg.text) {
|
|
2105
|
+
text = text.replace(/!!/g, prevMsg.text);
|
|
2106
|
+
} else {
|
|
2107
|
+
// Fallback: look up the last user message from the ring buffer
|
|
2108
|
+
const ring = getChatState(chatId, threadId).ring;
|
|
2109
|
+
const lastUserMsg = ring.messages.slice().reverse().find(m => m.role === 'user');
|
|
2110
|
+
if (lastUserMsg) {
|
|
2111
|
+
text = text.replace(/!!/g, lastUserMsg.text);
|
|
2112
|
+
} else {
|
|
2113
|
+
await sendLongMessage(token, cs, '⚠️ No previous message found to replace !!');
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2314
2118
|
|
|
2315
2119
|
log.debug(`[chat ${chatId}] ${from}: ${text}`);
|
|
2316
2120
|
msgLog('in', chatId, from, text);
|
|
@@ -2318,6 +2122,42 @@ async function main() {
|
|
|
2318
2122
|
// Build reply context if this message is a Telegram reply
|
|
2319
2123
|
const replyContext = quoteReply(msg);
|
|
2320
2124
|
|
|
2125
|
+
// Download on reply: if the user replies to a file message, download it
|
|
2126
|
+
if (msg.reply_to_message && (msg.reply_to_message.document || msg.reply_to_message.photo)) {
|
|
2127
|
+
if (!cs.serverBase) {
|
|
2128
|
+
await sendLongMessage(token, cs, '🔌 Not connected to any server. Cannot download file.');
|
|
2129
|
+
} else {
|
|
2130
|
+
const projectDir = serverDir(cs.serverBase);
|
|
2131
|
+
if (!projectDir) {
|
|
2132
|
+
await sendLongMessage(token, cs, '❌ Could not find project directory for this server.');
|
|
2133
|
+
} else {
|
|
2134
|
+
try {
|
|
2135
|
+
const replyMsg = msg.reply_to_message;
|
|
2136
|
+
let fileId, fileName, fileSize;
|
|
2137
|
+
if (replyMsg.document) {
|
|
2138
|
+
fileId = replyMsg.document.file_id;
|
|
2139
|
+
fileName = replyMsg.document.file_name || 'download.bin';
|
|
2140
|
+
fileSize = replyMsg.document.file_size || 0;
|
|
2141
|
+
} else if (replyMsg.photo) {
|
|
2142
|
+
const largest = replyMsg.photo[replyMsg.photo.length - 1];
|
|
2143
|
+
fileId = largest.file_id;
|
|
2144
|
+
fileName = 'photo.jpg';
|
|
2145
|
+
fileSize = largest.file_size || 0;
|
|
2146
|
+
}
|
|
2147
|
+
const fileData = await downloadTelegramFile(token, fileId);
|
|
2148
|
+
ensureSharedDir(projectDir);
|
|
2149
|
+
const relativePath = saveUploadedFile(projectDir, fileName, fileData);
|
|
2150
|
+
const sizeStr = fileSize > 0 ? ` (${formatFileSize(fileSize)})` : '';
|
|
2151
|
+
await sendLongMessage(token, cs, `📎 File downloaded: \`${relativePath}\`${sizeStr}`);
|
|
2152
|
+
} catch (err) {
|
|
2153
|
+
log.error(`Download on reply error: ${err.message}`);
|
|
2154
|
+
await sendLongMessage(token, cs, `❌ File download failed: ${err.message}`);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2321
2161
|
// Intercept //commands (raw TUI passthrough via tmux send-keys)
|
|
2322
2162
|
if (text.startsWith('//')) {
|
|
2323
2163
|
const tuiCmd = text.slice(1);
|
package/lib/ocmux.js
CHANGED
|
@@ -92,6 +92,18 @@ function activeWindowIndex() {
|
|
|
92
92
|
return null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Get the URL of the active tmux window (the one currently selected in the Opencode session).
|
|
96
|
+
function getActiveServer() {
|
|
97
|
+
const idx = activeWindowIndex();
|
|
98
|
+
if (idx == null) return null;
|
|
99
|
+
const dir = windowNameByIndex(idx);
|
|
100
|
+
if (!dir) return null;
|
|
101
|
+
const sf = statefileFor(dir);
|
|
102
|
+
if (!fs.existsSync(sf)) return null;
|
|
103
|
+
const state = readState(sf);
|
|
104
|
+
return state && state.url ? state.url : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
function paneCount(windowIndex) {
|
|
96
108
|
const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}']);
|
|
97
109
|
if (r.status !== 0) return 0;
|
|
@@ -259,6 +271,7 @@ module.exports = {
|
|
|
259
271
|
paneCount,
|
|
260
272
|
listServers,
|
|
261
273
|
activateServer,
|
|
274
|
+
getActiveServer,
|
|
262
275
|
hashDir,
|
|
263
276
|
logfileFor,
|
|
264
277
|
sleep,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
|
|
5
|
+
// Make a JSON request to the Telegram Bot API.
|
|
6
|
+
function telegramRequest(token, method, params) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const body = JSON.stringify(params || {});
|
|
9
|
+
const options = {
|
|
10
|
+
hostname: 'api.telegram.org',
|
|
11
|
+
port: 443,
|
|
12
|
+
path: `/bot${token}/${method}`,
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
'Content-Length': Buffer.byteLength(body),
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const req = https.request(options, (res) => {
|
|
20
|
+
let data = '';
|
|
21
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
22
|
+
res.on('end', () => {
|
|
23
|
+
try {
|
|
24
|
+
resolve(JSON.parse(data));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
reject(new Error(`Failed to parse Telegram response: ${data}`));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
req.on('error', reject);
|
|
31
|
+
req.write(body);
|
|
32
|
+
req.end();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getUpdates(token, offset, timeout) {
|
|
37
|
+
return telegramRequest(token, 'getUpdates', {
|
|
38
|
+
offset,
|
|
39
|
+
timeout: timeout != null ? timeout : 30,
|
|
40
|
+
allowed_updates: ['message'],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sendTelegramMessage(token, chatId, text, parseMode, replyTo, threadId) {
|
|
45
|
+
const params = { chat_id: chatId, text };
|
|
46
|
+
if (parseMode) params.parse_mode = parseMode;
|
|
47
|
+
if (replyTo) params.reply_to_message_id = replyTo;
|
|
48
|
+
if (threadId) params.message_thread_id = threadId;
|
|
49
|
+
return telegramRequest(token, 'sendMessage', params);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Download a file from Telegram's file storage.
|
|
53
|
+
// Returns a Buffer of the file contents.
|
|
54
|
+
async function downloadTelegramFile(token, fileId) {
|
|
55
|
+
const fileInfo = await telegramRequest(token, 'getFile', { file_id: fileId });
|
|
56
|
+
if (!fileInfo.ok || !fileInfo.result?.file_path) {
|
|
57
|
+
throw new Error('Failed to get file info from Telegram');
|
|
58
|
+
}
|
|
59
|
+
const filePath = fileInfo.result.file_path;
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const req = https.get(`https://api.telegram.org/file/bot${token}/${filePath}`, (res) => {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
64
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
65
|
+
});
|
|
66
|
+
req.on('error', reject);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Send a document to Telegram using multipart/form-data.
|
|
71
|
+
async function sendTelegramDocument(token, chatIdOrState, fileData, fileName, caption) {
|
|
72
|
+
const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
|
|
73
|
+
? chatIdOrState.chatId : chatIdOrState;
|
|
74
|
+
const threadId = typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined;
|
|
75
|
+
|
|
76
|
+
const boundary = '----tgagentpBoundary' + Math.random().toString(36).slice(2);
|
|
77
|
+
const parts = [];
|
|
78
|
+
|
|
79
|
+
// chat_id field
|
|
80
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`));
|
|
81
|
+
|
|
82
|
+
// message_thread_id field (if present)
|
|
83
|
+
if (threadId) {
|
|
84
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message_thread_id"\r\n\r\n${threadId}\r\n`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// document field (the file)
|
|
88
|
+
const mimeType = 'application/octet-stream';
|
|
89
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${fileName}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
|
|
90
|
+
parts.push(fileData);
|
|
91
|
+
parts.push(Buffer.from('\r\n'));
|
|
92
|
+
|
|
93
|
+
// caption field (optional)
|
|
94
|
+
if (caption) {
|
|
95
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// closing boundary
|
|
99
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
100
|
+
|
|
101
|
+
const body = Buffer.concat(parts);
|
|
102
|
+
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const options = {
|
|
105
|
+
hostname: 'api.telegram.org',
|
|
106
|
+
port: 443,
|
|
107
|
+
path: `/bot${token}/sendDocument`,
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
111
|
+
'Content-Length': body.length,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const req = https.request(options, (res) => {
|
|
115
|
+
let data = '';
|
|
116
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
117
|
+
res.on('end', () => {
|
|
118
|
+
try {
|
|
119
|
+
const result = JSON.parse(data);
|
|
120
|
+
if (result.ok) resolve(result);
|
|
121
|
+
else reject(new Error(result.description || 'Telegram API error'));
|
|
122
|
+
} catch (e) {
|
|
123
|
+
reject(new Error(`Failed to parse Telegram response: ${data}`));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
req.on('error', reject);
|
|
128
|
+
req.write(body);
|
|
129
|
+
req.end();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
telegramRequest,
|
|
135
|
+
getUpdates,
|
|
136
|
+
sendTelegramMessage,
|
|
137
|
+
downloadTelegramFile,
|
|
138
|
+
sendTelegramDocument,
|
|
139
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { sendTelegramMessage } = require('./telegram-api.js');
|
|
4
|
+
|
|
5
|
+
// Lightweight Markdown to Telegram HTML conversion.
|
|
6
|
+
function convertMarkdownToHtml(text) {
|
|
7
|
+
const parts = text.split('```');
|
|
8
|
+
|
|
9
|
+
return parts.map((part, i) => {
|
|
10
|
+
if (i % 2 === 1) {
|
|
11
|
+
// Code block ─ strip language tag, escape entities, wrap in <pre>
|
|
12
|
+
const nl = part.indexOf('\n');
|
|
13
|
+
const inner = nl >= 0 ? part.slice(nl + 1) : part;
|
|
14
|
+
return '<pre>' + inner
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>') + '</pre>';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Text segment
|
|
21
|
+
|
|
22
|
+
// Phase 1: extract table blocks into placeholders
|
|
23
|
+
const placeholders = [];
|
|
24
|
+
let processed = '';
|
|
25
|
+
const lines = part.split('\n');
|
|
26
|
+
let li = 0;
|
|
27
|
+
while (li < lines.length) {
|
|
28
|
+
if (/^\|/.test(lines[li])) {
|
|
29
|
+
const table = [];
|
|
30
|
+
while (li < lines.length && /^\|/.test(lines[li])) {
|
|
31
|
+
table.push(lines[li]);
|
|
32
|
+
li++;
|
|
33
|
+
}
|
|
34
|
+
const hasSep = table.some(l => /^\|[-:| ]+\|$/.test(l));
|
|
35
|
+
if (table.length >= 2 && hasSep) {
|
|
36
|
+
const cleaned = table
|
|
37
|
+
.map(l => l.replace(/^\|/, '').replace(/\|$/, ''))
|
|
38
|
+
.filter(l => !/^\s*[|:\- ]+\s*$/.test(l));
|
|
39
|
+
if (cleaned.length > 0) {
|
|
40
|
+
const escaped = cleaned.map(l =>
|
|
41
|
+
l.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
42
|
+
);
|
|
43
|
+
const idx = placeholders.length;
|
|
44
|
+
placeholders.push('<pre>' + escaped.join('\n') + '</pre>');
|
|
45
|
+
processed += '\0T' + idx + '\0\n';
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const l of table) { processed += l + '\n'; }
|
|
50
|
+
} else {
|
|
51
|
+
processed += lines[li] + '\n';
|
|
52
|
+
li++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Phase 2: escape entities and apply markdown on non-table text
|
|
57
|
+
let html = processed
|
|
58
|
+
.replace(/&/g, '&')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>');
|
|
61
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
62
|
+
html = html.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
|
|
63
|
+
html = html.replace(/^\s*-{3,}\s*$/gm, '────────────────────');
|
|
64
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
65
|
+
html = html.replace(/\*(.+?)\*/g, '<i>$1</i>');
|
|
66
|
+
|
|
67
|
+
// Phase 3: restore table placeholders
|
|
68
|
+
for (let j = 0; j < placeholders.length; j++) {
|
|
69
|
+
html = html.split('\0T' + j + '\0').join(placeholders[j]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return html;
|
|
73
|
+
}).join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Split text into chunks of at most maxLength characters,
|
|
77
|
+
// breaking at newlines when possible.
|
|
78
|
+
function splitMessage(text, maxLength = 4096) {
|
|
79
|
+
if (text.length <= maxLength) return [text];
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let start = 0;
|
|
82
|
+
while (start < text.length) {
|
|
83
|
+
let end = Math.min(start + maxLength, text.length);
|
|
84
|
+
if (end < text.length) {
|
|
85
|
+
const newlinePos = text.lastIndexOf('\n', end);
|
|
86
|
+
if (newlinePos > start) {
|
|
87
|
+
end = newlinePos + 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
chunks.push(text.slice(start, end));
|
|
91
|
+
start = end;
|
|
92
|
+
}
|
|
93
|
+
return chunks;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Send a text message to a Telegram chat, splitting into multiple
|
|
97
|
+
// messages if it exceeds the 4096-character limit.
|
|
98
|
+
// Converts Markdown to Telegram HTML formatting.
|
|
99
|
+
// Splits the raw text first, then converts each chunk independently
|
|
100
|
+
// to avoid broken HTML tags across messages.
|
|
101
|
+
async function sendLongMessage(token, chatIdOrState, text, replyTo, threadId) {
|
|
102
|
+
// Accept either (token, chatState, ...) or (token, rawChatId, ...)
|
|
103
|
+
const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
|
|
104
|
+
? chatIdOrState.chatId : chatIdOrState;
|
|
105
|
+
const effectiveThreadId = threadId || (typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined);
|
|
106
|
+
const chunks = splitMessage(text);
|
|
107
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
108
|
+
const html = convertMarkdownToHtml(chunks[i]);
|
|
109
|
+
await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined, effectiveThreadId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
convertMarkdownToHtml,
|
|
115
|
+
splitMessage,
|
|
116
|
+
sendLongMessage,
|
|
117
|
+
};
|