@soleri/forge 5.11.0 → 5.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/facades/forge.facade.js +3 -3
- package/dist/facades/forge.facade.js.map +1 -1
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +1 -1
- package/dist/lib.js.map +1 -1
- package/dist/scaffolder.js +137 -20
- package/dist/scaffolder.js.map +1 -1
- package/dist/templates/agents-md.d.ts +5 -0
- package/dist/templates/agents-md.js +33 -0
- package/dist/templates/agents-md.js.map +1 -0
- package/dist/templates/entry-point.js +15 -2
- package/dist/templates/entry-point.js.map +1 -1
- package/dist/templates/package-json.js +7 -0
- package/dist/templates/package-json.js.map +1 -1
- package/dist/templates/readme.js +80 -27
- package/dist/templates/readme.js.map +1 -1
- package/dist/templates/setup-script.d.ts +1 -1
- package/dist/templates/setup-script.js +135 -53
- package/dist/templates/setup-script.js.map +1 -1
- package/dist/templates/skills.d.ts +0 -7
- package/dist/templates/skills.js +0 -21
- package/dist/templates/skills.js.map +1 -1
- package/dist/templates/telegram-agent.d.ts +6 -0
- package/dist/templates/telegram-agent.js +212 -0
- package/dist/templates/telegram-agent.js.map +1 -0
- package/dist/templates/telegram-bot.d.ts +6 -0
- package/dist/templates/telegram-bot.js +450 -0
- package/dist/templates/telegram-bot.js.map +1 -0
- package/dist/templates/telegram-config.d.ts +6 -0
- package/dist/templates/telegram-config.js +81 -0
- package/dist/templates/telegram-config.js.map +1 -0
- package/dist/templates/telegram-supervisor.d.ts +6 -0
- package/dist/templates/telegram-supervisor.js +148 -0
- package/dist/templates/telegram-supervisor.js.map +1 -0
- package/dist/types.d.ts +13 -5
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/scaffolder.test.ts +62 -0
- package/src/facades/forge.facade.ts +3 -3
- package/src/lib.ts +2 -1
- package/src/scaffolder.ts +170 -28
- package/src/templates/agents-md.ts +35 -0
- package/src/templates/entry-point.ts +15 -2
- package/src/templates/package-json.ts +7 -0
- package/src/templates/readme.ts +89 -27
- package/src/templates/setup-script.ts +141 -54
- package/src/templates/skills.ts +0 -23
- package/src/templates/telegram-agent.ts +214 -0
- package/src/templates/telegram-bot.ts +452 -0
- package/src/templates/telegram-config.ts +83 -0
- package/src/templates/telegram-supervisor.ts +150 -0
- package/src/types.ts +9 -2
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template: Telegram bot entry point with Grammy.
|
|
3
|
+
* Generated by @soleri/forge for agents with Telegram transport.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AgentConfig } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export function generateTelegramBot(config: AgentConfig): string {
|
|
9
|
+
return `/**
|
|
10
|
+
* ${config.name} — Telegram Bot Entry Point.
|
|
11
|
+
* Generated by @soleri/forge.
|
|
12
|
+
*
|
|
13
|
+
* Grammy bot with middleware pipeline, session management, and agent dispatch.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Bot } from 'grammy';
|
|
17
|
+
import { loadTelegramConfig, type TelegramConfig } from './telegram-config.js';
|
|
18
|
+
import { createTelegramAgent } from './telegram-agent.js';
|
|
19
|
+
import {
|
|
20
|
+
ChatSessionManager,
|
|
21
|
+
ChatAuthManager,
|
|
22
|
+
TaskCancellationManager,
|
|
23
|
+
SelfUpdateManager,
|
|
24
|
+
RESTART_EXIT_CODE,
|
|
25
|
+
NotificationEngine,
|
|
26
|
+
FragmentBuffer,
|
|
27
|
+
chunkResponse,
|
|
28
|
+
detectFileIntent,
|
|
29
|
+
buildMultimodalContent,
|
|
30
|
+
saveTempFile,
|
|
31
|
+
cleanupTempFiles,
|
|
32
|
+
MAX_FILE_SIZE,
|
|
33
|
+
} from '@soleri/core';
|
|
34
|
+
import type { Fragment, FileInfo } from '@soleri/core';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { mkdirSync, appendFileSync } from 'node:fs';
|
|
38
|
+
|
|
39
|
+
// ─── Config ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const CONFIG_DIR = join(homedir(), '.${config.id}');
|
|
42
|
+
const SESSIONS_DIR = join(CONFIG_DIR, 'telegram-sessions');
|
|
43
|
+
const AUTH_FILE = join(CONFIG_DIR, 'telegram-auth.json');
|
|
44
|
+
const RESTART_FILE = join(CONFIG_DIR, 'telegram-restart.json');
|
|
45
|
+
const UPLOAD_DIR = join(CONFIG_DIR, 'telegram-uploads');
|
|
46
|
+
const LOG_DIR = join(CONFIG_DIR, 'logs');
|
|
47
|
+
|
|
48
|
+
// ─── State ───────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const processingLocks = new Set<string>();
|
|
51
|
+
let telegramConfig: TelegramConfig;
|
|
52
|
+
let sessions: ChatSessionManager;
|
|
53
|
+
let auth: ChatAuthManager;
|
|
54
|
+
let fragmentBuffer: FragmentBuffer;
|
|
55
|
+
const cancellation = new TaskCancellationManager();
|
|
56
|
+
const updater = new SelfUpdateManager(RESTART_FILE);
|
|
57
|
+
let notifications: NotificationEngine;
|
|
58
|
+
|
|
59
|
+
// ─── Event Logger ────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function logEvent(type: string, chatId: number | string, data?: Record<string, unknown>): void {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
64
|
+
const date = new Date().toISOString().split('T')[0];
|
|
65
|
+
const entry = JSON.stringify({ ts: new Date().toISOString(), chatId, type, ...data });
|
|
66
|
+
appendFileSync(join(LOG_DIR, \`events-\${date}.jsonl\`), entry + '\\n', 'utf-8');
|
|
67
|
+
} catch {
|
|
68
|
+
// Logging failure is non-critical
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Bot Setup ───────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export async function startBot(): Promise<void> {
|
|
75
|
+
telegramConfig = loadTelegramConfig();
|
|
76
|
+
|
|
77
|
+
// Initialize infrastructure
|
|
78
|
+
sessions = new ChatSessionManager({
|
|
79
|
+
storageDir: SESSIONS_DIR,
|
|
80
|
+
ttlMs: 7_200_000, // 2 hours
|
|
81
|
+
compactionThreshold: 100,
|
|
82
|
+
compactionKeep: 40,
|
|
83
|
+
});
|
|
84
|
+
sessions.startReaper();
|
|
85
|
+
|
|
86
|
+
auth = new ChatAuthManager({
|
|
87
|
+
storagePath: AUTH_FILE,
|
|
88
|
+
passphrase: telegramConfig.passphrase,
|
|
89
|
+
allowedUsers: telegramConfig.allowedUsers,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
fragmentBuffer = new FragmentBuffer(
|
|
93
|
+
{ startThreshold: 4000, maxGapMs: 1500, maxParts: 12, maxTotalBytes: 50_000 },
|
|
94
|
+
(key, merged) => handleMergedMessage(key, merged),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Initialize notification engine
|
|
98
|
+
notifications = new NotificationEngine({
|
|
99
|
+
intervalMs: 30 * 60 * 1000, // 30 minutes
|
|
100
|
+
onNotify: async (_checkId, message) => {
|
|
101
|
+
try {
|
|
102
|
+
// Send to the most recently active chat
|
|
103
|
+
const activeSessions = sessions.listActive();
|
|
104
|
+
if (activeSessions.length > 0) {
|
|
105
|
+
await bot.api.sendMessage(activeSessions[0], message, { parse_mode: 'HTML' });
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Notification delivery failure is non-critical
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const bot = new Bot(telegramConfig.botToken);
|
|
114
|
+
|
|
115
|
+
// ─── Restart Context ───────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const restartCtx = updater.loadContext();
|
|
118
|
+
if (restartCtx) {
|
|
119
|
+
updater.clearContext();
|
|
120
|
+
try {
|
|
121
|
+
await bot.api.sendMessage(restartCtx.chatId, \`Back online! Restart reason: \${restartCtx.reason}.\`);
|
|
122
|
+
logEvent('restart_confirmed', restartCtx.chatId, { reason: restartCtx.reason });
|
|
123
|
+
} catch {
|
|
124
|
+
// Chat might not exist anymore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Commands ──────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
bot.command('start', async (ctx) => {
|
|
131
|
+
if (!checkAuth(ctx)) return;
|
|
132
|
+
await ctx.reply('Welcome! I am ${config.name}, ${config.role}. How can I help?');
|
|
133
|
+
logEvent('command', ctx.chat.id, { command: 'start' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
bot.command('clear', async (ctx) => {
|
|
137
|
+
if (!checkAuth(ctx)) return;
|
|
138
|
+
sessions.clear(String(ctx.chat.id));
|
|
139
|
+
await ctx.reply('Session cleared. Starting fresh.');
|
|
140
|
+
logEvent('command', ctx.chat.id, { command: 'clear' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
bot.command('health', async (ctx) => {
|
|
144
|
+
if (!checkAuth(ctx)) return;
|
|
145
|
+
const status = {
|
|
146
|
+
sessions: sessions.size,
|
|
147
|
+
authenticated: auth.authenticatedCount,
|
|
148
|
+
fragments: fragmentBuffer.pendingCount,
|
|
149
|
+
};
|
|
150
|
+
await ctx.reply(\`Health: \${JSON.stringify(status, null, 2)}\`);
|
|
151
|
+
logEvent('command', ctx.chat.id, { command: 'health' });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
bot.command('help', async (ctx) => {
|
|
155
|
+
if (!checkAuth(ctx)) return;
|
|
156
|
+
await ctx.reply(
|
|
157
|
+
[
|
|
158
|
+
'<b>Commands:</b>',
|
|
159
|
+
'/start — Initialize',
|
|
160
|
+
'/clear — Clear conversation',
|
|
161
|
+
'/health — System status',
|
|
162
|
+
'/help — This message',
|
|
163
|
+
'/stop — Cancel current task',
|
|
164
|
+
'/restart — Restart the bot',
|
|
165
|
+
].join('\\n'),
|
|
166
|
+
{ parse_mode: 'HTML' },
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
bot.command('stop', async (ctx) => {
|
|
171
|
+
const chatId = String(ctx.chat.id);
|
|
172
|
+
const info = cancellation.cancel(chatId);
|
|
173
|
+
if (info) {
|
|
174
|
+
const ranSec = ((Date.now() - info.startedAt) / 1000).toFixed(1);
|
|
175
|
+
await ctx.reply(\`Task cancelled after \${ranSec}s.\`);
|
|
176
|
+
logEvent('command', ctx.chat.id, { command: 'stop', ranMs: Date.now() - info.startedAt });
|
|
177
|
+
} else {
|
|
178
|
+
await ctx.reply('No task is currently running.');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
bot.command('restart', async (ctx) => {
|
|
183
|
+
if (!checkAuth(ctx)) return;
|
|
184
|
+
const chatId = String(ctx.chat.id);
|
|
185
|
+
const result = updater.requestRestart(chatId, 'manual');
|
|
186
|
+
if (result.initiated) {
|
|
187
|
+
await ctx.reply('Restarting... I will be back shortly.');
|
|
188
|
+
logEvent('command', ctx.chat.id, { command: 'restart' });
|
|
189
|
+
setTimeout(() => process.exit(RESTART_EXIT_CODE), 1000);
|
|
190
|
+
} else {
|
|
191
|
+
await ctx.reply(\`Restart failed: \${result.error}\`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ─── Photo Messages ────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
bot.on('message:photo', async (ctx) => {
|
|
198
|
+
if (!checkAuth(ctx)) return;
|
|
199
|
+
const chatId = String(ctx.chat.id);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const photo = ctx.message.photo[ctx.message.photo.length - 1]; // Largest size
|
|
203
|
+
const file = await ctx.api.getFile(photo.file_id);
|
|
204
|
+
if (!file.file_path) {
|
|
205
|
+
await ctx.reply('Could not download photo.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const url = \`https://api.telegram.org/file/bot\${telegramConfig.botToken}/\${file.file_path}\`;
|
|
210
|
+
const resp = await fetch(url);
|
|
211
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
212
|
+
|
|
213
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
214
|
+
await ctx.reply('File too large (max 20MB). Please compress and try again.');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const caption = ctx.message.caption ?? 'What do you see in this image?';
|
|
219
|
+
const fileInfo: FileInfo = {
|
|
220
|
+
name: file.file_path.split('/').pop() ?? 'photo.jpg',
|
|
221
|
+
mimeType: 'image/jpeg',
|
|
222
|
+
size: buffer.length,
|
|
223
|
+
data: buffer,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const intent = detectFileIntent(fileInfo.name, fileInfo.mimeType, caption);
|
|
227
|
+
const content = buildMultimodalContent(fileInfo, intent);
|
|
228
|
+
|
|
229
|
+
// Build multimodal user message for agent
|
|
230
|
+
const multimodalText = content.type === 'text'
|
|
231
|
+
? \`[File: \${fileInfo.name}]\\n\${content.content}\\n\\n\${caption}\`
|
|
232
|
+
: \`[Image: \${fileInfo.name}, \${Math.round(fileInfo.size / 1024)}KB]\\n\${caption}\`;
|
|
233
|
+
|
|
234
|
+
logEvent('photo_in', ctx.chat.id, { size: buffer.length, intent });
|
|
235
|
+
await dispatchToAgent(chatId, multimodalText, ctx);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
238
|
+
await ctx.reply('Failed to process photo.');
|
|
239
|
+
logEvent('error', ctx.chat.id, { error: msg, type: 'photo' });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─── Document Messages ─────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
bot.on('message:document', async (ctx) => {
|
|
246
|
+
if (!checkAuth(ctx)) return;
|
|
247
|
+
const chatId = String(ctx.chat.id);
|
|
248
|
+
const doc = ctx.message.document;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
if (doc.file_size && doc.file_size > MAX_FILE_SIZE) {
|
|
252
|
+
await ctx.reply('File too large (max 20MB). Please compress and try again.');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const file = await ctx.api.getFile(doc.file_id);
|
|
257
|
+
if (!file.file_path) {
|
|
258
|
+
await ctx.reply('Could not download document.');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const url = \`https://api.telegram.org/file/bot\${telegramConfig.botToken}/\${file.file_path}\`;
|
|
263
|
+
const resp = await fetch(url);
|
|
264
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
265
|
+
|
|
266
|
+
const filename = doc.file_name ?? 'document';
|
|
267
|
+
const mimeType = doc.mime_type ?? 'application/octet-stream';
|
|
268
|
+
const caption = ctx.message.caption ?? '';
|
|
269
|
+
|
|
270
|
+
const intent = detectFileIntent(filename, mimeType, caption);
|
|
271
|
+
const fileInfo: FileInfo = { name: filename, mimeType, size: buffer.length, data: buffer };
|
|
272
|
+
|
|
273
|
+
if (intent === 'intake') {
|
|
274
|
+
saveTempFile(UPLOAD_DIR, filename, buffer);
|
|
275
|
+
await ctx.reply(\`Saved \${filename} for knowledge ingestion.\`);
|
|
276
|
+
logEvent('document_intake', ctx.chat.id, { filename, size: buffer.length });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const content = buildMultimodalContent(fileInfo, intent);
|
|
281
|
+
const userText = content.type === 'text'
|
|
282
|
+
? \`[File: \${filename}]\\n\${content.content}\${caption ? '\\n\\n' + caption : ''}\`
|
|
283
|
+
: \`[Document: \${filename}, \${Math.round(buffer.length / 1024)}KB]\${caption ? '\\n' + caption : ''}\`;
|
|
284
|
+
|
|
285
|
+
logEvent('document_in', ctx.chat.id, { filename, size: buffer.length, intent });
|
|
286
|
+
await dispatchToAgent(chatId, userText, ctx);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
289
|
+
await ctx.reply('Failed to process document.');
|
|
290
|
+
logEvent('error', ctx.chat.id, { error: msg, type: 'document' });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ─── Text Messages ────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
bot.on('message:text', async (ctx) => {
|
|
297
|
+
const text = ctx.message.text;
|
|
298
|
+
const chatId = String(ctx.chat.id);
|
|
299
|
+
const userId = ctx.from?.id;
|
|
300
|
+
|
|
301
|
+
// Auth check — if passphrase is set, check if user is authenticated
|
|
302
|
+
if (auth.enabled && !auth.isAuthenticated(String(userId))) {
|
|
303
|
+
if (telegramConfig.passphrase && text === telegramConfig.passphrase) {
|
|
304
|
+
auth.authenticate(String(userId), text);
|
|
305
|
+
await ctx.reply('Authenticated! How can I help?');
|
|
306
|
+
logEvent('auth', ctx.chat.id, { userId });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
await ctx.reply('Please authenticate first.');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
logEvent('message_in', ctx.chat.id, { length: text.length });
|
|
314
|
+
|
|
315
|
+
// Fragment buffering
|
|
316
|
+
const fragment: Fragment = {
|
|
317
|
+
text,
|
|
318
|
+
messageId: ctx.message.message_id,
|
|
319
|
+
receivedAt: Date.now(),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const buffered = fragmentBuffer.receive(chatId, fragment);
|
|
323
|
+
if (buffered) return; // Wait for more fragments
|
|
324
|
+
|
|
325
|
+
// Process immediately
|
|
326
|
+
await dispatchToAgent(chatId, text, ctx);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ─── Error Handling ───────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
bot.catch((err) => {
|
|
332
|
+
console.error('[bot] Error:', err.message);
|
|
333
|
+
logEvent('error', 'global', { error: err.message });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ─── Start ────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
console.log('[${config.id}] Starting Telegram bot...');
|
|
339
|
+
logEvent('bot_start', 'system');
|
|
340
|
+
|
|
341
|
+
// Start notification polling and periodic temp cleanup
|
|
342
|
+
notifications.start();
|
|
343
|
+
setInterval(() => cleanupTempFiles(UPLOAD_DIR, 3_600_000), 3_600_000); // 1h
|
|
344
|
+
|
|
345
|
+
process.on('SIGINT', () => shutdown(bot));
|
|
346
|
+
process.on('SIGTERM', () => shutdown(bot));
|
|
347
|
+
|
|
348
|
+
await bot.start();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Agent Dispatch ──────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
async function dispatchToAgent(
|
|
354
|
+
chatId: string,
|
|
355
|
+
text: string,
|
|
356
|
+
ctx: { reply: (text: string, options?: Record<string, unknown>) => Promise<unknown> },
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
if (processingLocks.has(chatId)) {
|
|
359
|
+
await ctx.reply('I am still working on your previous request. Please wait.');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
processingLocks.add(chatId);
|
|
364
|
+
const signal = cancellation.create(chatId, 'Processing message');
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
// Append user message
|
|
368
|
+
sessions.appendMessage(chatId, {
|
|
369
|
+
role: 'user',
|
|
370
|
+
content: text,
|
|
371
|
+
timestamp: Date.now(),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Run agent with cancellation signal
|
|
375
|
+
const agent = createTelegramAgent(telegramConfig);
|
|
376
|
+
const session = sessions.getOrCreate(chatId);
|
|
377
|
+
const result = await agent.run(session.messages, { signal });
|
|
378
|
+
|
|
379
|
+
// Append agent response
|
|
380
|
+
sessions.appendMessages(chatId, result.newMessages);
|
|
381
|
+
|
|
382
|
+
// Send response in chunks
|
|
383
|
+
const chunks = chunkResponse(result.text, { maxChunkSize: 4000, format: 'html' });
|
|
384
|
+
for (const chunk of chunks) {
|
|
385
|
+
try {
|
|
386
|
+
await ctx.reply(chunk, { parse_mode: 'HTML' });
|
|
387
|
+
} catch {
|
|
388
|
+
// Fallback to plain text if HTML fails
|
|
389
|
+
await ctx.reply(chunk);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
logEvent('response_out', chatId, {
|
|
394
|
+
iterations: result.iterations,
|
|
395
|
+
toolCalls: result.toolCalls,
|
|
396
|
+
chunks: chunks.length,
|
|
397
|
+
});
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (signal.aborted) {
|
|
400
|
+
logEvent('cancelled', chatId);
|
|
401
|
+
return; // Already notified user via /stop handler
|
|
402
|
+
}
|
|
403
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
404
|
+
console.error(\`[agent] Error for chat \${chatId}:\`, msg);
|
|
405
|
+
await ctx.reply('Sorry, I encountered an error. Please try again.');
|
|
406
|
+
logEvent('error', chatId, { error: msg });
|
|
407
|
+
} finally {
|
|
408
|
+
cancellation.complete(chatId);
|
|
409
|
+
processingLocks.delete(chatId);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── Fragment Handler ────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
function handleMergedMessage(key: string, merged: string): void {
|
|
416
|
+
// key is the chatId — dispatch the merged text
|
|
417
|
+
// Note: we don't have the ctx here, so we use the bot API directly
|
|
418
|
+
// In a real implementation, you'd store the ctx reference
|
|
419
|
+
console.log(\`[fragment] Merged \${merged.length} chars for \${key}\`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Auth Helper ─────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
function checkAuth(ctx: { from?: { id: number }; reply: (text: string) => Promise<unknown> }): boolean {
|
|
425
|
+
if (!auth.enabled) return true;
|
|
426
|
+
if (auth.isAuthenticated(String(ctx.from?.id))) return true;
|
|
427
|
+
ctx.reply('Please authenticate first.');
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ─── Shutdown ────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
function shutdown(bot: Bot): void {
|
|
434
|
+
console.log('[${config.id}] Shutting down...');
|
|
435
|
+
logEvent('bot_stop', 'system');
|
|
436
|
+
cancellation.cancelAll();
|
|
437
|
+
notifications.stop();
|
|
438
|
+
cleanupTempFiles(UPLOAD_DIR);
|
|
439
|
+
fragmentBuffer.close();
|
|
440
|
+
sessions.close();
|
|
441
|
+
bot.stop();
|
|
442
|
+
process.exit(0);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
startBot().catch((err) => {
|
|
448
|
+
console.error('[${config.id}] Fatal:', err);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
});
|
|
451
|
+
`;
|
|
452
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template: Telegram configuration module.
|
|
3
|
+
* Generated by @soleri/forge for agents with Telegram transport.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AgentConfig } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export function generateTelegramConfig(config: AgentConfig): string {
|
|
9
|
+
return `/**
|
|
10
|
+
* Telegram Configuration — env + file-based config loading.
|
|
11
|
+
* Generated by @soleri/forge.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
|
|
18
|
+
export interface TelegramConfig {
|
|
19
|
+
/** Telegram bot token (from @BotFather). */
|
|
20
|
+
botToken: string;
|
|
21
|
+
/** Anthropic API key. */
|
|
22
|
+
apiKey: string;
|
|
23
|
+
/** Claude model to use. Default: 'claude-sonnet-4-20250514'. */
|
|
24
|
+
model?: string;
|
|
25
|
+
/** Working directory for the agent. Default: cwd. */
|
|
26
|
+
workingDirectory?: string;
|
|
27
|
+
/** Allowed user IDs (empty = any authenticated user). */
|
|
28
|
+
allowedUsers?: number[];
|
|
29
|
+
/** Auth passphrase. If unset, auth is disabled. */
|
|
30
|
+
passphrase?: string;
|
|
31
|
+
/** Max output tokens per LLM call. Default: 16384. */
|
|
32
|
+
maxTokens?: number;
|
|
33
|
+
/** Max agent loop iterations. Default: 200. */
|
|
34
|
+
maxIterations?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CONFIG_DIR = join(homedir(), '.${config.id}');
|
|
38
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'telegram.json');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load Telegram config from environment variables and config file.
|
|
42
|
+
* Environment variables take priority over file-based config.
|
|
43
|
+
*/
|
|
44
|
+
export function loadTelegramConfig(): TelegramConfig {
|
|
45
|
+
let fileConfig: Partial<TelegramConfig> = {};
|
|
46
|
+
|
|
47
|
+
if (existsSync(CONFIG_FILE)) {
|
|
48
|
+
try {
|
|
49
|
+
fileConfig = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
50
|
+
} catch {
|
|
51
|
+
console.error('[config] Failed to parse telegram.json, using defaults');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const botToken = process.env.TELEGRAM_BOT_TOKEN ?? fileConfig.botToken ?? '';
|
|
56
|
+
const apiKey =
|
|
57
|
+
process.env.ANTHROPIC_API_KEY ?? fileConfig.apiKey ?? '';
|
|
58
|
+
|
|
59
|
+
if (!botToken) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'Missing TELEGRAM_BOT_TOKEN. Set via environment or ~/.${config.id}/telegram.json',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!apiKey) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'Missing ANTHROPIC_API_KEY. Set via environment or ~/.${config.id}/telegram.json',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
botToken,
|
|
72
|
+
apiKey,
|
|
73
|
+
model: process.env.ANTHROPIC_MODEL ?? fileConfig.model ?? 'claude-sonnet-4-20250514',
|
|
74
|
+
workingDirectory:
|
|
75
|
+
process.env.AGENT_WORKING_DIR ?? fileConfig.workingDirectory ?? process.cwd(),
|
|
76
|
+
allowedUsers: fileConfig.allowedUsers ?? [],
|
|
77
|
+
passphrase: fileConfig.passphrase,
|
|
78
|
+
maxTokens: fileConfig.maxTokens ?? 16384,
|
|
79
|
+
maxIterations: fileConfig.maxIterations ?? 200,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template: Telegram supervisor — process management with restart and logging.
|
|
3
|
+
* Generated by @soleri/forge for agents with Telegram transport.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AgentConfig } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export function generateTelegramSupervisor(config: AgentConfig): string {
|
|
9
|
+
return `/**
|
|
10
|
+
* ${config.name} — Telegram Bot Supervisor.
|
|
11
|
+
* Generated by @soleri/forge.
|
|
12
|
+
*
|
|
13
|
+
* Wraps the bot process with:
|
|
14
|
+
* - Restart on crash (exponential backoff)
|
|
15
|
+
* - Restart on self-update (exit code 75)
|
|
16
|
+
* - Graceful shutdown (SIGINT/SIGTERM)
|
|
17
|
+
* - Log rotation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { mkdirSync, appendFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
const LOG_DIR = join(homedir(), '.${config.id}', 'logs');
|
|
26
|
+
const MAX_LOG_AGE_DAYS = 7;
|
|
27
|
+
const SELF_UPDATE_EXIT_CODE = 75;
|
|
28
|
+
|
|
29
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
|
30
|
+
const MIN_RESTART_DELAY = 1000;
|
|
31
|
+
const MAX_RESTART_DELAY = 30_000;
|
|
32
|
+
|
|
33
|
+
let restartCount = 0;
|
|
34
|
+
let shutdownRequested = false;
|
|
35
|
+
|
|
36
|
+
function log(message: string): void {
|
|
37
|
+
const ts = new Date().toISOString();
|
|
38
|
+
const line = \`[\${ts}] [supervisor] \${message}\`;
|
|
39
|
+
console.log(line);
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
42
|
+
const date = ts.split('T')[0];
|
|
43
|
+
appendFileSync(join(LOG_DIR, \`telegram-\${date}.log\`), line + '\\n', 'utf-8');
|
|
44
|
+
} catch {
|
|
45
|
+
// Log write failure is non-critical
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rotateLogs(): void {
|
|
50
|
+
try {
|
|
51
|
+
const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
52
|
+
const files = readdirSync(LOG_DIR);
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
if (!file.startsWith('telegram-') && !file.startsWith('events-')) continue;
|
|
55
|
+
const filePath = join(LOG_DIR, file);
|
|
56
|
+
try {
|
|
57
|
+
const stat = statSync(filePath);
|
|
58
|
+
if (stat.mtimeMs < cutoff) {
|
|
59
|
+
rmSync(filePath);
|
|
60
|
+
log(\`Rotated old log: \${file}\`);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Skip files we can't stat
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Rotation failure is non-critical
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getRestartDelay(): number {
|
|
72
|
+
const delay = Math.min(MIN_RESTART_DELAY * Math.pow(2, restartCount), MAX_RESTART_DELAY);
|
|
73
|
+
return delay;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function startBot(): void {
|
|
77
|
+
rotateLogs();
|
|
78
|
+
log(\`Starting bot (attempt \${restartCount + 1})...\`);
|
|
79
|
+
|
|
80
|
+
const child = spawn('node', [join(process.cwd(), 'dist', 'telegram-bot.js')], {
|
|
81
|
+
stdio: 'inherit',
|
|
82
|
+
env: { ...process.env },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
child.on('exit', (code, signal) => {
|
|
86
|
+
if (shutdownRequested) {
|
|
87
|
+
log('Bot stopped (shutdown requested).');
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (code === SELF_UPDATE_EXIT_CODE) {
|
|
92
|
+
log('Bot requested self-update restart. Rebuilding...');
|
|
93
|
+
restartCount = 0;
|
|
94
|
+
try {
|
|
95
|
+
const { execFileSync } = require('node:child_process');
|
|
96
|
+
execFileSync('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'pipe', timeout: 60_000 });
|
|
97
|
+
log('Rebuild succeeded. Restarting...');
|
|
98
|
+
} catch (buildErr: unknown) {
|
|
99
|
+
const buildMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
|
|
100
|
+
log(\`Rebuild failed: \${buildMsg}. Attempting rollback...\`);
|
|
101
|
+
try {
|
|
102
|
+
const { execFileSync: execFile2 } = require('node:child_process');
|
|
103
|
+
execFile2('git', ['revert', 'HEAD', '--no-edit'], { cwd: process.cwd(), stdio: 'pipe', timeout: 30_000 });
|
|
104
|
+
execFile2('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'pipe', timeout: 60_000 });
|
|
105
|
+
log('Rollback succeeded. Restarting with previous code...');
|
|
106
|
+
} catch {
|
|
107
|
+
log('Rollback also failed. Restarting with current code anyway...');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
setTimeout(startBot, 1000);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (code === 0) {
|
|
115
|
+
log('Bot exited cleanly.');
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
restartCount++;
|
|
120
|
+
const delay = getRestartDelay();
|
|
121
|
+
log(\`Bot exited with code \${code ?? signal}. Restarting in \${delay}ms...\`);
|
|
122
|
+
setTimeout(startBot, delay);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
child.on('error', (err) => {
|
|
126
|
+
log(\`Failed to start bot: \${err.message}\`);
|
|
127
|
+
restartCount++;
|
|
128
|
+
const delay = getRestartDelay();
|
|
129
|
+
setTimeout(startBot, delay);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Reset backoff after 60 seconds of successful running
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
if (!shutdownRequested) restartCount = 0;
|
|
135
|
+
}, 60_000);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Graceful shutdown
|
|
139
|
+
process.on('SIGINT', () => {
|
|
140
|
+
shutdownRequested = true;
|
|
141
|
+
log('SIGINT received, shutting down...');
|
|
142
|
+
});
|
|
143
|
+
process.on('SIGTERM', () => {
|
|
144
|
+
shutdownRequested = true;
|
|
145
|
+
log('SIGTERM received, shutting down...');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
startBot();
|
|
149
|
+
`;
|
|
150
|
+
}
|