obol-ai 0.2.9 → 0.2.11
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/.claude/settings.local.json +2 -1
- package/package.json +2 -1
- package/src/claude.js +61 -15
- package/src/history.js +37 -6
- package/src/telegram.js +538 -199
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"inquirer": "^8.2.6",
|
|
31
31
|
"node-cron": "^3.0.3",
|
|
32
32
|
"open": "^8.4.2",
|
|
33
|
+
"pdf-parse": "^2.4.5",
|
|
33
34
|
"pdfkit": "^0.17.2"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
package/src/claude.js
CHANGED
|
@@ -8,7 +8,7 @@ const { execAsync, isAllowedUrl } = require('./sanitize');
|
|
|
8
8
|
const { ChatHistory } = require('./history');
|
|
9
9
|
|
|
10
10
|
const MAX_EXEC_TIMEOUT = 120;
|
|
11
|
-
let MAX_TOOL_ITERATIONS =
|
|
11
|
+
let MAX_TOOL_ITERATIONS = 10;
|
|
12
12
|
|
|
13
13
|
const BLOCKED_EXEC_PATTERNS = [
|
|
14
14
|
/\brm\s+(-[a-zA-Z]*f|-[a-zA-Z]*r|--force|--recursive)\b/,
|
|
@@ -204,6 +204,12 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
|
|
|
204
204
|
|
|
205
205
|
vlog(`[router] model=${decision.model || 'sonnet'} memory=${decision.need_memory || false}${decision.search_query ? ` query="${decision.search_query}"` : ''}`);
|
|
206
206
|
|
|
207
|
+
context._onRouteDecision?.({
|
|
208
|
+
model: decision.model || 'sonnet',
|
|
209
|
+
needMemory: decision.need_memory || false,
|
|
210
|
+
memoryCount: 0,
|
|
211
|
+
});
|
|
212
|
+
|
|
207
213
|
if (decision.model === 'opus') {
|
|
208
214
|
context._model = 'claude-opus-4-6';
|
|
209
215
|
} else if (decision.model === 'haiku') {
|
|
@@ -227,6 +233,8 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
|
|
|
227
233
|
|
|
228
234
|
vlog(`[memory] ${combined.length} memories found (${todayMemories.length} today, ${semanticMemories.length} semantic)`);
|
|
229
235
|
|
|
236
|
+
context._onRouteUpdate?.({ memoryCount: combined.length });
|
|
237
|
+
|
|
230
238
|
if (combined.length > 0) {
|
|
231
239
|
memoryContext = '\n\n[Relevant memories]\n' +
|
|
232
240
|
combined.map(m => `- [${m.category}] ${m.content}`).join('\n');
|
|
@@ -298,7 +306,22 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
|
|
|
298
306
|
return { text, usage: totalUsage, model };
|
|
299
307
|
}
|
|
300
308
|
|
|
301
|
-
|
|
309
|
+
let text = finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
310
|
+
|
|
311
|
+
if (!text.trim() && newMessages.length > 1) {
|
|
312
|
+
vlog('[claude] No text in final response after tool use — forcing summary');
|
|
313
|
+
histories.pushUser(chatId, 'Provide a concise response to the user based on the tool results above.');
|
|
314
|
+
const summaryResponse = await client.messages.create({
|
|
315
|
+
model, max_tokens: 4096, system: systemPrompt, messages: [...histories.get(chatId)],
|
|
316
|
+
}, { signal: abortController.signal });
|
|
317
|
+
histories.pushAssistant(chatId, summaryResponse.content);
|
|
318
|
+
if (summaryResponse.usage) {
|
|
319
|
+
totalUsage.input_tokens += summaryResponse.usage.input_tokens || 0;
|
|
320
|
+
totalUsage.output_tokens += summaryResponse.usage.output_tokens || 0;
|
|
321
|
+
}
|
|
322
|
+
text = summaryResponse.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
302
325
|
return { text, usage: totalUsage, model };
|
|
303
326
|
|
|
304
327
|
} catch (e) {
|
|
@@ -537,26 +560,39 @@ Only available if bridge is enabled. Communicate with partner's AI agent.
|
|
|
537
560
|
parts.push(`
|
|
538
561
|
## Telegram Formatting
|
|
539
562
|
|
|
540
|
-
You communicate via Telegram.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
563
|
+
You communicate via Telegram. Use ONLY Telegram Markdown syntax — never GitHub-flavored Markdown.
|
|
564
|
+
|
|
565
|
+
ALLOWED formatting:
|
|
566
|
+
- *bold* (single asterisks)
|
|
567
|
+
- _italic_ (underscores)
|
|
568
|
+
- \`inline code\` (backticks)
|
|
569
|
+
- \`\`\`code blocks\`\`\` (triple backticks)
|
|
570
|
+
|
|
571
|
+
FORBIDDEN formatting — these do NOT render in Telegram:
|
|
572
|
+
- **double asterisks** — use *single asterisks* instead
|
|
573
|
+
- ## headings — use *bold text* on its own line instead
|
|
574
|
+
- --- horizontal rules — use a blank line instead
|
|
575
|
+
- [text](url) links — just paste the raw URL
|
|
576
|
+
- > blockquotes — not supported
|
|
577
|
+
|
|
578
|
+
Structure tips:
|
|
579
|
+
- Break content into short paragraphs with blank lines
|
|
580
|
+
- Use *bold* sparingly for section titles on their own line
|
|
581
|
+
- Use numbered lists (1. 2. 3.) or bullet dashes (- item)
|
|
582
|
+
- Keep lines short — Telegram wraps poorly on mobile
|
|
583
|
+
- Never use markdown tables — use numbered lists instead
|
|
584
|
+
|
|
585
|
+
*Email/inbox lists* — use this pattern:
|
|
546
586
|
📬 *Inbox (10)*
|
|
547
587
|
|
|
548
588
|
1\\. *Google* — Security alert \`22:58\`
|
|
549
589
|
2\\. *LinkedIn* — Matthew Chittle wants to connect \`21:31\`
|
|
550
590
|
3\\. *DeepLearning\\.AI* — AI Dev 26 × SF speakers \`13:20\`
|
|
551
|
-
4\\. *LinkedIn Jobs* — Project Manager / TPM roles \`17:32\`
|
|
552
|
-
\`\`\`
|
|
553
591
|
|
|
554
|
-
|
|
592
|
+
*Copyable values* (email addresses, URLs, API keys, commands) — wrap in backtick code spans:
|
|
555
593
|
\`user@example.com\`, \`https://example.com\`, \`npm install foo\`
|
|
556
594
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
**Keep lines short** — Telegram wraps long lines poorly on mobile. Break at natural points.
|
|
595
|
+
*Human-in-the-loop* — after listing emails or before acting, use \`telegram_ask\` to offer inline buttons rather than asking the user to type a reply.
|
|
560
596
|
`);
|
|
561
597
|
|
|
562
598
|
// Safety rules (hardcoded — never drifts)
|
|
@@ -579,6 +615,7 @@ You communicate via Telegram. Format responses for mobile readability.
|
|
|
579
615
|
- Search memory before claiming you don't know something
|
|
580
616
|
- Use \`store_secret\`/\`read_secret\` for all credential operations
|
|
581
617
|
- If a user sends what appears to be an API key, token, or credential in conversation, immediately warn them that it's visible in chat history, tell them to revoke/rotate it, and direct them to use \`/secret set <key> <value>\` instead
|
|
618
|
+
- After executing tools (exec, web_fetch, read_secret, etc.), ALWAYS provide a text response summarizing what you found or did. Never end your turn with only tool calls and no text reply — the user cannot see tool results directly, they only see your text responses
|
|
582
619
|
`);
|
|
583
620
|
|
|
584
621
|
return parts.join('\n');
|
|
@@ -702,7 +739,7 @@ function buildTools(memory, opts = {}) {
|
|
|
702
739
|
// Read/write files
|
|
703
740
|
tools.push({
|
|
704
741
|
name: 'read_file',
|
|
705
|
-
description: 'Read contents of a file.',
|
|
742
|
+
description: 'Read contents of a file. Supports text files and PDFs (extracts text from PDF automatically).',
|
|
706
743
|
input_schema: {
|
|
707
744
|
type: 'object',
|
|
708
745
|
properties: {
|
|
@@ -848,6 +885,8 @@ function buildRunnableTools(tools, memory, context, vlog) {
|
|
|
848
885
|
tool.name === 'cancel_event' ? input.event_id :
|
|
849
886
|
JSON.stringify(input).substring(0, 80);
|
|
850
887
|
vlog(`[tool] ${tool.name}: ${inputSummary}`);
|
|
888
|
+
|
|
889
|
+
context._onToolStart?.(tool.name, inputSummary);
|
|
851
890
|
return await executeToolCall({ name: tool.name, input }, memory, context);
|
|
852
891
|
},
|
|
853
892
|
}));
|
|
@@ -1014,6 +1053,13 @@ async function executeToolCall(toolUse, memory, context = {}) {
|
|
|
1014
1053
|
|
|
1015
1054
|
case 'read_file': {
|
|
1016
1055
|
const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
|
|
1056
|
+
if (filePath.toLowerCase().endsWith('.pdf')) {
|
|
1057
|
+
const pdfParse = require('pdf-parse');
|
|
1058
|
+
const pdfBuffer = fs.readFileSync(filePath);
|
|
1059
|
+
const { text } = await pdfParse(pdfBuffer);
|
|
1060
|
+
const truncatedPdf = text.substring(0, 50000);
|
|
1061
|
+
return text.length > 50000 ? truncatedPdf + '\n...(truncated)' : truncatedPdf;
|
|
1062
|
+
}
|
|
1017
1063
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
1018
1064
|
const truncatedFile = fileContent.substring(0, 50000);
|
|
1019
1065
|
return fileContent.length > 50000 ? truncatedFile + '\n...(truncated)' : truncatedFile;
|
package/src/history.js
CHANGED
|
@@ -119,6 +119,7 @@ function validate(messages) {
|
|
|
119
119
|
|
|
120
120
|
const allToolUseIds = new Set();
|
|
121
121
|
const allToolResultIds = new Set();
|
|
122
|
+
const duplicateToolResultIds = [];
|
|
122
123
|
|
|
123
124
|
for (const msg of messages) {
|
|
124
125
|
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
@@ -128,11 +129,20 @@ function validate(messages) {
|
|
|
128
129
|
}
|
|
129
130
|
if (msg.role === 'user' && Array.isArray(msg.content)) {
|
|
130
131
|
for (const b of msg.content) {
|
|
131
|
-
if (b.type === 'tool_result')
|
|
132
|
+
if (b.type === 'tool_result') {
|
|
133
|
+
if (allToolResultIds.has(b.tool_use_id)) {
|
|
134
|
+
duplicateToolResultIds.push(b.tool_use_id);
|
|
135
|
+
}
|
|
136
|
+
allToolResultIds.add(b.tool_use_id);
|
|
137
|
+
}
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
for (const id of duplicateToolResultIds) {
|
|
143
|
+
errors.push(`duplicate tool_result for tool_use_id=${id}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
136
146
|
for (const id of allToolResultIds) {
|
|
137
147
|
if (!allToolUseIds.has(id)) {
|
|
138
148
|
errors.push(`orphaned tool_result for tool_use_id=${id}`);
|
|
@@ -150,12 +160,18 @@ function validate(messages) {
|
|
|
150
160
|
|
|
151
161
|
function repair(messages) {
|
|
152
162
|
const allToolUseIds = new Set();
|
|
163
|
+
const allToolResultIds = new Set();
|
|
153
164
|
for (const msg of messages) {
|
|
154
165
|
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
155
166
|
for (const b of msg.content) {
|
|
156
167
|
if (b.type === 'tool_use') allToolUseIds.add(b.id);
|
|
157
168
|
}
|
|
158
169
|
}
|
|
170
|
+
if (msg.role === 'user' && Array.isArray(msg.content)) {
|
|
171
|
+
for (const b of msg.content) {
|
|
172
|
+
if (b.type === 'tool_result') allToolResultIds.add(b.tool_use_id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
159
175
|
}
|
|
160
176
|
|
|
161
177
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -183,7 +199,7 @@ function repair(messages) {
|
|
|
183
199
|
if (next?.role === 'user' && Array.isArray(next.content)) {
|
|
184
200
|
const existingIds = new Set(
|
|
185
201
|
next.content.filter(b => b.type === 'tool_result').map(b => b.tool_use_id));
|
|
186
|
-
const missingIds = toolUseIds.filter(id => !existingIds.has(id));
|
|
202
|
+
const missingIds = toolUseIds.filter(id => !existingIds.has(id) && !allToolResultIds.has(id));
|
|
187
203
|
if (missingIds.length > 0) {
|
|
188
204
|
next.content = [
|
|
189
205
|
...next.content,
|
|
@@ -193,10 +209,14 @@ function repair(messages) {
|
|
|
193
209
|
];
|
|
194
210
|
}
|
|
195
211
|
} else {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
212
|
+
const existingElsewhere = toolUseIds.filter(id => allToolResultIds.has(id));
|
|
213
|
+
const trulyMissing = toolUseIds.filter(id => !allToolResultIds.has(id));
|
|
214
|
+
if (trulyMissing.length > 0) {
|
|
215
|
+
const fakeResults = trulyMissing.map(id => ({
|
|
216
|
+
type: 'tool_result', tool_use_id: id, content: '[interrupted]',
|
|
217
|
+
}));
|
|
218
|
+
messages.splice(i + 1, 0, { role: 'user', content: fakeResults });
|
|
219
|
+
}
|
|
200
220
|
}
|
|
201
221
|
}
|
|
202
222
|
|
|
@@ -212,6 +232,17 @@ function repair(messages) {
|
|
|
212
232
|
messages.splice(i, 1);
|
|
213
233
|
}
|
|
214
234
|
}
|
|
235
|
+
|
|
236
|
+
for (const msg of messages) {
|
|
237
|
+
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
msg.content = msg.content.filter(b => {
|
|
240
|
+
if (b.type !== 'tool_result') return true;
|
|
241
|
+
if (seen.has(b.tool_use_id)) return false;
|
|
242
|
+
seen.add(b.tool_use_id);
|
|
243
|
+
return true;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
class ChatHistory {
|
package/src/telegram.js
CHANGED
|
@@ -14,8 +14,87 @@ const RATE_LIMIT_MS = 3000;
|
|
|
14
14
|
const SPAM_THRESHOLD = 5;
|
|
15
15
|
const SPAM_COOLDOWN_MS = 30000;
|
|
16
16
|
const EVOLUTION_IDLE_MS = 15 * 60 * 1000;
|
|
17
|
+
const DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
18
|
+
const DEDUP_MAX_SIZE = 2000;
|
|
19
|
+
const TEXT_BUFFER_GAP_MS = 1500;
|
|
20
|
+
const TEXT_BUFFER_MAX_PARTS = 12;
|
|
21
|
+
const TEXT_BUFFER_MAX_CHARS = 50000;
|
|
22
|
+
const TEXT_BUFFER_THRESHOLD = 4000;
|
|
23
|
+
const MEDIA_GROUP_DELAY_MS = 500;
|
|
24
|
+
const TERM_WIDTH = 25;
|
|
25
|
+
const TERM_SEP = '━'.repeat(TERM_WIDTH);
|
|
17
26
|
|
|
18
27
|
const _evolutionTimers = new Map();
|
|
28
|
+
const _toolDescriptionCache = new Map();
|
|
29
|
+
|
|
30
|
+
function termBar(pct, width = 20) {
|
|
31
|
+
const filled = Math.round((pct / 100) * width);
|
|
32
|
+
return '━'.repeat(filled) + '╌'.repeat(width - filled);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildStatusHtml({ route, elapsed, toolStatus }) {
|
|
36
|
+
const lines = [`◈ OBOL ${'━'.repeat(TERM_WIDTH - 7)}`];
|
|
37
|
+
if (route) {
|
|
38
|
+
lines.push(`⬡ ROUTE ${(route.model || 'sonnet').toUpperCase()}`);
|
|
39
|
+
if (route.memoryCount > 0) {
|
|
40
|
+
lines.push(`⬡ MEMORY ${route.memoryCount} recalled`);
|
|
41
|
+
} else if (route.needMemory) {
|
|
42
|
+
lines.push(`⬡ MEMORY scanning`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (toolStatus) {
|
|
46
|
+
lines.push(`▸ ${toolStatus}`);
|
|
47
|
+
} else {
|
|
48
|
+
lines.push(`▸ Processing`);
|
|
49
|
+
}
|
|
50
|
+
const es = elapsed > 0 ? ` ${elapsed}s ` : '';
|
|
51
|
+
const padLen = Math.max(0, TERM_WIDTH - es.length);
|
|
52
|
+
const left = Math.floor(padLen / 2);
|
|
53
|
+
const right = padLen - left;
|
|
54
|
+
lines.push(`${'━'.repeat(left)}${es}${'━'.repeat(right)}`);
|
|
55
|
+
return `<pre>${lines.join('\n')}</pre>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function markdownToTelegramHtml(text) {
|
|
59
|
+
const codeBlocks = [];
|
|
60
|
+
let result = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
|
61
|
+
const idx = codeBlocks.length;
|
|
62
|
+
const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
63
|
+
codeBlocks.push(`<pre>${escaped}</pre>`);
|
|
64
|
+
return `\x00CB${idx}\x00`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const inlineCode = [];
|
|
68
|
+
result = result.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
69
|
+
const idx = inlineCode.length;
|
|
70
|
+
const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
71
|
+
inlineCode.push(`<code>${escaped}</code>`);
|
|
72
|
+
return `\x00IC${idx}\x00`;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
result = result.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
76
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
77
|
+
result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
78
|
+
result = result.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
|
79
|
+
result = result.replace(/(?<!\w)\*([^\s*](?:.*?[^\s*])?)\*(?!\w)/g, '<i>$1</i>');
|
|
80
|
+
result = result.replace(/(?<!\w)_([^\s_](?:.*?[^\s_])?)_(?!\w)/g, '<i>$1</i>');
|
|
81
|
+
|
|
82
|
+
result = result.replace(/\x00CB(\d+)\x00/g, (_, idx) => codeBlocks[parseInt(idx)]);
|
|
83
|
+
result = result.replace(/\x00IC(\d+)\x00/g, (_, idx) => inlineCode[parseInt(idx)]);
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sendHtml(ctx, text, extra = {}) {
|
|
89
|
+
const html = markdownToTelegramHtml(text);
|
|
90
|
+
return ctx.reply(html, { parse_mode: 'HTML', ...extra }).catch(() => ctx.reply(text, extra));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function editHtml(ctx, chatId, messageId, text, extra = {}) {
|
|
94
|
+
const html = markdownToTelegramHtml(text);
|
|
95
|
+
return ctx.api.editMessageText(chatId, messageId, html, { parse_mode: 'HTML', ...extra })
|
|
96
|
+
.catch(() => ctx.api.editMessageText(chatId, messageId, text, extra));
|
|
97
|
+
}
|
|
19
98
|
|
|
20
99
|
function startTyping(ctx) {
|
|
21
100
|
ctx.replyWithChatAction('typing').catch(() => {});
|
|
@@ -30,8 +109,32 @@ function createBot(telegramConfig, config) {
|
|
|
30
109
|
const allowedUsers = new Set(telegramConfig.allowedUsers || []);
|
|
31
110
|
const rateLimits = new Map();
|
|
32
111
|
const pendingAsks = new Map();
|
|
112
|
+
const processedUpdates = new Map();
|
|
113
|
+
const textBuffers = new Map();
|
|
114
|
+
const mediaGroups = new Map();
|
|
33
115
|
let askIdCounter = 0;
|
|
34
116
|
|
|
117
|
+
function describeToolCall(client, toolName, inputSummary) {
|
|
118
|
+
const key = `${toolName}:${inputSummary}`;
|
|
119
|
+
const cached = _toolDescriptionCache.get(key);
|
|
120
|
+
if (cached) return Promise.resolve(cached);
|
|
121
|
+
|
|
122
|
+
return client.messages.create({
|
|
123
|
+
model: 'claude-haiku-4-5',
|
|
124
|
+
max_tokens: 30,
|
|
125
|
+
system: 'Describe this tool call in 3-8 words from the user\'s perspective. Present participle. No quotes, period, or emoji.',
|
|
126
|
+
messages: [{ role: 'user', content: `${toolName}: ${inputSummary}` }],
|
|
127
|
+
}).then(r => {
|
|
128
|
+
const desc = r.content[0]?.text?.trim() || null;
|
|
129
|
+
if (desc) _toolDescriptionCache.set(key, desc);
|
|
130
|
+
if (_toolDescriptionCache.size > 200) {
|
|
131
|
+
const first = _toolDescriptionCache.keys().next().value;
|
|
132
|
+
_toolDescriptionCache.delete(first);
|
|
133
|
+
}
|
|
134
|
+
return desc;
|
|
135
|
+
}).catch(() => null);
|
|
136
|
+
}
|
|
137
|
+
|
|
35
138
|
function createAsk(ctx, message, options, timeoutSecs = 60) {
|
|
36
139
|
return new Promise((resolve) => {
|
|
37
140
|
const askId = ++askIdCounter;
|
|
@@ -47,7 +150,7 @@ function createBot(telegramConfig, config) {
|
|
|
47
150
|
}
|
|
48
151
|
}, timeoutSecs * 1000);
|
|
49
152
|
pendingAsks.set(askId, { resolve, options, timer });
|
|
50
|
-
ctx
|
|
153
|
+
sendHtml(ctx, message, { reply_markup: keyboard }).catch(() => {
|
|
51
154
|
clearTimeout(timer);
|
|
52
155
|
pendingAsks.delete(askId);
|
|
53
156
|
resolve('error');
|
|
@@ -63,6 +166,21 @@ function createBot(telegramConfig, config) {
|
|
|
63
166
|
}, 600000);
|
|
64
167
|
_rateLimitCleanup.unref();
|
|
65
168
|
|
|
169
|
+
bot.use(async (ctx, next) => {
|
|
170
|
+
const updateId = ctx.update?.update_id;
|
|
171
|
+
if (updateId != null) {
|
|
172
|
+
if (processedUpdates.has(updateId)) return;
|
|
173
|
+
processedUpdates.set(updateId, Date.now());
|
|
174
|
+
if (processedUpdates.size > DEDUP_MAX_SIZE) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
for (const [id, ts] of processedUpdates) {
|
|
177
|
+
if (now - ts > DEDUP_TTL_MS) processedUpdates.delete(id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await next();
|
|
182
|
+
});
|
|
183
|
+
|
|
66
184
|
bot.use(async (ctx, next) => {
|
|
67
185
|
if (allowedUsers.size > 0 && !allowedUsers.has(ctx.from?.id)) {
|
|
68
186
|
return;
|
|
@@ -91,7 +209,7 @@ function createBot(telegramConfig, config) {
|
|
|
91
209
|
]).catch(() => {});
|
|
92
210
|
|
|
93
211
|
bot.command('start', async (ctx) => {
|
|
94
|
-
await ctx.reply(
|
|
212
|
+
await ctx.reply(`<pre>◈ OBOL v${pkg.version}\n${TERM_SEP}\nSYSTEM ONLINE\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
|
|
95
213
|
});
|
|
96
214
|
|
|
97
215
|
bot.command('memory', async (ctx) => {
|
|
@@ -122,7 +240,7 @@ function createBot(telegramConfig, config) {
|
|
|
122
240
|
if (!ctx.from) return;
|
|
123
241
|
const tenant = await getTenant(ctx.from.id, config);
|
|
124
242
|
tenant.claude.clearHistory(ctx.chat.id);
|
|
125
|
-
await ctx.reply(
|
|
243
|
+
await ctx.reply(`<pre>◈ CONTEXT CLEARED\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
|
|
126
244
|
});
|
|
127
245
|
|
|
128
246
|
bot.command('status', async (ctx) => {
|
|
@@ -134,35 +252,49 @@ function createBot(telegramConfig, config) {
|
|
|
134
252
|
const m = Math.floor((uptime % 3600) / 60);
|
|
135
253
|
const running = tenant.bg.getStatus();
|
|
136
254
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
255
|
+
const lines = [
|
|
256
|
+
`◈ OBOL SYSTEM STATUS`,
|
|
257
|
+
TERM_SEP,
|
|
258
|
+
``,
|
|
259
|
+
`RUNTIME`,
|
|
260
|
+
` uptime ${h}h ${m}m`,
|
|
261
|
+
` memory ${mem}MB`,
|
|
262
|
+
` tasks ${running.length} active`,
|
|
263
|
+
` tools ${getMaxToolIterations()} max iter`,
|
|
264
|
+
];
|
|
142
265
|
|
|
143
266
|
if (tenant.memory) {
|
|
144
267
|
const stats = await tenant.memory.stats().catch(() => null);
|
|
145
|
-
|
|
268
|
+
lines.push(``, `MEMORY BANK`);
|
|
269
|
+
lines.push(` stored ${stats ? stats.total : '?'} memories`);
|
|
146
270
|
}
|
|
147
271
|
|
|
148
272
|
const ctxStats = tenant.claude.getContextStats(ctx.chat.id);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
273
|
+
lines.push(
|
|
274
|
+
``, `CONTEXT`,
|
|
275
|
+
` ${termBar(ctxStats.pct)} ${ctxStats.pct}%`,
|
|
276
|
+
` ${(ctxStats.estimatedTokens / 1000).toFixed(1)}k / ${(ctxStats.maxTokens / 1000).toFixed(0)}k tokens`,
|
|
277
|
+
` ${ctxStats.messages} messages`,
|
|
278
|
+
);
|
|
152
279
|
|
|
153
280
|
const evoState = loadEvolutionState(tenant.userDir);
|
|
154
281
|
const cfg = loadConfig();
|
|
155
282
|
const threshold = cfg?.evolution?.exchanges || 100;
|
|
156
283
|
const evoCount = evoState.exchangesSinceLastEvolution || 0;
|
|
157
284
|
const evoPct = Math.min(100, Math.round((evoCount / threshold) * 100));
|
|
158
|
-
|
|
159
|
-
|
|
285
|
+
lines.push(
|
|
286
|
+
``, `EVOLUTION`,
|
|
287
|
+
` ${termBar(evoPct)} ${evoPct}%`,
|
|
288
|
+
` ${evoCount}/${threshold} exchanges ▪ ${evoState.evolutionCount || 0} completed`,
|
|
289
|
+
);
|
|
160
290
|
|
|
161
291
|
const personalityDir = path.join(tenant.userDir, 'personality');
|
|
162
292
|
const traits = loadTraits(personalityDir);
|
|
163
|
-
|
|
293
|
+
lines.push(``, `TRAITS`);
|
|
294
|
+
lines.push(formatTraits(traits));
|
|
295
|
+
lines.push(TERM_SEP);
|
|
164
296
|
|
|
165
|
-
await ctx.reply(
|
|
297
|
+
await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
|
|
166
298
|
});
|
|
167
299
|
|
|
168
300
|
bot.command('backup', async (ctx) => {
|
|
@@ -250,7 +382,8 @@ function createBot(telegramConfig, config) {
|
|
|
250
382
|
saveTraits(personalityDir, { ...DEFAULT_TRAITS });
|
|
251
383
|
tenant.claude.reloadPersonality();
|
|
252
384
|
const traits = { ...DEFAULT_TRAITS };
|
|
253
|
-
|
|
385
|
+
const lines = [`◈ OBOL PERSONALITY MATRIX`, TERM_SEP, `RESET TO DEFAULTS`, ``, formatTraits(traits), TERM_SEP];
|
|
386
|
+
await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
|
|
254
387
|
return;
|
|
255
388
|
}
|
|
256
389
|
|
|
@@ -269,12 +402,14 @@ function createBot(telegramConfig, config) {
|
|
|
269
402
|
traits[traitName] = value;
|
|
270
403
|
saveTraits(personalityDir, traits);
|
|
271
404
|
tenant.claude.reloadPersonality();
|
|
272
|
-
|
|
405
|
+
const lines = [`◈ OBOL PERSONALITY MATRIX`, TERM_SEP, `UPDATED ${traitName} → ${value}`, ``, formatTraits(traits), TERM_SEP];
|
|
406
|
+
await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
|
|
273
407
|
return;
|
|
274
408
|
}
|
|
275
409
|
|
|
276
410
|
const traits = loadTraits(personalityDir);
|
|
277
|
-
|
|
411
|
+
const lines = [`◈ OBOL PERSONALITY MATRIX`, TERM_SEP, ``, formatTraits(traits), ``, `/traits <name> <0-100>`, `/traits reset`, TERM_SEP];
|
|
412
|
+
await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
|
|
278
413
|
});
|
|
279
414
|
|
|
280
415
|
bot.command('secret', async (ctx) => {
|
|
@@ -339,16 +474,20 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
339
474
|
const threshold = cfg?.evolution?.exchanges || 100;
|
|
340
475
|
const count = state.exchangesSinceLastEvolution || 0;
|
|
341
476
|
const pct = Math.min(100, Math.round((count / threshold) * 100));
|
|
342
|
-
const bar = '█'.repeat(Math.floor(pct / 5)) + '░'.repeat(20 - Math.floor(pct / 5));
|
|
343
477
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
478
|
+
const lines = [
|
|
479
|
+
`◈ OBOL EVOLUTION CYCLE`,
|
|
480
|
+
TERM_SEP,
|
|
481
|
+
``,
|
|
482
|
+
` ${termBar(pct)} ${pct}%`,
|
|
483
|
+
` ${count}/${threshold} exchanges`,
|
|
484
|
+
` ${state.evolutionCount || 0} completed`,
|
|
485
|
+
];
|
|
348
486
|
if (state.lastEvolution) {
|
|
349
|
-
|
|
487
|
+
lines.push(` last ${new Date(state.lastEvolution).toLocaleDateString()}`);
|
|
350
488
|
}
|
|
351
|
-
|
|
489
|
+
lines.push(TERM_SEP);
|
|
490
|
+
await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
|
|
352
491
|
});
|
|
353
492
|
|
|
354
493
|
bot.command('events', async (ctx) => {
|
|
@@ -363,9 +502,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
363
502
|
const dueLocal = new Date(e.due_at).toLocaleString('en-US', { timeZone: tz, dateStyle: 'medium', timeStyle: 'short' });
|
|
364
503
|
return `${i + 1}. *${e.title}*\n ${dueLocal} (${tz})\n \`${e.id}\``;
|
|
365
504
|
}).join('\n\n');
|
|
366
|
-
await ctx
|
|
367
|
-
ctx.reply(`📅 Upcoming Events\n\n${text.replace(/\*/g, '')}`)
|
|
368
|
-
);
|
|
505
|
+
await sendHtml(ctx, `📅 **Upcoming Events**\n\n${text}`);
|
|
369
506
|
} catch (e) {
|
|
370
507
|
await ctx.reply(`⚠️ ${e.message}`);
|
|
371
508
|
}
|
|
@@ -375,13 +512,18 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
375
512
|
if (!ctx.from) return;
|
|
376
513
|
const tenant = await getTenant(ctx.from.id, config);
|
|
377
514
|
const running = tenant.bg.getStatus();
|
|
515
|
+
const lines = [`◈ OBOL ACTIVE TASKS`, TERM_SEP];
|
|
378
516
|
if (running.length === 0) {
|
|
379
|
-
|
|
517
|
+
lines.push(``, ` (none)`);
|
|
518
|
+
} else {
|
|
519
|
+
lines.push(``);
|
|
520
|
+
for (const t of running) {
|
|
521
|
+
lines.push(` ▸ #${t.id} ${t.task}`);
|
|
522
|
+
lines.push(` ${t.elapsed}`);
|
|
523
|
+
}
|
|
380
524
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
).join('\n');
|
|
384
|
-
return ctx.reply(`Running tasks:\n\n${text}`);
|
|
525
|
+
lines.push(TERM_SEP);
|
|
526
|
+
return ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
|
|
385
527
|
});
|
|
386
528
|
|
|
387
529
|
bot.command('help', async (ctx) => {
|
|
@@ -412,14 +554,19 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
412
554
|
if (!ctx.from) return;
|
|
413
555
|
const tenant = await getTenant(ctx.from.id, config);
|
|
414
556
|
const stopped = tenant.claude.stopChat(ctx.chat.id);
|
|
415
|
-
|
|
557
|
+
if (stopped) {
|
|
558
|
+
await ctx.reply(`<pre>◈ PROCESS TERMINATED\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
|
|
559
|
+
} else {
|
|
560
|
+
await ctx.reply('Nothing running to stop.');
|
|
561
|
+
}
|
|
416
562
|
});
|
|
417
563
|
|
|
418
564
|
bot.command('verbose', async (ctx) => {
|
|
419
565
|
if (!ctx.from) return;
|
|
420
566
|
const tenant = await getTenant(ctx.from.id, config);
|
|
421
567
|
tenant.verbose = !tenant.verbose;
|
|
422
|
-
|
|
568
|
+
const state = tenant.verbose ? '◉ ACTIVE' : '○ INACTIVE';
|
|
569
|
+
await ctx.reply(`<pre>◈ VERBOSE ${state}\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
|
|
423
570
|
});
|
|
424
571
|
|
|
425
572
|
bot.command('upgrade', async (ctx) => {
|
|
@@ -509,37 +656,9 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
509
656
|
return API_KEY_PATTERNS.some(pattern => pattern.test(text));
|
|
510
657
|
}
|
|
511
658
|
|
|
512
|
-
|
|
513
|
-
if (!ctx.from) return;
|
|
514
|
-
const userMessage = ctx.message.text;
|
|
515
|
-
if (!userMessage || !userMessage.trim()) return;
|
|
659
|
+
async function processTextMessage(ctx, fullMessage) {
|
|
516
660
|
const userId = ctx.from.id;
|
|
517
661
|
const userName = ctx.from.first_name || 'User';
|
|
518
|
-
|
|
519
|
-
if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') {
|
|
520
|
-
const me = await bot.api.getMe();
|
|
521
|
-
if (!userMessage.includes(`@${me.username}`)) return;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (!userMessage.startsWith('/secret') && containsApiKey(userMessage)) {
|
|
525
|
-
ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id).catch(() => {});
|
|
526
|
-
await ctx.reply(
|
|
527
|
-
'⚠️ That message contained what looks like an API key or token. I deleted it, but it may have been seen already — consider rotating it.\n\nUse `/secret set <name> <value>` to store credentials safely.'
|
|
528
|
-
).catch(() => {});
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
const rateResult = checkRateLimit(userId);
|
|
533
|
-
if (rateResult === 'cooldown' || rateResult === 'skip') return;
|
|
534
|
-
if (rateResult === 'spam') {
|
|
535
|
-
await ctx.reply('Spam detected. Cooling down for 30 seconds.').catch(() => {});
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
538
|
-
if (rateResult === 'slow') {
|
|
539
|
-
await ctx.reply('Slow down a bit — I\'m still processing.').catch(() => {});
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
662
|
const tenant = await getTenant(userId, config);
|
|
544
663
|
|
|
545
664
|
if (_evolutionTimers.has(userId)) {
|
|
@@ -548,10 +667,47 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
548
667
|
if (tenant.messageLog) tenant.messageLog._evolutionPending = false;
|
|
549
668
|
}
|
|
550
669
|
|
|
670
|
+
let replyContext = '';
|
|
671
|
+
const reply = ctx.message?.reply_to_message;
|
|
672
|
+
if (reply) {
|
|
673
|
+
const quote = (reply.text || reply.caption || '').substring(0, 500);
|
|
674
|
+
const sender = reply.from
|
|
675
|
+
? (reply.from.first_name || '') + (reply.from.last_name ? ` ${reply.from.last_name}` : '')
|
|
676
|
+
: reply.forward_origin?.sender_user?.first_name || 'someone';
|
|
677
|
+
if (quote) replyContext = `[Replying to "${quote}" from ${sender}]\n\n`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const chatMessage = replyContext + fullMessage;
|
|
551
681
|
const stopTyping = startTyping(ctx);
|
|
552
682
|
|
|
683
|
+
let statusMsgId = null;
|
|
684
|
+
let statusText = 'Processing';
|
|
685
|
+
let statusTimer = null;
|
|
686
|
+
let statusStart = null;
|
|
687
|
+
let routeInfo = null;
|
|
688
|
+
|
|
689
|
+
const clearStatus = () => {
|
|
690
|
+
if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }
|
|
691
|
+
if (statusMsgId) { ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {}); statusMsgId = null; }
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const startStatusTimer = () => {
|
|
695
|
+
if (statusTimer) return;
|
|
696
|
+
statusStart = Date.now();
|
|
697
|
+
const html = buildStatusHtml({ route: routeInfo, elapsed: 0, toolStatus: statusText });
|
|
698
|
+
ctx.reply(html, { parse_mode: 'HTML' }).then(sent => {
|
|
699
|
+
if (sent) statusMsgId = sent.message_id;
|
|
700
|
+
}).catch(() => {});
|
|
701
|
+
statusTimer = setInterval(() => {
|
|
702
|
+
if (!statusMsgId) return;
|
|
703
|
+
const elapsed = Math.round((Date.now() - statusStart) / 1000);
|
|
704
|
+
const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: statusText });
|
|
705
|
+
ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
|
|
706
|
+
}, 1000);
|
|
707
|
+
};
|
|
708
|
+
|
|
553
709
|
try {
|
|
554
|
-
tenant.messageLog?.log(ctx.chat.id, 'user',
|
|
710
|
+
tenant.messageLog?.log(ctx.chat.id, 'user', chatMessage);
|
|
555
711
|
|
|
556
712
|
const chatContext = {
|
|
557
713
|
userId,
|
|
@@ -564,25 +720,48 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
564
720
|
config,
|
|
565
721
|
verbose: tenant.verbose,
|
|
566
722
|
_verboseNotify: tenant.verbose ? (msg) => {
|
|
567
|
-
|
|
568
|
-
ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
|
|
723
|
+
sendHtml(ctx, `\`${msg}\``).catch(() => {});
|
|
569
724
|
} : undefined,
|
|
570
725
|
telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
|
|
571
726
|
_notifyFn: (targetUserId, message) => {
|
|
572
727
|
if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
|
|
573
728
|
return bot.api.sendMessage(targetUserId, message);
|
|
574
729
|
},
|
|
730
|
+
_onRouteDecision: (info) => {
|
|
731
|
+
routeInfo = info;
|
|
732
|
+
startStatusTimer();
|
|
733
|
+
},
|
|
734
|
+
_onRouteUpdate: (update) => {
|
|
735
|
+
if (routeInfo) routeInfo.memoryCount = update.memoryCount;
|
|
736
|
+
},
|
|
737
|
+
_onToolStart: (toolName, inputSummary) => {
|
|
738
|
+
statusText = 'Processing';
|
|
739
|
+
describeToolCall(tenant.claude.client, toolName, inputSummary).then(desc => {
|
|
740
|
+
if (desc) statusText = desc;
|
|
741
|
+
});
|
|
742
|
+
startStatusTimer();
|
|
743
|
+
},
|
|
575
744
|
};
|
|
576
|
-
const { text: response, usage, model } = await tenant.claude.chat(
|
|
745
|
+
const { text: response, usage, model } = await tenant.claude.chat(chatMessage, chatContext);
|
|
746
|
+
|
|
747
|
+
if (statusTimer) {
|
|
748
|
+
clearInterval(statusTimer);
|
|
749
|
+
statusTimer = null;
|
|
750
|
+
if (statusMsgId) {
|
|
751
|
+
const elapsed = statusStart ? Math.round((Date.now() - statusStart) / 1000) : 0;
|
|
752
|
+
const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: 'Formatting output' });
|
|
753
|
+
ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
577
756
|
|
|
578
757
|
if (!response?.trim()) {
|
|
579
758
|
stopTyping();
|
|
759
|
+
clearStatus();
|
|
580
760
|
return;
|
|
581
761
|
}
|
|
582
762
|
|
|
583
763
|
tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
|
|
584
764
|
|
|
585
|
-
|
|
586
765
|
if (tenant.messageLog?._evolutionReady && !_evolutionTimers.has(userId)) {
|
|
587
766
|
tenant.messageLog._evolutionReady = false;
|
|
588
767
|
tenant.messageLog._evolutionPending = true;
|
|
@@ -622,9 +801,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
622
801
|
msg += `\n\n_${result.changelog}_`;
|
|
623
802
|
}
|
|
624
803
|
|
|
625
|
-
await ctx
|
|
626
|
-
ctx.reply(msg).catch(() => {})
|
|
627
|
-
);
|
|
804
|
+
await sendHtml(ctx, msg).catch(() => {});
|
|
628
805
|
} catch (e) {
|
|
629
806
|
console.error('Evolution failed:', e.message);
|
|
630
807
|
} finally {
|
|
@@ -639,164 +816,325 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
639
816
|
if (response.length > 4096) {
|
|
640
817
|
const chunks = splitMessage(response, 4096);
|
|
641
818
|
for (const chunk of chunks) {
|
|
642
|
-
await ctx
|
|
643
|
-
ctx.reply(chunk)
|
|
644
|
-
);
|
|
819
|
+
await sendHtml(ctx, chunk).catch(() => {});
|
|
645
820
|
}
|
|
646
821
|
} else {
|
|
647
|
-
await ctx
|
|
648
|
-
|
|
649
|
-
|
|
822
|
+
await sendHtml(ctx, response).catch(() => {});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (usage && model) {
|
|
826
|
+
const tag = model.includes('opus') ? 'opus' : model.includes('haiku') ? 'haiku' : 'sonnet';
|
|
827
|
+
const tokIn = usage.input_tokens >= 1000 ? `${(usage.input_tokens/1000).toFixed(1)}k` : usage.input_tokens;
|
|
828
|
+
const tokOut = usage.output_tokens >= 1000 ? `${(usage.output_tokens/1000).toFixed(1)}k` : usage.output_tokens;
|
|
829
|
+
const dur = statusStart ? ((Date.now() - statusStart)/1000).toFixed(1) : null;
|
|
830
|
+
const parts = [`◈ ${tag}`, `${tokIn} in`, `${tokOut} out`];
|
|
831
|
+
if (dur) parts.push(`${dur}s`);
|
|
832
|
+
await ctx.reply(`<code>${parts.join(' ▪ ')}</code>`, { parse_mode: 'HTML' }).catch(() => {});
|
|
650
833
|
}
|
|
834
|
+
|
|
835
|
+
if (statusMsgId) ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {});
|
|
651
836
|
} catch (e) {
|
|
837
|
+
clearStatus();
|
|
652
838
|
stopTyping();
|
|
653
839
|
console.error('Message handling error:', e.message);
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
840
|
+
const errMsg = e.isOAuthExpiry
|
|
841
|
+
? `OAuth error: ${e.message}\n\nRun \`obol config\` → Anthropic → re-authenticate OAuth.`
|
|
842
|
+
: (e.status === 401 || e.message?.includes('401'))
|
|
843
|
+
? 'API key invalid or expired. Run `obol config` to update.'
|
|
844
|
+
: (e.status === 429 || e.message?.includes('rate'))
|
|
845
|
+
? 'Rate limited. Wait a moment and try again.'
|
|
846
|
+
: 'Something went wrong. Check logs with `obol logs`.';
|
|
847
|
+
if (e.isOAuthExpiry) console.error('[oauth] Full error:', e.stack || e.message);
|
|
848
|
+
await ctx.reply(errMsg).catch(() => {});
|
|
664
849
|
}
|
|
665
|
-
}
|
|
850
|
+
}
|
|
666
851
|
|
|
667
|
-
|
|
852
|
+
function flushTextBuffer(chatId, ctx) {
|
|
853
|
+
const buf = textBuffers.get(chatId);
|
|
854
|
+
if (!buf) return;
|
|
855
|
+
clearTimeout(buf.timer);
|
|
856
|
+
textBuffers.delete(chatId);
|
|
857
|
+
const combined = buf.parts.join('');
|
|
858
|
+
processTextMessage(ctx, combined).catch(e => console.error('Buffer flush error:', e.message));
|
|
859
|
+
}
|
|
668
860
|
|
|
669
|
-
async
|
|
861
|
+
bot.on('message:text', async (ctx) => {
|
|
670
862
|
if (!ctx.from) return;
|
|
863
|
+
const userMessage = ctx.message.text;
|
|
864
|
+
if (!userMessage || !userMessage.trim()) return;
|
|
671
865
|
const userId = ctx.from.id;
|
|
866
|
+
|
|
867
|
+
if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') {
|
|
868
|
+
const me = await bot.api.getMe();
|
|
869
|
+
if (!userMessage.includes(`@${me.username}`)) return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (!userMessage.startsWith('/secret') && containsApiKey(userMessage)) {
|
|
873
|
+
ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id).catch(() => {});
|
|
874
|
+
await ctx.reply(
|
|
875
|
+
'⚠️ That message contained what looks like an API key or token. I deleted it, but it may have been seen already — consider rotating it.\n\nUse `/secret set <name> <value>` to store credentials safely.'
|
|
876
|
+
).catch(() => {});
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
672
880
|
const rateResult = checkRateLimit(userId);
|
|
673
|
-
if (rateResult) return;
|
|
674
|
-
|
|
675
|
-
|
|
881
|
+
if (rateResult === 'cooldown' || rateResult === 'skip') return;
|
|
882
|
+
if (rateResult === 'spam') {
|
|
883
|
+
await ctx.reply('Spam detected. Cooling down for 30 seconds.').catch(() => {});
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (rateResult === 'slow') {
|
|
887
|
+
await ctx.reply('Slow down a bit — I\'m still processing.').catch(() => {});
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
676
890
|
|
|
677
|
-
|
|
678
|
-
|
|
891
|
+
const chatId = ctx.chat.id;
|
|
892
|
+
const existingBuf = textBuffers.get(chatId);
|
|
893
|
+
|
|
894
|
+
if (userMessage.length >= TEXT_BUFFER_THRESHOLD) {
|
|
895
|
+
if (existingBuf) {
|
|
896
|
+
clearTimeout(existingBuf.timer);
|
|
897
|
+
if (existingBuf.parts.length < TEXT_BUFFER_MAX_PARTS &&
|
|
898
|
+
existingBuf.totalLength + userMessage.length <= TEXT_BUFFER_MAX_CHARS) {
|
|
899
|
+
existingBuf.parts.push(userMessage);
|
|
900
|
+
existingBuf.totalLength += userMessage.length;
|
|
901
|
+
existingBuf.ctx = ctx;
|
|
902
|
+
existingBuf.timer = setTimeout(() => flushTextBuffer(chatId, ctx), TEXT_BUFFER_GAP_MS);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
flushTextBuffer(chatId, ctx);
|
|
906
|
+
}
|
|
907
|
+
const buf = {
|
|
908
|
+
parts: [userMessage],
|
|
909
|
+
totalLength: userMessage.length,
|
|
910
|
+
ctx,
|
|
911
|
+
timer: setTimeout(() => flushTextBuffer(chatId, ctx), TEXT_BUFFER_GAP_MS),
|
|
912
|
+
};
|
|
913
|
+
textBuffers.set(chatId, buf);
|
|
679
914
|
return;
|
|
680
915
|
}
|
|
681
916
|
|
|
682
|
-
|
|
917
|
+
if (existingBuf) {
|
|
918
|
+
flushTextBuffer(chatId, existingBuf.ctx);
|
|
919
|
+
}
|
|
683
920
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const file = await ctx.getFile();
|
|
687
|
-
const buffer = await media.downloadFile(telegramConfig.token, file.file_path);
|
|
921
|
+
await processTextMessage(ctx, userMessage);
|
|
922
|
+
});
|
|
688
923
|
|
|
689
|
-
|
|
690
|
-
const assetsDir = path.join(tenant.userDir, 'assets');
|
|
691
|
-
const savedPath = media.saveFile(buffer, assetsDir, filename);
|
|
924
|
+
const MAX_MEDIA_SIZE = 50 * 1024 * 1024;
|
|
692
925
|
|
|
693
|
-
|
|
926
|
+
async function downloadMediaItem(ctx, fileInfo) {
|
|
927
|
+
const file = await ctx.getFile();
|
|
928
|
+
const buffer = await media.downloadFile(telegramConfig.token, file.file_path);
|
|
929
|
+
const filename = media.generateFilename(fileInfo, file.file_path);
|
|
930
|
+
return { buffer, filename, fileInfo, caption: ctx.message.caption || '' };
|
|
931
|
+
}
|
|
694
932
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
933
|
+
async function processMediaItems(ctx, items) {
|
|
934
|
+
if (!ctx.from) return;
|
|
935
|
+
const userId = ctx.from.id;
|
|
936
|
+
const stopTyping = startTyping(ctx);
|
|
937
|
+
let statusMsgId = null;
|
|
938
|
+
let statusText = 'Processing';
|
|
939
|
+
let statusTimer = null;
|
|
940
|
+
let statusStart = null;
|
|
941
|
+
let routeInfo = null;
|
|
942
|
+
|
|
943
|
+
const clearStatus = () => {
|
|
944
|
+
if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }
|
|
945
|
+
if (statusMsgId) { ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {}); statusMsgId = null; }
|
|
946
|
+
};
|
|
704
947
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const safe = msg.replace(/`/g, "'");
|
|
720
|
-
ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
|
|
721
|
-
} : undefined,
|
|
722
|
-
images: [imageBlock],
|
|
723
|
-
_notifyFn: (targetUserId, message) => {
|
|
724
|
-
if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
|
|
725
|
-
return bot.api.sendMessage(targetUserId, message);
|
|
726
|
-
},
|
|
727
|
-
};
|
|
728
|
-
const { text: response, usage, model } = await tenant.claude.chat(prompt, mediaChatCtx);
|
|
948
|
+
const startStatusTimer = () => {
|
|
949
|
+
if (statusTimer) return;
|
|
950
|
+
statusStart = Date.now();
|
|
951
|
+
const html = buildStatusHtml({ route: routeInfo, elapsed: 0, toolStatus: statusText });
|
|
952
|
+
ctx.reply(html, { parse_mode: 'HTML' }).then(sent => {
|
|
953
|
+
if (sent) statusMsgId = sent.message_id;
|
|
954
|
+
}).catch(() => {});
|
|
955
|
+
statusTimer = setInterval(() => {
|
|
956
|
+
if (!statusMsgId) return;
|
|
957
|
+
const elapsed = Math.round((Date.now() - statusStart) / 1000);
|
|
958
|
+
const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: statusText });
|
|
959
|
+
ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
|
|
960
|
+
}, 1000);
|
|
961
|
+
};
|
|
729
962
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
963
|
+
try {
|
|
964
|
+
const tenant = await getTenant(userId, config);
|
|
965
|
+
const assetsDir = path.join(tenant.userDir, 'assets');
|
|
966
|
+
const imageBlocks = [];
|
|
967
|
+
const nonImageParts = [];
|
|
968
|
+
const caption = items.map(i => i.caption).filter(Boolean).join('\n') || '';
|
|
969
|
+
|
|
970
|
+
for (const item of items) {
|
|
971
|
+
const savedPath = media.saveFile(item.buffer, assetsDir, item.filename);
|
|
972
|
+
|
|
973
|
+
if (tenant.memory && !media.isImage(item.fileInfo)) {
|
|
974
|
+
const memContent = media.buildMemoryContent(item.fileInfo, item.filename, savedPath, item.caption);
|
|
975
|
+
await tenant.memory.add(memContent, {
|
|
976
|
+
category: 'resource', importance: 0.6,
|
|
977
|
+
source: 'telegram-media', tags: [item.fileInfo.mediaType],
|
|
743
978
|
}).catch(() => {});
|
|
744
979
|
}
|
|
745
980
|
|
|
746
|
-
if (
|
|
747
|
-
|
|
748
|
-
await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() => ctx.reply(chunk));
|
|
749
|
-
}
|
|
981
|
+
if (media.isImage(item.fileInfo)) {
|
|
982
|
+
imageBlocks.push(media.bufferToImageBlock(item.buffer, item.fileInfo.mimeType));
|
|
750
983
|
} else {
|
|
751
|
-
|
|
984
|
+
nonImageParts.push(item.caption
|
|
985
|
+
? `[User sent a ${item.fileInfo.mediaType}: ${item.filename}, saved at ${savedPath}] ${item.caption}`
|
|
986
|
+
: `[User sent a ${item.fileInfo.mediaType}: ${item.filename}, saved at ${savedPath}. Use read_file to read its contents if needed.]`);
|
|
752
987
|
}
|
|
753
|
-
}
|
|
754
|
-
const contextMsg = `[User sent a ${fileInfo.mediaType}: ${filename}] ${caption}`;
|
|
755
|
-
const mediaCaptionCtx = {
|
|
756
|
-
userId,
|
|
757
|
-
userName: ctx.from.first_name || 'User',
|
|
758
|
-
chatId: ctx.chat.id,
|
|
759
|
-
bg: tenant.bg,
|
|
760
|
-
ctx,
|
|
761
|
-
claude: tenant.claude,
|
|
762
|
-
scheduler: tenant.scheduler,
|
|
763
|
-
config,
|
|
764
|
-
verbose: tenant.verbose,
|
|
765
|
-
_verboseNotify: tenant.verbose ? (msg) => {
|
|
766
|
-
const safe = msg.replace(/`/g, "'");
|
|
767
|
-
ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
|
|
768
|
-
} : undefined,
|
|
769
|
-
_notifyFn: (targetUserId, message) => {
|
|
770
|
-
if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
|
|
771
|
-
return bot.api.sendMessage(targetUserId, message);
|
|
772
|
-
},
|
|
773
|
-
};
|
|
774
|
-
const { text: response, usage, model } = await tenant.claude.chat(contextMsg, mediaCaptionCtx);
|
|
775
|
-
|
|
776
|
-
stopTyping();
|
|
777
|
-
if (!response?.trim()) return;
|
|
988
|
+
}
|
|
778
989
|
|
|
779
|
-
|
|
780
|
-
|
|
990
|
+
let prompt, chatImages;
|
|
991
|
+
if (imageBlocks.length > 0) {
|
|
992
|
+
prompt = caption || `The user sent ${imageBlocks.length} image(s). Describe what you see and respond naturally.`;
|
|
993
|
+
if (nonImageParts.length > 0) prompt += '\n\n' + nonImageParts.join('\n');
|
|
994
|
+
chatImages = imageBlocks;
|
|
995
|
+
} else {
|
|
996
|
+
prompt = nonImageParts.join('\n');
|
|
997
|
+
}
|
|
781
998
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
999
|
+
const mediaChatCtx = {
|
|
1000
|
+
userId,
|
|
1001
|
+
userName: ctx.from.first_name || 'User',
|
|
1002
|
+
chatId: ctx.chat.id,
|
|
1003
|
+
bg: tenant.bg, ctx, claude: tenant.claude,
|
|
1004
|
+
scheduler: tenant.scheduler, config,
|
|
1005
|
+
verbose: tenant.verbose,
|
|
1006
|
+
_verboseNotify: tenant.verbose ? (msg) => {
|
|
1007
|
+
sendHtml(ctx, `\`${msg}\``).catch(() => {});
|
|
1008
|
+
} : undefined,
|
|
1009
|
+
...(chatImages ? { images: chatImages } : {}),
|
|
1010
|
+
_notifyFn: (targetUserId, message) => {
|
|
1011
|
+
if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
|
|
1012
|
+
return bot.api.sendMessage(targetUserId, message);
|
|
1013
|
+
},
|
|
1014
|
+
_onRouteDecision: (info) => {
|
|
1015
|
+
routeInfo = info;
|
|
1016
|
+
startStatusTimer();
|
|
1017
|
+
},
|
|
1018
|
+
_onRouteUpdate: (update) => {
|
|
1019
|
+
if (routeInfo) routeInfo.memoryCount = update.memoryCount;
|
|
1020
|
+
},
|
|
1021
|
+
_onToolStart: (toolName, inputSummary) => {
|
|
1022
|
+
statusText = 'Processing';
|
|
1023
|
+
describeToolCall(tenant.claude.client, toolName, inputSummary).then(desc => {
|
|
1024
|
+
if (desc) statusText = desc;
|
|
1025
|
+
});
|
|
1026
|
+
startStatusTimer();
|
|
1027
|
+
},
|
|
1028
|
+
};
|
|
1029
|
+
const { text: response, usage, model } = await tenant.claude.chat(prompt, mediaChatCtx);
|
|
1030
|
+
|
|
1031
|
+
if (statusTimer) {
|
|
1032
|
+
clearInterval(statusTimer);
|
|
1033
|
+
statusTimer = null;
|
|
1034
|
+
if (statusMsgId) {
|
|
1035
|
+
const elapsed = statusStart ? Math.round((Date.now() - statusStart) / 1000) : 0;
|
|
1036
|
+
const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: 'Formatting output' });
|
|
1037
|
+
ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
|
|
788
1038
|
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
stopTyping();
|
|
1042
|
+
if (!response?.trim()) {
|
|
1043
|
+
clearStatus();
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const logLabel = items.map(i => `[${i.fileInfo.mediaType}] ${i.caption || i.filename}`).join(', ');
|
|
1048
|
+
tenant.messageLog?.log(ctx.chat.id, 'user', logLabel);
|
|
1049
|
+
tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
|
|
1050
|
+
|
|
1051
|
+
if (tenant.memory && imageBlocks.length > 0) {
|
|
1052
|
+
const filenames = items.filter(i => media.isImage(i.fileInfo)).map(i => i.filename).join(', ');
|
|
1053
|
+
const analysisMemory = `Images: ${filenames}${caption ? `. Caption: "${caption}"` : ''}. Analysis: ${response.substring(0, 1500)}`;
|
|
1054
|
+
await tenant.memory.add(analysisMemory, {
|
|
1055
|
+
category: 'resource', importance: 0.7,
|
|
1056
|
+
source: 'image-analysis',
|
|
1057
|
+
tags: ['image', ...(caption ? caption.toLowerCase().split(/\s+/).slice(0, 3) : [])],
|
|
1058
|
+
}).catch(() => {});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (response.length > 4096) {
|
|
1062
|
+
const chunks = splitMessage(response, 4096);
|
|
1063
|
+
for (const chunk of chunks) await sendHtml(ctx, chunk).catch(() => {});
|
|
789
1064
|
} else {
|
|
790
|
-
|
|
791
|
-
|
|
1065
|
+
await sendHtml(ctx, response).catch(() => {});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (usage && model) {
|
|
1069
|
+
const tag = model.includes('opus') ? 'opus' : model.includes('haiku') ? 'haiku' : 'sonnet';
|
|
1070
|
+
const tokIn = usage.input_tokens >= 1000 ? `${(usage.input_tokens/1000).toFixed(1)}k` : usage.input_tokens;
|
|
1071
|
+
const tokOut = usage.output_tokens >= 1000 ? `${(usage.output_tokens/1000).toFixed(1)}k` : usage.output_tokens;
|
|
1072
|
+
const dur = statusStart ? ((Date.now() - statusStart)/1000).toFixed(1) : null;
|
|
1073
|
+
const parts = [`◈ ${tag}`, `${tokIn} in`, `${tokOut} out`];
|
|
1074
|
+
if (dur) parts.push(`${dur}s`);
|
|
1075
|
+
await ctx.reply(`<code>${parts.join(' ▪ ')}</code>`, { parse_mode: 'HTML' }).catch(() => {});
|
|
792
1076
|
}
|
|
1077
|
+
|
|
1078
|
+
if (statusMsgId) ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {});
|
|
793
1079
|
} catch (e) {
|
|
1080
|
+
clearStatus();
|
|
794
1081
|
stopTyping();
|
|
795
1082
|
console.error('Media handling error:', e.message);
|
|
796
1083
|
await ctx.reply('Failed to process that file. Check logs.').catch(() => {});
|
|
797
1084
|
}
|
|
798
1085
|
}
|
|
799
1086
|
|
|
1087
|
+
async function handleMedia(ctx) {
|
|
1088
|
+
if (!ctx.from) return;
|
|
1089
|
+
const userId = ctx.from.id;
|
|
1090
|
+
const rateResult = checkRateLimit(userId);
|
|
1091
|
+
if (rateResult) return;
|
|
1092
|
+
const fileInfo = media.getFileInfo(ctx);
|
|
1093
|
+
if (!fileInfo) return;
|
|
1094
|
+
|
|
1095
|
+
if (fileInfo.fileSize > MAX_MEDIA_SIZE) {
|
|
1096
|
+
await ctx.reply(`File too large (${(fileInfo.fileSize / 1024 / 1024).toFixed(1)}MB). Max is 50MB.`).catch(() => {});
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const item = await downloadMediaItem(ctx, fileInfo).catch(e => {
|
|
1101
|
+
console.error('Media download error:', e.message);
|
|
1102
|
+
return null;
|
|
1103
|
+
});
|
|
1104
|
+
if (!item) return;
|
|
1105
|
+
|
|
1106
|
+
const groupId = ctx.message.media_group_id;
|
|
1107
|
+
if (groupId) {
|
|
1108
|
+
const existing = mediaGroups.get(groupId);
|
|
1109
|
+
if (existing) {
|
|
1110
|
+
clearTimeout(existing.timer);
|
|
1111
|
+
existing.items.push(item);
|
|
1112
|
+
existing.ctx = ctx;
|
|
1113
|
+
existing.timer = setTimeout(() => {
|
|
1114
|
+
mediaGroups.delete(groupId);
|
|
1115
|
+
processMediaItems(existing.ctx, existing.items).catch(e =>
|
|
1116
|
+
console.error('Media group error:', e.message)
|
|
1117
|
+
);
|
|
1118
|
+
}, MEDIA_GROUP_DELAY_MS);
|
|
1119
|
+
} else {
|
|
1120
|
+
const group = {
|
|
1121
|
+
items: [item],
|
|
1122
|
+
ctx,
|
|
1123
|
+
timer: setTimeout(() => {
|
|
1124
|
+
mediaGroups.delete(groupId);
|
|
1125
|
+
processMediaItems(ctx, [item]).catch(e =>
|
|
1126
|
+
console.error('Media group error:', e.message)
|
|
1127
|
+
);
|
|
1128
|
+
}, MEDIA_GROUP_DELAY_MS),
|
|
1129
|
+
};
|
|
1130
|
+
mediaGroups.set(groupId, group);
|
|
1131
|
+
}
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
await processMediaItems(ctx, [item]);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
800
1138
|
bot.on('message:photo', handleMedia);
|
|
801
1139
|
bot.on('message:document', handleMedia);
|
|
802
1140
|
bot.on('message:voice', handleMedia);
|
|
@@ -808,17 +1146,19 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
808
1146
|
|
|
809
1147
|
bot.on('callback_query:data', async (ctx) => {
|
|
810
1148
|
const data = ctx.callbackQuery.data;
|
|
811
|
-
|
|
1149
|
+
const answer = (opts) => ctx.answerCallbackQuery(opts).catch(() => {});
|
|
1150
|
+
if (!data.startsWith('ask:')) return answer();
|
|
812
1151
|
const parts = data.split(':');
|
|
813
1152
|
const askId = parseInt(parts[1]);
|
|
814
1153
|
const optIdx = parseInt(parts[2]);
|
|
815
1154
|
const pending = pendingAsks.get(askId);
|
|
816
|
-
if (!pending) return
|
|
1155
|
+
if (!pending) return answer({ text: 'Expired' });
|
|
817
1156
|
const selected = pending.options[optIdx];
|
|
818
|
-
await
|
|
1157
|
+
await answer({ text: selected });
|
|
819
1158
|
clearTimeout(pending.timer);
|
|
820
1159
|
pendingAsks.delete(askId);
|
|
821
|
-
|
|
1160
|
+
const confirmHtml = markdownToTelegramHtml(`${ctx.callbackQuery.message.text}\n\n✓ _${selected}_`);
|
|
1161
|
+
ctx.editMessageText(confirmHtml, { parse_mode: 'HTML' }).catch(() => {});
|
|
822
1162
|
pending.resolve(selected);
|
|
823
1163
|
});
|
|
824
1164
|
|
|
@@ -877,9 +1217,8 @@ Your message is deleted immediately when using /secret set to keep credentials o
|
|
|
877
1217
|
function formatTraits(traits) {
|
|
878
1218
|
const maxLen = Math.max(...Object.keys(traits).map(k => k.length));
|
|
879
1219
|
return Object.entries(traits).map(([name, val]) => {
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
return `${name.charAt(0).toUpperCase() + name.slice(1).padEnd(maxLen)} ${bar} ${val}`;
|
|
1220
|
+
const label = (name.charAt(0).toUpperCase() + name.slice(1)).padEnd(maxLen + 1);
|
|
1221
|
+
return ` ${label}${termBar(val)} ${val}`;
|
|
883
1222
|
}).join('\n');
|
|
884
1223
|
}
|
|
885
1224
|
|