@aion0/forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot ā remote interface for My Workflow.
|
|
3
|
+
*
|
|
4
|
+
* Optimized for mobile:
|
|
5
|
+
* - /tasks shows numbered list, reply with number to see details
|
|
6
|
+
* - Reply to task messages to send follow-ups
|
|
7
|
+
* - Plain text "project: instructions" to create tasks
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { loadSettings } from './settings';
|
|
11
|
+
import { createTask, getTask, listTasks, cancelTask, retryTask, onTaskEvent } from './task-manager';
|
|
12
|
+
import { scanProjects } from './projects';
|
|
13
|
+
import { listClaudeSessions, getSessionFilePath, readSessionEntries } from './claude-sessions';
|
|
14
|
+
import { listWatchers, createWatcher, deleteWatcher, toggleWatcher } from './session-watcher';
|
|
15
|
+
import { startTunnel, stopTunnel, getTunnelStatus } from './cloudflared';
|
|
16
|
+
import { getPassword } from './password';
|
|
17
|
+
import type { Task, TaskLogEntry } from '@/src/types';
|
|
18
|
+
|
|
19
|
+
let polling = false;
|
|
20
|
+
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
let lastUpdateId = 0;
|
|
22
|
+
|
|
23
|
+
// Prevent duplicate polling across hot-reloads
|
|
24
|
+
const globalKey = Symbol.for('mw-telegram-polling');
|
|
25
|
+
const g = globalThis as any;
|
|
26
|
+
|
|
27
|
+
// Track which Telegram message maps to which task (for reply-based interaction)
|
|
28
|
+
const taskMessageMap = new Map<number, string>(); // messageId ā taskId
|
|
29
|
+
const taskChatMap = new Map<string, number>(); // taskId ā chatId
|
|
30
|
+
|
|
31
|
+
// Numbered lists ā maps number (1-10) ā id for quick selection
|
|
32
|
+
const chatNumberedTasks = new Map<number, Map<number, string>>();
|
|
33
|
+
// Session selection: two-tier ā first pick project, then pick session
|
|
34
|
+
const chatNumberedSessions = new Map<number, Map<number, { projectName: string; sessionId: string }>>();
|
|
35
|
+
const chatNumberedProjects = new Map<number, Map<number, string>>();
|
|
36
|
+
// Track what the last numbered list was for
|
|
37
|
+
const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions'>();
|
|
38
|
+
|
|
39
|
+
// Buffer for streaming logs
|
|
40
|
+
const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof setTimeout> | null }>();
|
|
41
|
+
|
|
42
|
+
// āāā Start/Stop āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
43
|
+
|
|
44
|
+
export function startTelegramBot() {
|
|
45
|
+
if (polling || g[globalKey]) return;
|
|
46
|
+
const settings = loadSettings();
|
|
47
|
+
if (!settings.telegramBotToken || !settings.telegramChatId) return;
|
|
48
|
+
|
|
49
|
+
polling = true;
|
|
50
|
+
g[globalKey] = true;
|
|
51
|
+
console.log('[telegram] Bot started');
|
|
52
|
+
|
|
53
|
+
// Set bot command menu
|
|
54
|
+
setBotCommands(settings.telegramBotToken);
|
|
55
|
+
|
|
56
|
+
// Listen for task events ā stream to Telegram
|
|
57
|
+
onTaskEvent((taskId, event, data) => {
|
|
58
|
+
const settings = loadSettings();
|
|
59
|
+
if (!settings.telegramBotToken || !settings.telegramChatId) return;
|
|
60
|
+
const chatId = Number(settings.telegramChatId);
|
|
61
|
+
|
|
62
|
+
if (event === 'log') {
|
|
63
|
+
bufferLogEntry(taskId, chatId, data as TaskLogEntry);
|
|
64
|
+
} else if (event === 'status') {
|
|
65
|
+
handleStatusChange(taskId, chatId, data as string);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
poll();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stopTelegramBot() {
|
|
73
|
+
polling = false;
|
|
74
|
+
g[globalKey] = false;
|
|
75
|
+
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// āāā Polling āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
79
|
+
|
|
80
|
+
async function poll() {
|
|
81
|
+
if (!polling) return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const settings = loadSettings();
|
|
85
|
+
const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${lastUpdateId + 1}&timeout=30`;
|
|
86
|
+
const res = await fetch(url);
|
|
87
|
+
const data = await res.json();
|
|
88
|
+
|
|
89
|
+
if (data.ok && data.result) {
|
|
90
|
+
for (const update of data.result) {
|
|
91
|
+
lastUpdateId = update.update_id;
|
|
92
|
+
if (update.message?.text) {
|
|
93
|
+
await handleMessage(update.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('[telegram] Poll error:', err);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pollTimer = setTimeout(poll, 1000);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// āāā Message Handler āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
105
|
+
|
|
106
|
+
async function handleMessage(msg: any) {
|
|
107
|
+
const chatId = msg.chat.id;
|
|
108
|
+
console.log(`[telegram] Message from chat ID: ${chatId}, user: ${msg.from?.username || msg.from?.first_name || 'unknown'}`);
|
|
109
|
+
const text: string = msg.text.trim();
|
|
110
|
+
const replyTo = msg.reply_to_message?.message_id;
|
|
111
|
+
|
|
112
|
+
// Check if replying to a task message ā follow-up
|
|
113
|
+
if (replyTo && taskMessageMap.has(replyTo)) {
|
|
114
|
+
const taskId = taskMessageMap.get(replyTo)!;
|
|
115
|
+
await handleFollowUp(chatId, taskId, text);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Quick number selection (1-10) ā context-dependent
|
|
120
|
+
if (/^\d{1,2}$/.test(text)) {
|
|
121
|
+
const num = parseInt(text);
|
|
122
|
+
const mode = chatListMode.get(chatId);
|
|
123
|
+
|
|
124
|
+
if (mode === 'projects') {
|
|
125
|
+
const projMap = chatNumberedProjects.get(chatId);
|
|
126
|
+
if (projMap?.has(num)) {
|
|
127
|
+
await sendSessionList(chatId, projMap.get(num)!);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
} else if (mode === 'sessions') {
|
|
131
|
+
const sessMap = chatNumberedSessions.get(chatId);
|
|
132
|
+
if (sessMap?.has(num)) {
|
|
133
|
+
const { projectName, sessionId } = sessMap.get(num)!;
|
|
134
|
+
await sendSessionContent(chatId, projectName, sessionId);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
const taskMap = chatNumberedTasks.get(chatId);
|
|
139
|
+
if (taskMap?.has(num)) {
|
|
140
|
+
await sendTaskDetail(chatId, taskMap.get(num)!);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Commands
|
|
147
|
+
if (text.startsWith('/')) {
|
|
148
|
+
const [cmd, ...args] = text.split(/\s+/);
|
|
149
|
+
switch (cmd) {
|
|
150
|
+
case '/start':
|
|
151
|
+
case '/help':
|
|
152
|
+
await sendHelp(chatId);
|
|
153
|
+
break;
|
|
154
|
+
case '/tasks':
|
|
155
|
+
case '/t':
|
|
156
|
+
await sendNumberedTaskList(chatId, args[0]);
|
|
157
|
+
break;
|
|
158
|
+
case '/new':
|
|
159
|
+
case '/task':
|
|
160
|
+
await handleNewTask(chatId, args.join(' '));
|
|
161
|
+
break;
|
|
162
|
+
case '/sessions':
|
|
163
|
+
case '/s':
|
|
164
|
+
if (args[0]) {
|
|
165
|
+
await sendSessionList(chatId, args[0]);
|
|
166
|
+
} else {
|
|
167
|
+
await sendProjectListForSessions(chatId);
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
case '/projects':
|
|
171
|
+
case '/p':
|
|
172
|
+
await sendProjectList(chatId);
|
|
173
|
+
break;
|
|
174
|
+
case '/watch':
|
|
175
|
+
await handleWatch(chatId, args[0], args[1]);
|
|
176
|
+
break;
|
|
177
|
+
case '/watchers':
|
|
178
|
+
case '/w':
|
|
179
|
+
await sendWatcherList(chatId);
|
|
180
|
+
break;
|
|
181
|
+
case '/unwatch':
|
|
182
|
+
await handleUnwatch(chatId, args[0]);
|
|
183
|
+
break;
|
|
184
|
+
case '/cancel':
|
|
185
|
+
await handleCancel(chatId, args[0]);
|
|
186
|
+
break;
|
|
187
|
+
case '/retry':
|
|
188
|
+
await handleRetry(chatId, args[0]);
|
|
189
|
+
break;
|
|
190
|
+
case '/tunnel':
|
|
191
|
+
await handleTunnel(chatId, args[0], args[1], msg.message_id);
|
|
192
|
+
break;
|
|
193
|
+
case '/tunnel_password':
|
|
194
|
+
await handleTunnelPassword(chatId, args[0], msg.message_id);
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
await send(chatId, `Unknown command: ${cmd}\nUse /help to see available commands.`);
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Plain text ā try to parse as "project: task" format
|
|
203
|
+
const colonIdx = text.indexOf(':');
|
|
204
|
+
if (colonIdx > 0 && colonIdx < 30) {
|
|
205
|
+
const projectName = text.slice(0, colonIdx).trim();
|
|
206
|
+
const prompt = text.slice(colonIdx + 1).trim();
|
|
207
|
+
if (prompt) {
|
|
208
|
+
await handleNewTask(chatId, `${projectName} ${prompt}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await send(chatId,
|
|
214
|
+
`Send a task as:\nproject-name: your instructions\n\nOr use /help for all commands.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// āāā Command Handlers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
219
|
+
|
|
220
|
+
async function sendHelp(chatId: number) {
|
|
221
|
+
await send(chatId,
|
|
222
|
+
`š¤ Forge\n\n` +
|
|
223
|
+
`š /tasks ā numbered task list\n` +
|
|
224
|
+
`/tasks running ā filter by status\n` +
|
|
225
|
+
`š /sessions ā browse session content\n` +
|
|
226
|
+
`/sessions <project> ā sessions for project\n\n` +
|
|
227
|
+
`š /watch <project> [sessionId] ā monitor session\n` +
|
|
228
|
+
`/watchers ā list active watchers\n` +
|
|
229
|
+
`/unwatch <id> ā stop watching\n\n` +
|
|
230
|
+
`š Submit task:\nproject-name: your instructions\n\n` +
|
|
231
|
+
`š§ /cancel <id> /retry <id>\n` +
|
|
232
|
+
`/projects ā list projects\n\n` +
|
|
233
|
+
`š /tunnel [start|stop] ā remote access\n` +
|
|
234
|
+
`/tunnel_password <pw> ā get login password\n\n` +
|
|
235
|
+
`Reply number to select`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function sendNumberedTaskList(chatId: number, statusFilter?: string) {
|
|
240
|
+
// Get running/queued first, then recent done/failed
|
|
241
|
+
const allTasks = listTasks(statusFilter as any || undefined);
|
|
242
|
+
|
|
243
|
+
// Sort: running first, then queued, then by recency
|
|
244
|
+
const prioritized = [
|
|
245
|
+
...allTasks.filter(t => t.status === 'running'),
|
|
246
|
+
...allTasks.filter(t => t.status === 'queued'),
|
|
247
|
+
...allTasks.filter(t => t.status !== 'running' && t.status !== 'queued'),
|
|
248
|
+
].slice(0, 10);
|
|
249
|
+
|
|
250
|
+
if (prioritized.length === 0) {
|
|
251
|
+
await send(chatId, 'No tasks found.');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Build numbered map
|
|
256
|
+
const numMap = new Map<number, string>();
|
|
257
|
+
const lines: string[] = [];
|
|
258
|
+
|
|
259
|
+
prioritized.forEach((t, i) => {
|
|
260
|
+
const num = i + 1;
|
|
261
|
+
numMap.set(num, t.id);
|
|
262
|
+
|
|
263
|
+
const icon = t.status === 'running' ? 'š' : t.status === 'queued' ? 'ā³' : t.status === 'done' ? 'ā
' : t.status === 'failed' ? 'ā' : 'āŖ';
|
|
264
|
+
const cost = t.costUSD != null ? ` $${t.costUSD.toFixed(3)}` : '';
|
|
265
|
+
const prompt = t.prompt.length > 40 ? t.prompt.slice(0, 40) + '...' : t.prompt;
|
|
266
|
+
|
|
267
|
+
lines.push(`${num}. ${icon} ${t.projectName}\n ${prompt}${cost}`);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
chatNumberedTasks.set(chatId, numMap);
|
|
271
|
+
chatListMode.set(chatId, 'tasks');
|
|
272
|
+
|
|
273
|
+
await send(chatId,
|
|
274
|
+
`š Tasks ā reply number to see details\n\n${lines.join('\n\n')}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function sendTaskDetail(chatId: number, taskId: string) {
|
|
279
|
+
const task = getTask(taskId);
|
|
280
|
+
if (!task) {
|
|
281
|
+
await send(chatId, `Task not found: ${taskId}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const icon = task.status === 'done' ? 'ā
' : task.status === 'running' ? 'š' : task.status === 'failed' ? 'ā' : 'ā³';
|
|
286
|
+
|
|
287
|
+
let text = `${icon} ${task.projectName} [${task.id}]\n`;
|
|
288
|
+
text += `Status: ${task.status}\n`;
|
|
289
|
+
text += `Task: ${task.prompt}\n`;
|
|
290
|
+
|
|
291
|
+
if (task.startedAt) text += `Started: ${new Date(task.startedAt).toLocaleString()}\n`;
|
|
292
|
+
if (task.completedAt) text += `Done: ${new Date(task.completedAt).toLocaleString()}\n`;
|
|
293
|
+
if (task.costUSD != null) text += `Cost: $${task.costUSD.toFixed(4)}\n`;
|
|
294
|
+
if (task.error) text += `\nā Error: ${task.error}\n`;
|
|
295
|
+
|
|
296
|
+
if (task.resultSummary) {
|
|
297
|
+
const result = task.resultSummary.length > 1500
|
|
298
|
+
? task.resultSummary.slice(0, 1500) + '...'
|
|
299
|
+
: task.resultSummary;
|
|
300
|
+
text += `\n--- Result ---\n${result}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Show recent log summary for running tasks
|
|
304
|
+
if (task.status === 'running' && task.log.length > 0) {
|
|
305
|
+
const recent = task.log
|
|
306
|
+
.filter(e => e.subtype === 'text' || e.subtype === 'tool_use')
|
|
307
|
+
.slice(-5)
|
|
308
|
+
.map(e => e.subtype === 'tool_use' ? `š§ ${e.tool}` : e.content.slice(0, 80))
|
|
309
|
+
.join('\n');
|
|
310
|
+
if (recent) text += `\n--- Recent ---\n${recent}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const msgId = await send(chatId, text);
|
|
314
|
+
if (msgId) {
|
|
315
|
+
taskMessageMap.set(msgId, taskId);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Show action hints
|
|
319
|
+
if (task.status === 'done') {
|
|
320
|
+
await send(chatId, `š¬ Reply to the message above to send follow-up`);
|
|
321
|
+
} else if (task.status === 'failed') {
|
|
322
|
+
await send(chatId, `š /retry ${task.id}`);
|
|
323
|
+
} else if (task.status === 'running' || task.status === 'queued') {
|
|
324
|
+
await send(chatId, `š /cancel ${task.id}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function sendProjectListForSessions(chatId: number) {
|
|
329
|
+
const projects = scanProjects();
|
|
330
|
+
if (projects.length === 0) {
|
|
331
|
+
await send(chatId, 'No projects found.');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const numMap = new Map<number, string>();
|
|
336
|
+
const lines: string[] = [];
|
|
337
|
+
|
|
338
|
+
projects.slice(0, 10).forEach((p, i) => {
|
|
339
|
+
const num = i + 1;
|
|
340
|
+
numMap.set(num, p.name);
|
|
341
|
+
lines.push(`${num}. ${p.name}${p.language ? ` (${p.language})` : ''}`);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
chatNumberedProjects.set(chatId, numMap);
|
|
345
|
+
chatListMode.set(chatId, 'projects');
|
|
346
|
+
|
|
347
|
+
await send(chatId,
|
|
348
|
+
`š Select project ā reply number\n\n${lines.join('\n')}`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function sendSessionList(chatId: number, projectName: string) {
|
|
353
|
+
const sessions = listClaudeSessions(projectName);
|
|
354
|
+
if (sessions.length === 0) {
|
|
355
|
+
await send(chatId, `No sessions for ${projectName}`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const numMap = new Map<number, { projectName: string; sessionId: string }>();
|
|
360
|
+
const lines: string[] = [];
|
|
361
|
+
|
|
362
|
+
sessions.slice(0, 10).forEach((s, i) => {
|
|
363
|
+
const num = i + 1;
|
|
364
|
+
numMap.set(num, { projectName, sessionId: s.sessionId });
|
|
365
|
+
const label = s.summary || s.firstPrompt || s.sessionId.slice(0, 8);
|
|
366
|
+
const msgs = s.messageCount != null ? ` (${s.messageCount} msgs)` : '';
|
|
367
|
+
const date = s.modified ? new Date(s.modified).toLocaleDateString() : '';
|
|
368
|
+
lines.push(`${num}. ${label}${msgs}\n ${date} ${s.gitBranch || ''}`);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
chatNumberedSessions.set(chatId, numMap);
|
|
372
|
+
chatListMode.set(chatId, 'sessions');
|
|
373
|
+
|
|
374
|
+
await send(chatId,
|
|
375
|
+
`š ${projectName} sessions ā reply number\n\n${lines.join('\n\n')}`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function sendSessionContent(chatId: number, projectName: string, sessionId: string) {
|
|
380
|
+
const filePath = getSessionFilePath(projectName, sessionId);
|
|
381
|
+
if (!filePath) {
|
|
382
|
+
await send(chatId, 'Session file not found');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const entries = readSessionEntries(filePath);
|
|
387
|
+
if (entries.length === 0) {
|
|
388
|
+
await send(chatId, 'Session is empty');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Build a readable summary ā show user messages and assistant text, skip tool details
|
|
393
|
+
const parts: string[] = [];
|
|
394
|
+
let charCount = 0;
|
|
395
|
+
const MAX = 3500;
|
|
396
|
+
|
|
397
|
+
// Walk from end to get most recent content
|
|
398
|
+
for (let i = entries.length - 1; i >= 0 && charCount < MAX; i--) {
|
|
399
|
+
const e = entries[i];
|
|
400
|
+
let line = '';
|
|
401
|
+
if (e.type === 'user') {
|
|
402
|
+
line = `š¤ ${e.content}`;
|
|
403
|
+
} else if (e.type === 'assistant_text') {
|
|
404
|
+
line = `š¤ ${e.content.slice(0, 500)}`;
|
|
405
|
+
} else if (e.type === 'tool_use') {
|
|
406
|
+
line = `š§ ${e.toolName || 'tool'}`;
|
|
407
|
+
}
|
|
408
|
+
// Skip thinking, tool_result, system for brevity
|
|
409
|
+
if (!line) continue;
|
|
410
|
+
|
|
411
|
+
if (charCount + line.length > MAX) {
|
|
412
|
+
line = line.slice(0, MAX - charCount) + '...';
|
|
413
|
+
}
|
|
414
|
+
parts.unshift(line);
|
|
415
|
+
charCount += line.length;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const header = `š Session: ${sessionId.slice(0, 8)}\nProject: ${projectName}\n${entries.length} entries\n\n`;
|
|
419
|
+
|
|
420
|
+
// Split into chunks for Telegram's 4096 limit
|
|
421
|
+
const fullText = header + parts.join('\n\n');
|
|
422
|
+
const chunks = splitMessage(fullText, 4000);
|
|
423
|
+
for (const chunk of chunks) {
|
|
424
|
+
await send(chatId, chunk);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Parse task creation input. Supports:
|
|
430
|
+
* project-name instructions
|
|
431
|
+
* project-name -s sessionId instructions
|
|
432
|
+
* project-name -in 30m instructions
|
|
433
|
+
* project-name -at 2024-01-01T10:00 instructions
|
|
434
|
+
*/
|
|
435
|
+
async function handleNewTask(chatId: number, input: string) {
|
|
436
|
+
if (!input) {
|
|
437
|
+
await send(chatId,
|
|
438
|
+
'Usage:\nproject: instructions\n\n' +
|
|
439
|
+
'Options:\n' +
|
|
440
|
+
' -s <sessionId> ā resume specific session\n' +
|
|
441
|
+
' -in 30m ā delay (e.g. 10m, 2h, 1d)\n' +
|
|
442
|
+
' -at 18:00 ā schedule at time\n\n' +
|
|
443
|
+
'Example:\nmy-app: Fix the login bug\nmy-app -s abc123 -in 1h: continue work'
|
|
444
|
+
);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Parse project name (before first space or colon)
|
|
449
|
+
const colonIdx = input.indexOf(':');
|
|
450
|
+
let projectPart: string;
|
|
451
|
+
let restPart: string;
|
|
452
|
+
|
|
453
|
+
if (colonIdx > 0 && colonIdx < 40) {
|
|
454
|
+
projectPart = input.slice(0, colonIdx).trim();
|
|
455
|
+
restPart = input.slice(colonIdx + 1).trim();
|
|
456
|
+
} else {
|
|
457
|
+
const spaceIdx = input.indexOf(' ');
|
|
458
|
+
if (spaceIdx < 0) {
|
|
459
|
+
await send(chatId, 'Please provide instructions after the project name.');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
projectPart = input.slice(0, spaceIdx).trim();
|
|
463
|
+
restPart = input.slice(spaceIdx + 1).trim();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const projects = scanProjects();
|
|
467
|
+
const project = projects.find(p => p.name === projectPart || p.name.toLowerCase() === projectPart.toLowerCase());
|
|
468
|
+
|
|
469
|
+
if (!project) {
|
|
470
|
+
const available = projects.slice(0, 10).map(p => ` ${p.name}`).join('\n');
|
|
471
|
+
await send(chatId, `Project not found: ${projectPart}\n\nAvailable:\n${available}`);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Parse flags
|
|
476
|
+
let sessionId: string | undefined;
|
|
477
|
+
let scheduledAt: string | undefined;
|
|
478
|
+
let tokens = restPart.split(/\s+/);
|
|
479
|
+
const promptTokens: string[] = [];
|
|
480
|
+
|
|
481
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
482
|
+
if (tokens[i] === '-s' && i + 1 < tokens.length) {
|
|
483
|
+
sessionId = tokens[++i];
|
|
484
|
+
} else if (tokens[i] === '-in' && i + 1 < tokens.length) {
|
|
485
|
+
scheduledAt = parseDelay(tokens[++i]);
|
|
486
|
+
} else if (tokens[i] === '-at' && i + 1 < tokens.length) {
|
|
487
|
+
scheduledAt = parseTimeAt(tokens[++i]);
|
|
488
|
+
} else {
|
|
489
|
+
promptTokens.push(tokens[i]);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const prompt = promptTokens.join(' ');
|
|
494
|
+
if (!prompt) {
|
|
495
|
+
await send(chatId, 'Please provide instructions.');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const task = createTask({
|
|
500
|
+
projectName: project.name,
|
|
501
|
+
projectPath: project.path,
|
|
502
|
+
prompt,
|
|
503
|
+
conversationId: sessionId,
|
|
504
|
+
scheduledAt,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
let statusLine = 'Status: queued';
|
|
508
|
+
if (scheduledAt) {
|
|
509
|
+
statusLine = `Scheduled: ${new Date(scheduledAt).toLocaleString()}`;
|
|
510
|
+
}
|
|
511
|
+
if (sessionId) {
|
|
512
|
+
statusLine += `\nSession: ${sessionId.slice(0, 12)}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const msgId = await send(chatId,
|
|
516
|
+
`š Task created: ${task.id}\n${task.projectName}: ${prompt}\n\n${statusLine}`
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
if (msgId) {
|
|
520
|
+
taskMessageMap.set(msgId, task.id);
|
|
521
|
+
taskChatMap.set(task.id, chatId);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function parseDelay(s: string): string | undefined {
|
|
526
|
+
const match = s.match(/^(\d+)(m|h|d)$/);
|
|
527
|
+
if (!match) return undefined;
|
|
528
|
+
const val = Number(match[1]);
|
|
529
|
+
const unit = match[2];
|
|
530
|
+
const ms = unit === 'm' ? val * 60_000 : unit === 'h' ? val * 3600_000 : val * 86400_000;
|
|
531
|
+
return new Date(Date.now() + ms).toISOString();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function parseTimeAt(s: string): string | undefined {
|
|
535
|
+
// Try HH:MM format (today)
|
|
536
|
+
const timeMatch = s.match(/^(\d{1,2}):(\d{2})$/);
|
|
537
|
+
if (timeMatch) {
|
|
538
|
+
const now = new Date();
|
|
539
|
+
now.setHours(Number(timeMatch[1]), Number(timeMatch[2]), 0, 0);
|
|
540
|
+
if (now.getTime() < Date.now()) now.setDate(now.getDate() + 1); // next day
|
|
541
|
+
return now.toISOString();
|
|
542
|
+
}
|
|
543
|
+
// Try ISO or date format
|
|
544
|
+
try {
|
|
545
|
+
const d = new Date(s);
|
|
546
|
+
if (!isNaN(d.getTime())) return d.toISOString();
|
|
547
|
+
} catch {}
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function handleFollowUp(chatId: number, taskId: string, message: string) {
|
|
552
|
+
const task = getTask(taskId);
|
|
553
|
+
if (!task) {
|
|
554
|
+
await send(chatId, 'Task not found.');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (task.status === 'running') {
|
|
559
|
+
await send(chatId, 'ā³ Task still running, wait for it to finish.');
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const newTask = createTask({
|
|
564
|
+
projectName: task.projectName,
|
|
565
|
+
projectPath: task.projectPath,
|
|
566
|
+
prompt: message,
|
|
567
|
+
conversationId: task.conversationId || undefined,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const msgId = await send(chatId,
|
|
571
|
+
`š Follow-up: ${newTask.id}\nContinuing ${task.projectName} session\n\n${message}`
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
if (msgId) {
|
|
575
|
+
taskMessageMap.set(msgId, newTask.id);
|
|
576
|
+
taskChatMap.set(newTask.id, chatId);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function sendProjectList(chatId: number) {
|
|
581
|
+
const projects = scanProjects();
|
|
582
|
+
const lines = projects.slice(0, 20).map(p =>
|
|
583
|
+
`${p.name}${p.language ? ` (${p.language})` : ''}`
|
|
584
|
+
);
|
|
585
|
+
await send(chatId, `š Projects\n\n${lines.join('\n')}\n\n${projects.length} total`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function handleCancel(chatId: number, taskId?: string) {
|
|
589
|
+
if (!taskId) { await send(chatId, 'Usage: /cancel <task-id>'); return; }
|
|
590
|
+
const ok = cancelTask(taskId);
|
|
591
|
+
await send(chatId, ok ? `š Task ${taskId} cancelled` : `Cannot cancel task ${taskId}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function handleRetry(chatId: number, taskId?: string) {
|
|
595
|
+
if (!taskId) { await send(chatId, 'Usage: /retry <task-id>'); return; }
|
|
596
|
+
const newTask = retryTask(taskId);
|
|
597
|
+
if (!newTask) {
|
|
598
|
+
await send(chatId, `Cannot retry task ${taskId}`);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const msgId = await send(chatId, `š Retrying as ${newTask.id}`);
|
|
602
|
+
if (msgId) {
|
|
603
|
+
taskMessageMap.set(msgId, newTask.id);
|
|
604
|
+
taskChatMap.set(newTask.id, chatId);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// āāā Watcher Commands āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
609
|
+
|
|
610
|
+
async function handleWatch(chatId: number, projectName?: string, sessionId?: string) {
|
|
611
|
+
if (!projectName) {
|
|
612
|
+
await send(chatId, 'Usage: /watch <project> [sessionId]\n\nMonitors a session and sends updates here.');
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const label = sessionId ? `${projectName}/${sessionId.slice(0, 8)}` : projectName;
|
|
616
|
+
const watcher = createWatcher({ projectName, sessionId, label });
|
|
617
|
+
await send(chatId, `š Watching: ${label}\nID: ${watcher.id}\nChecking every ${watcher.checkInterval}s`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function sendWatcherList(chatId: number) {
|
|
621
|
+
const all = listWatchers();
|
|
622
|
+
if (all.length === 0) {
|
|
623
|
+
await send(chatId, 'š No watchers.\n\nUse /watch <project> [sessionId] to add one.');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const lines = all.map((w, i) => {
|
|
628
|
+
const status = w.active ? 'ā' : 'ā';
|
|
629
|
+
const target = w.sessionId ? `${w.projectName}/${w.sessionId.slice(0, 8)}` : w.projectName;
|
|
630
|
+
return `${status} ${w.id} ā ${target} (${w.checkInterval}s)`;
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
await send(chatId, `š Watchers\n\n${lines.join('\n')}\n\nUse /unwatch <id> to remove`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function handleUnwatch(chatId: number, watcherId?: string) {
|
|
637
|
+
if (!watcherId) {
|
|
638
|
+
await send(chatId, 'Usage: /unwatch <watcher-id>');
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
deleteWatcher(watcherId);
|
|
642
|
+
await send(chatId, `š Watcher ${watcherId} removed`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// āāā Tunnel Commands āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
646
|
+
|
|
647
|
+
async function handleTunnel(chatId: number, action?: string, password?: string, userMsgId?: number) {
|
|
648
|
+
const settings = loadSettings();
|
|
649
|
+
if (String(chatId) !== settings.telegramChatId) {
|
|
650
|
+
await send(chatId, 'ā Unauthorized');
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// start/stop require password
|
|
655
|
+
if (action === 'start' || action === 'stop') {
|
|
656
|
+
if (!settings.telegramTunnelPassword) {
|
|
657
|
+
await send(chatId, 'ā ļø Set telegram tunnel password in Settings first.');
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (!password || password !== settings.telegramTunnelPassword) {
|
|
661
|
+
await send(chatId, `ā Password required\nUsage: /tunnel ${action} <password>`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Delete user's message containing password
|
|
665
|
+
if (userMsgId) deleteMessageLater(chatId, userMsgId, 0);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (action === 'start') {
|
|
669
|
+
const status = getTunnelStatus();
|
|
670
|
+
if (status.status === 'running' && status.url) {
|
|
671
|
+
await send(chatId, `š Tunnel already running:\n${status.url}`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
await send(chatId, 'š Starting tunnel...');
|
|
675
|
+
const result = await startTunnel();
|
|
676
|
+
if (result.url) {
|
|
677
|
+
await send(chatId, 'ā
Tunnel started:');
|
|
678
|
+
await sendHtml(chatId, `<a href="${result.url}">${result.url}</a>`);
|
|
679
|
+
} else {
|
|
680
|
+
await send(chatId, `ā Failed: ${result.error}`);
|
|
681
|
+
}
|
|
682
|
+
} else if (action === 'stop') {
|
|
683
|
+
stopTunnel();
|
|
684
|
+
await send(chatId, 'š Tunnel stopped');
|
|
685
|
+
} else {
|
|
686
|
+
// Status (no password needed)
|
|
687
|
+
const status = getTunnelStatus();
|
|
688
|
+
if (status.status === 'running' && status.url) {
|
|
689
|
+
await send(chatId, `š Tunnel running:\n${status.url}\n\n/tunnel stop <pw> ā stop tunnel`);
|
|
690
|
+
} else if (status.status === 'starting') {
|
|
691
|
+
await send(chatId, 'ā³ Tunnel is starting...');
|
|
692
|
+
} else {
|
|
693
|
+
await send(chatId, `š Tunnel is ${status.status}\n\n/tunnel start <pw> ā start tunnel`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function handleTunnelPassword(chatId: number, password?: string, userMsgId?: number) {
|
|
699
|
+
const settings = loadSettings();
|
|
700
|
+
if (String(chatId) !== settings.telegramChatId) {
|
|
701
|
+
await send(chatId, 'ā Unauthorized');
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (!settings.telegramTunnelPassword) {
|
|
706
|
+
await send(chatId, 'ā ļø Telegram tunnel password not configured.\nSet it in Settings ā Remote Access ā Telegram tunnel password');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (!password) {
|
|
711
|
+
await send(chatId, 'Usage: /tunnel_password <your-password>');
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Immediately delete user's message containing password
|
|
716
|
+
if (userMsgId) deleteMessageLater(chatId, userMsgId, 0);
|
|
717
|
+
|
|
718
|
+
if (password !== settings.telegramTunnelPassword) {
|
|
719
|
+
await send(chatId, 'ā Wrong password');
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const loginPassword = getPassword();
|
|
724
|
+
const status = getTunnelStatus();
|
|
725
|
+
// Send password as code block, auto-delete after 30s
|
|
726
|
+
const labelId = await send(chatId, 'š Login password (auto-deletes in 30s):');
|
|
727
|
+
const pwId = await sendHtml(chatId, `<code>${loginPassword}</code>`);
|
|
728
|
+
if (labelId) deleteMessageLater(chatId, labelId);
|
|
729
|
+
if (pwId) deleteMessageLater(chatId, pwId);
|
|
730
|
+
if (status.status === 'running' && status.url) {
|
|
731
|
+
const urlLabelId = await send(chatId, 'š URL:');
|
|
732
|
+
const urlId = await sendHtml(chatId, `<a href="${status.url}">${status.url}</a>`);
|
|
733
|
+
if (urlLabelId) deleteMessageLater(chatId, urlLabelId);
|
|
734
|
+
if (urlId) deleteMessageLater(chatId, urlId);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// āāā Real-time Streaming āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
739
|
+
|
|
740
|
+
function bufferLogEntry(taskId: string, chatId: number, entry: TaskLogEntry) {
|
|
741
|
+
taskChatMap.set(taskId, chatId);
|
|
742
|
+
|
|
743
|
+
let buf = logBuffers.get(taskId);
|
|
744
|
+
if (!buf) {
|
|
745
|
+
buf = { entries: [], timer: null };
|
|
746
|
+
logBuffers.set(taskId, buf);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
let line = '';
|
|
750
|
+
if (entry.subtype === 'tool_use') {
|
|
751
|
+
line = `š§ ${entry.tool || 'tool'}: ${entry.content.slice(0, 80)}`;
|
|
752
|
+
} else if (entry.subtype === 'text') {
|
|
753
|
+
line = entry.content.slice(0, 200);
|
|
754
|
+
} else if (entry.type === 'result') {
|
|
755
|
+
line = `ā
${entry.content.slice(0, 200)}`;
|
|
756
|
+
} else if (entry.subtype === 'error') {
|
|
757
|
+
line = `ā ${entry.content.slice(0, 200)}`;
|
|
758
|
+
}
|
|
759
|
+
if (!line) return;
|
|
760
|
+
|
|
761
|
+
buf.entries.push(line);
|
|
762
|
+
|
|
763
|
+
if (!buf.timer) {
|
|
764
|
+
buf.timer = setTimeout(() => flushLogBuffer(taskId, chatId), 3000);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function flushLogBuffer(taskId: string, chatId: number) {
|
|
769
|
+
const buf = logBuffers.get(taskId);
|
|
770
|
+
if (!buf || buf.entries.length === 0) return;
|
|
771
|
+
|
|
772
|
+
const text = buf.entries.join('\n');
|
|
773
|
+
buf.entries = [];
|
|
774
|
+
buf.timer = null;
|
|
775
|
+
|
|
776
|
+
await send(chatId, text);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function handleStatusChange(taskId: string, chatId: number, status: string) {
|
|
780
|
+
await flushLogBuffer(taskId, chatId);
|
|
781
|
+
|
|
782
|
+
const task = getTask(taskId);
|
|
783
|
+
if (!task) return;
|
|
784
|
+
|
|
785
|
+
const targetChat = taskChatMap.get(taskId) || chatId;
|
|
786
|
+
|
|
787
|
+
if (status === 'running') {
|
|
788
|
+
const msgId = await send(targetChat,
|
|
789
|
+
`š Started: ${taskId}\n${task.projectName}: ${task.prompt.slice(0, 100)}`
|
|
790
|
+
);
|
|
791
|
+
if (msgId) taskMessageMap.set(msgId, taskId);
|
|
792
|
+
} else if (status === 'done') {
|
|
793
|
+
const cost = task.costUSD != null ? `Cost: $${task.costUSD.toFixed(4)}\n` : '';
|
|
794
|
+
const result = task.resultSummary ? task.resultSummary.slice(0, 800) : '';
|
|
795
|
+
const msgId = await send(targetChat,
|
|
796
|
+
`ā
Done: ${taskId}\n${task.projectName}\n${cost}${result ? `\n${result}` : ''}\n\nš¬ Reply to continue`
|
|
797
|
+
);
|
|
798
|
+
if (msgId) taskMessageMap.set(msgId, taskId);
|
|
799
|
+
} else if (status === 'failed') {
|
|
800
|
+
const msgId = await send(targetChat,
|
|
801
|
+
`ā Failed: ${taskId}\n${task.error || 'Unknown error'}\n\n/retry ${taskId}`
|
|
802
|
+
);
|
|
803
|
+
if (msgId) taskMessageMap.set(msgId, taskId);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// āāā Telegram API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
808
|
+
|
|
809
|
+
async function send(chatId: number, text: string): Promise<number | null> {
|
|
810
|
+
const settings = loadSettings();
|
|
811
|
+
if (!settings.telegramBotToken) return null;
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const url = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`;
|
|
815
|
+
const res = await fetch(url, {
|
|
816
|
+
method: 'POST',
|
|
817
|
+
headers: { 'Content-Type': 'application/json' },
|
|
818
|
+
body: JSON.stringify({
|
|
819
|
+
chat_id: chatId,
|
|
820
|
+
text,
|
|
821
|
+
disable_web_page_preview: true,
|
|
822
|
+
}),
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const data = await res.json();
|
|
826
|
+
if (!data.ok) {
|
|
827
|
+
console.error('[telegram] Send error:', data.description);
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
return data.result?.message_id || null;
|
|
831
|
+
} catch (err) {
|
|
832
|
+
console.error('[telegram] Send failed:', err);
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Delete a message after a delay (seconds) */
|
|
838
|
+
function deleteMessageLater(chatId: number, messageId: number, delaySec: number = 30) {
|
|
839
|
+
setTimeout(async () => {
|
|
840
|
+
const settings = loadSettings();
|
|
841
|
+
if (!settings.telegramBotToken) return;
|
|
842
|
+
try {
|
|
843
|
+
await fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/deleteMessage`, {
|
|
844
|
+
method: 'POST',
|
|
845
|
+
headers: { 'Content-Type': 'application/json' },
|
|
846
|
+
body: JSON.stringify({ chat_id: chatId, message_id: messageId }),
|
|
847
|
+
});
|
|
848
|
+
} catch {}
|
|
849
|
+
}, delaySec * 1000);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Set bot command menu for quick access */
|
|
853
|
+
async function setBotCommands(token: string) {
|
|
854
|
+
try {
|
|
855
|
+
await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
|
|
856
|
+
method: 'POST',
|
|
857
|
+
headers: { 'Content-Type': 'application/json' },
|
|
858
|
+
body: JSON.stringify({
|
|
859
|
+
commands: [
|
|
860
|
+
{ command: 'tasks', description: 'List tasks' },
|
|
861
|
+
{ command: 'task', description: 'Create task: /task project prompt' },
|
|
862
|
+
{ command: 'sessions', description: 'Browse sessions' },
|
|
863
|
+
{ command: 'projects', description: 'List projects' },
|
|
864
|
+
{ command: 'tunnel', description: 'Tunnel status / start / stop' },
|
|
865
|
+
{ command: 'tunnel_password', description: 'Get login password' },
|
|
866
|
+
{ command: 'watch', description: 'Monitor session' },
|
|
867
|
+
{ command: 'watchers', description: 'List watchers' },
|
|
868
|
+
{ command: 'help', description: 'Show help' },
|
|
869
|
+
],
|
|
870
|
+
}),
|
|
871
|
+
});
|
|
872
|
+
} catch {}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function sendHtml(chatId: number, html: string): Promise<number | null> {
|
|
876
|
+
const settings = loadSettings();
|
|
877
|
+
if (!settings.telegramBotToken) return null;
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
const url = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`;
|
|
881
|
+
const res = await fetch(url, {
|
|
882
|
+
method: 'POST',
|
|
883
|
+
headers: { 'Content-Type': 'application/json' },
|
|
884
|
+
body: JSON.stringify({
|
|
885
|
+
chat_id: chatId,
|
|
886
|
+
text: html,
|
|
887
|
+
parse_mode: 'HTML',
|
|
888
|
+
disable_web_page_preview: true,
|
|
889
|
+
}),
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
const data = await res.json();
|
|
893
|
+
if (!data.ok) {
|
|
894
|
+
return send(chatId, html.replace(/<[^>]+>/g, ''));
|
|
895
|
+
}
|
|
896
|
+
return data.result?.message_id || null;
|
|
897
|
+
} catch {
|
|
898
|
+
return send(chatId, html.replace(/<[^>]+>/g, ''));
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function splitMessage(text: string, maxLen: number): string[] {
|
|
903
|
+
if (text.length <= maxLen) return [text];
|
|
904
|
+
const chunks: string[] = [];
|
|
905
|
+
while (text.length > 0) {
|
|
906
|
+
const cut = text.lastIndexOf('\n', maxLen);
|
|
907
|
+
const splitAt = cut > 0 ? cut : maxLen;
|
|
908
|
+
chunks.push(text.slice(0, splitAt));
|
|
909
|
+
text = text.slice(splitAt).trimStart();
|
|
910
|
+
}
|
|
911
|
+
return chunks;
|
|
912
|
+
}
|