agentp 0.11.3 → 0.11.5
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 +79 -9
- package/bin/tgagentp +14 -240
- package/docs/specification.md +1 -1
- 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';
|
|
@@ -77,6 +78,8 @@ async function main() {
|
|
|
77
78
|
let serverBase = 'http://localhost:4096';
|
|
78
79
|
let qaMode = false;
|
|
79
80
|
let flushMode = false;
|
|
81
|
+
let sessionName = null;
|
|
82
|
+
let newSession = false;
|
|
80
83
|
// tgMode: false = disabled, 'auto' = auto-detect, true = explicit --tg
|
|
81
84
|
let tgMode = null;
|
|
82
85
|
|
|
@@ -97,6 +100,8 @@ async function main() {
|
|
|
97
100
|
console.error(' --no-tg Do not forward to Telegram');
|
|
98
101
|
console.error(' --flush Flush tgagentp\'s recorded buffer without prepending it to output');
|
|
99
102
|
console.error(' --getLast Retrieve last N assistant answers (or QA pairs with --qa) from session history');
|
|
103
|
+
console.error(' --session Target a specific session by name (creates it if not found with --new)');
|
|
104
|
+
console.error(' --new Create a new session with the given title (requires --session)');
|
|
100
105
|
console.error(' --help Show this help message');
|
|
101
106
|
console.error('');
|
|
102
107
|
console.error('By default, --qa auto-detects tgagentp; standalone mode implies --no-tg.');
|
|
@@ -109,6 +114,10 @@ async function main() {
|
|
|
109
114
|
console.error('--flush clears the tgagentp recorded buffer and also prevents the prepended');
|
|
110
115
|
console.error('context from appearing in --qa output. Use it when you want fresh context.');
|
|
111
116
|
console.error('');
|
|
117
|
+
console.error('--session <name> targets a specific session by exact or partial name match.');
|
|
118
|
+
console.error('If the session does not exist, agentp errors (use --new to create it).');
|
|
119
|
+
console.error('--new creates a new session with the given title (does not match existing sessions).');
|
|
120
|
+
console.error('');
|
|
112
121
|
console.error('Arguments:');
|
|
113
122
|
console.error(' url OpenCode server URL or port number (default: 4096)');
|
|
114
123
|
console.error(' Examples: 4096, http://localhost:4096, http://192.168.1.50:4096');
|
|
@@ -119,6 +128,8 @@ async function main() {
|
|
|
119
128
|
console.error('Examples:');
|
|
120
129
|
console.error(' printf "Summarize this file" | agentp');
|
|
121
130
|
console.error(' cat prompt.txt | agentp $(ocmux) # talk to current project\'s server');
|
|
131
|
+
console.error(' cat prompt.txt | agentp --session "My Task" $(ocmux)');
|
|
132
|
+
console.error(' cat prompt.txt | agentp --session "New Task" --new $(ocmux)');
|
|
122
133
|
console.error(' # Vim/Neovim filter, preserving prompt + answer:');
|
|
123
134
|
console.error(" :'<,'>!agentp --qa $(ocmux)");
|
|
124
135
|
console.error(' # Retrieve last 3 assistant answers:');
|
|
@@ -130,6 +141,15 @@ async function main() {
|
|
|
130
141
|
qaMode = true;
|
|
131
142
|
} else if (args[i] === '--flush') {
|
|
132
143
|
flushMode = true;
|
|
144
|
+
} else if (args[i] === '--session') {
|
|
145
|
+
i++;
|
|
146
|
+
sessionName = args[i];
|
|
147
|
+
if (!sessionName) {
|
|
148
|
+
console.error('Error: --session requires a session name');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
} else if (args[i] === '--new') {
|
|
152
|
+
newSession = true;
|
|
133
153
|
} else if (args[i] === '--getLast') {
|
|
134
154
|
i++;
|
|
135
155
|
const nStr = args[i];
|
|
@@ -145,8 +165,12 @@ async function main() {
|
|
|
145
165
|
process.exit(1);
|
|
146
166
|
}
|
|
147
167
|
const sorted = sessions
|
|
148
|
-
.filter(s => s.time && s.time.updated)
|
|
149
|
-
.sort((a, b) =>
|
|
168
|
+
.filter(s => s.time && (s.time.updated || s.time.created))
|
|
169
|
+
.sort((a, b) => {
|
|
170
|
+
const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
|
|
171
|
+
const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
|
|
172
|
+
return bTime - aTime;
|
|
173
|
+
});
|
|
150
174
|
const sessionId = sorted[0].id;
|
|
151
175
|
|
|
152
176
|
function buildPairsFromMessages(messages) {
|
|
@@ -275,10 +299,51 @@ async function main() {
|
|
|
275
299
|
}
|
|
276
300
|
|
|
277
301
|
let sessionId = null;
|
|
278
|
-
|
|
302
|
+
|
|
303
|
+
if (sessionName) {
|
|
304
|
+
// Find session by name
|
|
305
|
+
const matches = sessions.filter(s => {
|
|
306
|
+
const title = s.title || s.slug || s.id;
|
|
307
|
+
return title.toLowerCase() === sessionName.toLowerCase();
|
|
308
|
+
});
|
|
309
|
+
if (matches.length === 0) {
|
|
310
|
+
// Try partial match
|
|
311
|
+
const partial = sessions.filter(s => {
|
|
312
|
+
const title = s.title || s.slug || s.id;
|
|
313
|
+
return title.toLowerCase().includes(sessionName.toLowerCase());
|
|
314
|
+
});
|
|
315
|
+
if (partial.length === 1) {
|
|
316
|
+
sessionId = partial[0].id;
|
|
317
|
+
} else if (partial.length > 1) {
|
|
318
|
+
console.error(`Error: multiple sessions match "${sessionName}":`);
|
|
319
|
+
partial.forEach(s => console.error(` - ${s.title || s.slug || s.id}`));
|
|
320
|
+
process.exit(1);
|
|
321
|
+
} else if (newSession) {
|
|
322
|
+
// Create new session
|
|
323
|
+
try {
|
|
324
|
+
const session = await createSession(serverBase, sessionName);
|
|
325
|
+
sessionId = session.id;
|
|
326
|
+
await selectSession(serverBase, sessionId);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.error('Error creating session:', err.message);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
console.error(`Error: no session found matching "${sessionName}"`);
|
|
333
|
+
console.error('Use --new to create a new session with this name');
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
sessionId = matches[0].id;
|
|
338
|
+
}
|
|
339
|
+
} else if (sessions && sessions.length > 0) {
|
|
279
340
|
const sorted = sessions
|
|
280
|
-
.filter(s => s.time && s.time.updated)
|
|
281
|
-
.sort((a, b) =>
|
|
341
|
+
.filter(s => s.time && (s.time.updated || s.time.created))
|
|
342
|
+
.sort((a, b) => {
|
|
343
|
+
const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
|
|
344
|
+
const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
|
|
345
|
+
return bTime - aTime;
|
|
346
|
+
});
|
|
282
347
|
if (sorted.length > 0) sessionId = sorted[0].id;
|
|
283
348
|
}
|
|
284
349
|
|
|
@@ -344,7 +409,12 @@ async function main() {
|
|
|
344
409
|
}
|
|
345
410
|
}
|
|
346
411
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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/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,12 @@ 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
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
1817
|
+
st.messageQueue.push({ text: noteText, replyTo: null });
|
|
1818
|
+
await sendLongMessage(token, chatState, `⏳ Server is busy. Note queued (#${st.messageQueue.length}).`);
|
|
2044
1819
|
return;
|
|
2045
1820
|
}
|
|
2046
1821
|
|
|
@@ -2049,11 +1824,10 @@ async function main() {
|
|
|
2049
1824
|
const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(url));
|
|
2050
1825
|
if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
|
|
2051
1826
|
|
|
2052
|
-
const intro = 'The following information is for your awareness. Reply with only "Ack" and do not take any action:';
|
|
2053
1827
|
st.busy = true;
|
|
2054
1828
|
const myTaskId = ++st.taskId;
|
|
2055
1829
|
log.debug(` → /note: starting background processing (task ${myTaskId})`);
|
|
2056
|
-
processMessageAsync(chatId,
|
|
1830
|
+
processMessageAsync(chatId, noteText, url, !!st.activeSessionId, st.activeSessionId, myTaskId, null, chatState.threadId);
|
|
2057
1831
|
};
|
|
2058
1832
|
|
|
2059
1833
|
const cmds = { ...COMMANDS, status: cmdStatus, cancel: cmdCancel, note: cmdNote };
|
package/docs/specification.md
CHANGED
|
@@ -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
|
+
};
|