arisa 2.0.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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * @module daemon/channels/telegram
3
+ * @role Telegram channel adapter using grammy.
4
+ * @responsibilities
5
+ * - Connect to Telegram Bot API
6
+ * - Receive text, voice, and photo messages
7
+ * - Download media from Telegram servers as buffers
8
+ * - Send text (HTML) and file messages back
9
+ * - Extract commands from text and forward to Core
10
+ * @dependencies grammy, shared/config
11
+ * @effects Network (Telegram API), spawns long-polling connection
12
+ * @contract Implements Channel interface
13
+ */
14
+
15
+ import { Bot, GrammyError, HttpError, InputFile } from "grammy";
16
+ import type { Channel, IncomingMessage } from "./base";
17
+ import { config } from "../../shared/config";
18
+ import { createLogger } from "../../shared/logger";
19
+
20
+ const log = createLogger("telegram");
21
+
22
+ export class TelegramChannel implements Channel {
23
+ name = "telegram";
24
+ private bot: Bot;
25
+ private handler: ((msg: IncomingMessage) => void) | null = null;
26
+
27
+ constructor() {
28
+ if (!config.telegramBotToken) {
29
+ throw new Error("TELEGRAM_BOT_TOKEN not configured");
30
+ }
31
+ this.bot = new Bot(config.telegramBotToken);
32
+
33
+ this.bot.catch((err) => {
34
+ const e = err.error;
35
+ if (e instanceof GrammyError) {
36
+ log.error(`Telegram API error: ${e.description}`);
37
+ } else if (e instanceof HttpError) {
38
+ log.error(`Telegram HTTP error: ${e}`);
39
+ } else {
40
+ log.error(`Telegram unknown error: ${e}`);
41
+ }
42
+ });
43
+ }
44
+
45
+ async connect(): Promise<void> {
46
+ // All text messages — commands are extracted and forwarded to Core
47
+ this.bot.on("message:text", async (ctx) => {
48
+ if (ctx.chat.type !== "private") return;
49
+ const text = ctx.message.text;
50
+ if (!text?.trim()) return;
51
+
52
+ // Extract command if message starts with /
53
+ let command: string | undefined;
54
+ if (text.startsWith("/")) {
55
+ const match = text.match(/^\/(\w+)/);
56
+ if (match) command = `/${match[1].toLowerCase()}`;
57
+ }
58
+
59
+ log.info(`${command ? `Cmd ${command}` : "Text"} from ${ctx.from!.first_name}: ${text.substring(0, 60)}`);
60
+ if (!command) await ctx.api.sendChatAction(ctx.chat.id, "typing");
61
+
62
+ // Capture reply_to_message if present
63
+ let replyTo: IncomingMessage["replyTo"];
64
+ if (ctx.message.reply_to_message) {
65
+ const reply = ctx.message.reply_to_message;
66
+ replyTo = {
67
+ messageId: reply.message_id,
68
+ text: "text" in reply ? reply.text : undefined,
69
+ sender: reply.from ? this.getSenderName({ from: reply.from }) : "Unknown",
70
+ timestamp: reply.date * 1000,
71
+ };
72
+ }
73
+
74
+ this.handler?.({
75
+ chatId: String(ctx.chat.id),
76
+ sender: this.getSenderName(ctx),
77
+ senderId: String(ctx.from!.id),
78
+ text,
79
+ command,
80
+ messageId: ctx.message.message_id,
81
+ timestamp: Date.now(),
82
+ replyTo,
83
+ });
84
+ });
85
+
86
+ // Voice messages
87
+ this.bot.on("message:voice", async (ctx) => {
88
+ if (ctx.chat.type !== "private") return;
89
+
90
+ const voice = ctx.message.voice;
91
+ log.info(`Voice from ${ctx.from!.first_name} (${voice.duration}s)`);
92
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
93
+
94
+ // Capture reply_to_message if present
95
+ let replyTo: IncomingMessage["replyTo"];
96
+ if (ctx.message.reply_to_message) {
97
+ const reply = ctx.message.reply_to_message;
98
+ replyTo = {
99
+ messageId: reply.message_id,
100
+ text: "text" in reply ? reply.text : undefined,
101
+ sender: reply.from ? this.getSenderName({ from: reply.from }) : "Unknown",
102
+ timestamp: reply.date * 1000,
103
+ };
104
+ }
105
+
106
+ try {
107
+ const file = await ctx.api.getFile(voice.file_id);
108
+ if (!file.file_path) {
109
+ await ctx.reply("No se pudo descargar el audio.");
110
+ return;
111
+ }
112
+ const buffer = await this.downloadFile(file.file_path);
113
+ this.handler?.({
114
+ chatId: String(ctx.chat.id),
115
+ sender: this.getSenderName(ctx),
116
+ senderId: String(ctx.from!.id),
117
+ audio: { base64: buffer.toString("base64"), filename: `voice_${Date.now()}.ogg` },
118
+ messageId: ctx.message.message_id,
119
+ timestamp: Date.now(),
120
+ replyTo,
121
+ });
122
+ } catch (error) {
123
+ log.error(`Voice download error: ${error}`);
124
+ await ctx.reply("Could not download the audio. Try again.");
125
+ }
126
+ });
127
+
128
+ // Photo messages
129
+ this.bot.on("message:photo", async (ctx) => {
130
+ if (ctx.chat.type !== "private") return;
131
+
132
+ const photos = ctx.message.photo;
133
+ const photo = photos[photos.length - 1];
134
+ const caption = ctx.message.caption || "";
135
+
136
+ log.info(`Photo from ${ctx.from!.first_name} (${photo.width}x${photo.height})`);
137
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
138
+
139
+ // Capture reply_to_message if present
140
+ let replyTo: IncomingMessage["replyTo"];
141
+ if (ctx.message.reply_to_message) {
142
+ const reply = ctx.message.reply_to_message;
143
+ replyTo = {
144
+ messageId: reply.message_id,
145
+ text: "text" in reply ? reply.text : undefined,
146
+ sender: reply.from ? this.getSenderName({ from: reply.from }) : "Unknown",
147
+ timestamp: reply.date * 1000,
148
+ };
149
+ }
150
+
151
+ try {
152
+ const file = await ctx.api.getFile(photo.file_id);
153
+ if (!file.file_path) {
154
+ await ctx.reply("No se pudo descargar la imagen.");
155
+ return;
156
+ }
157
+ const buffer = await this.downloadFile(file.file_path);
158
+ this.handler?.({
159
+ chatId: String(ctx.chat.id),
160
+ sender: this.getSenderName(ctx),
161
+ senderId: String(ctx.from!.id),
162
+ image: { base64: buffer.toString("base64"), caption: caption || undefined },
163
+ messageId: ctx.message.message_id,
164
+ timestamp: Date.now(),
165
+ replyTo,
166
+ });
167
+ } catch (error) {
168
+ log.error(`Photo download error: ${error}`);
169
+ await ctx.reply("Could not download the image. Try again.");
170
+ }
171
+ });
172
+
173
+ // Document messages (PDFs, files, etc.)
174
+ this.bot.on("message:document", async (ctx) => {
175
+ if (ctx.chat.type !== "private") return;
176
+
177
+ const doc = ctx.message.document;
178
+ const caption = ctx.message.caption || "";
179
+
180
+ log.info(`Document from ${ctx.from!.first_name}: ${doc.file_name} (${doc.mime_type})`);
181
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
182
+
183
+ // Capture reply_to_message if present
184
+ let replyTo: IncomingMessage["replyTo"];
185
+ if (ctx.message.reply_to_message) {
186
+ const reply = ctx.message.reply_to_message;
187
+ replyTo = {
188
+ messageId: reply.message_id,
189
+ text: "text" in reply ? reply.text : undefined,
190
+ sender: reply.from ? this.getSenderName({ from: reply.from }) : "Unknown",
191
+ timestamp: reply.date * 1000,
192
+ };
193
+ }
194
+
195
+ try {
196
+ const file = await ctx.api.getFile(doc.file_id);
197
+ if (!file.file_path) {
198
+ await ctx.reply("Could not download the document.");
199
+ return;
200
+ }
201
+ const buffer = await this.downloadFile(file.file_path);
202
+ this.handler?.({
203
+ chatId: String(ctx.chat.id),
204
+ sender: this.getSenderName(ctx),
205
+ senderId: String(ctx.from!.id),
206
+ document: {
207
+ base64: buffer.toString("base64"),
208
+ filename: doc.file_name || `file_${Date.now()}`,
209
+ mimeType: doc.mime_type || "application/octet-stream",
210
+ caption: caption || undefined,
211
+ },
212
+ messageId: ctx.message.message_id,
213
+ timestamp: Date.now(),
214
+ replyTo,
215
+ });
216
+ } catch (error) {
217
+ log.error(`Document download error: ${error}`);
218
+ await ctx.reply("Could not download the document. Try again.");
219
+ }
220
+ });
221
+
222
+ await this.bot.start({
223
+ onStart: (botInfo) => {
224
+ log.info(`Telegram bot connected as @${botInfo.username}`);
225
+ },
226
+ });
227
+ }
228
+
229
+ onMessage(handler: (msg: IncomingMessage) => void): void {
230
+ this.handler = handler;
231
+ }
232
+
233
+ async send(chatId: string, text: string, parseMode: "HTML" | "plain" = "HTML"): Promise<number | undefined> {
234
+ try {
235
+ if (parseMode === "HTML") {
236
+ try {
237
+ const sent = await this.bot.api.sendMessage(chatId, text, { parse_mode: "HTML" });
238
+ return sent.message_id;
239
+ } catch (error) {
240
+ if (error instanceof GrammyError && error.description?.includes("can't parse entities")) {
241
+ log.warn("HTML parse failed, falling back to plain text");
242
+ } else {
243
+ throw error;
244
+ }
245
+ }
246
+ }
247
+ // Strip HTML tags and unescape entities for plain text
248
+ const plain = text
249
+ .replace(/<[^>]+>/g, "")
250
+ .replace(/&amp;/g, "&")
251
+ .replace(/&lt;/g, "<")
252
+ .replace(/&gt;/g, ">")
253
+ .replace(/&quot;/g, '"');
254
+ const sent = await this.bot.api.sendMessage(chatId, plain);
255
+ return sent.message_id;
256
+ } catch (error) {
257
+ log.error(`Send error to ${chatId}: ${error}`);
258
+ throw error;
259
+ }
260
+ }
261
+
262
+ async sendFile(chatId: string, filePath: string): Promise<void> {
263
+ try {
264
+ await this.bot.api.sendDocument(chatId, new InputFile(filePath));
265
+ log.info(`Sent file to ${chatId}: ${filePath}`);
266
+ } catch (error) {
267
+ log.error(`File send error: ${error}`);
268
+ throw error;
269
+ }
270
+ }
271
+
272
+ async sendAudio(chatId: string, filePath: string): Promise<void> {
273
+ try {
274
+ await this.bot.api.sendVoice(chatId, new InputFile(filePath));
275
+ log.info(`Sent audio to ${chatId}: ${filePath}`);
276
+ } catch (error) {
277
+ log.error(`Audio send error: ${error}`);
278
+ throw error;
279
+ }
280
+ }
281
+
282
+ async sendTyping(chatId: string): Promise<void> {
283
+ try {
284
+ await this.bot.api.sendChatAction(chatId, "typing");
285
+ } catch {
286
+ // Non-critical — ignore
287
+ }
288
+ }
289
+
290
+ private getSenderName(ctx: { from?: { first_name: string; last_name?: string; username?: string; id: number } }): string {
291
+ if (!ctx.from) return "Unknown";
292
+ return (
293
+ ctx.from.first_name + (ctx.from.last_name ? " " + ctx.from.last_name : "") ||
294
+ ctx.from.username ||
295
+ String(ctx.from.id)
296
+ );
297
+ }
298
+
299
+ private async downloadFile(filePath: string): Promise<Buffer> {
300
+ const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
301
+ const response = await fetch(url);
302
+ if (!response.ok) throw new Error(`Download failed: ${response.status}`);
303
+ const arrayBuffer = await response.arrayBuffer();
304
+ return Buffer.from(arrayBuffer);
305
+ }
306
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module daemon/fallback
3
+ * @role Direct AI CLI invocation when Core is down.
4
+ * @responsibilities
5
+ * - Call claude/codex CLI directly as emergency fallback
6
+ * - Include Core error context so the model can help diagnose
7
+ * @dependencies shared/config
8
+ * @effects Spawns AI CLI process
9
+ * @contract fallbackClaude(message, coreError?) => Promise<string>
10
+ */
11
+
12
+ import { config } from "../shared/config";
13
+ import { createLogger } from "../shared/logger";
14
+ import { getAgentCliLabel, runWithCliFallback } from "./agent-cli";
15
+
16
+ const log = createLogger("daemon");
17
+
18
+ export async function fallbackClaude(message: string, coreError?: string): Promise<string> {
19
+ const systemContext = coreError
20
+ ? `[System: Core process is down. Error: ${coreError}. You are running in fallback mode from Daemon. The user's project is at ${config.projectDir}. Respond to the user normally. If they ask about the error, explain what you see.]\n\n`
21
+ : `[System: Core process is down. You are running in fallback mode from Daemon. The user's project is at ${config.projectDir}. Respond to the user normally.]\n\n`;
22
+
23
+ const prompt = systemContext + message;
24
+
25
+ try {
26
+ const outcome = await runWithCliFallback(prompt, config.claudeTimeout);
27
+ const result = outcome.result;
28
+
29
+ if (!result) {
30
+ if (outcome.attempted.length === 0) {
31
+ return "[Fallback mode] Neither Claude nor Codex CLI is available. Core is down and fallback is unavailable.";
32
+ }
33
+ log.error(`Fallback failed: ${outcome.failures.join(" | ").slice(0, 500)}`);
34
+ return "[Fallback mode] Claude and Codex fallback both failed. Core is down and fallback is unavailable. Please check server logs.";
35
+ }
36
+
37
+ const cli = getAgentCliLabel(result.cli);
38
+ if (result.partial) {
39
+ log.warn(`Fallback ${cli} returned output but exited with code ${result.exitCode}`);
40
+ } else {
41
+ log.warn(`Using fallback ${cli} CLI`);
42
+ }
43
+
44
+ return result.output || `[Fallback mode] Empty response from ${cli} CLI.`;
45
+ } catch (error) {
46
+ log.error(`Fallback CLI error: ${error}`);
47
+ return "[Fallback mode] Could not reach fallback CLI. Core is down and fallback is unavailable. Please check server logs.";
48
+ }
49
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @module daemon/index
3
+ * @role Entry point for the Daemon process.
4
+ * @responsibilities
5
+ * - Run interactive setup if config is missing
6
+ * - Start the Telegram channel adapter
7
+ * - Spawn Core process with --watch
8
+ * - Run HTTP server on :7778 for Core → Daemon pushes (scheduler)
9
+ * - Route incoming messages to Core via bridge
10
+ * - Route Core responses back to channel
11
+ * @dependencies All daemon/* modules, shared/*
12
+ * @effects Network (Telegram, HTTP servers), spawns Core process
13
+ */
14
+
15
+ // Setup runs first — no config dependency, writes .env if needed
16
+ import { runSetup } from "./setup";
17
+ const ready = await runSetup();
18
+ if (!ready) process.exit(1);
19
+
20
+ // Dynamic imports so config loads AFTER setup has written .env
21
+ const { config } = await import("../shared/config");
22
+
23
+ // Initialize encrypted secrets
24
+ await config.secrets.initialize();
25
+ const { createLogger } = await import("../shared/logger");
26
+ const { serveWithRetry, claimProcess, releaseProcess } = await import("../shared/ports");
27
+ const { TelegramChannel } = await import("./channels/telegram");
28
+ const { sendToCore } = await import("./bridge");
29
+ const { startCore, stopCore, setLifecycleNotify } = await import("./lifecycle");
30
+ const { setAutoFixNotify } = await import("./autofix");
31
+ const { chunkMessage, markdownToTelegramHtml } = await import("../core/format");
32
+ const { saveMessageRecord } = await import("../shared/db");
33
+
34
+ const log = createLogger("daemon");
35
+
36
+ // --- Claim process: kill previous daemon, write our PID ---
37
+ claimProcess("daemon");
38
+
39
+ // --- Track known chatIds in memory (no deepbase dependency) ---
40
+ const knownChatIds = new Set<string>();
41
+
42
+ // Pre-seed from DB (best-effort — won't crash if DB is corrupt)
43
+ try {
44
+ const { getAuthorizedUsers } = await import("../shared/db");
45
+ const chatIds = await getAuthorizedUsers();
46
+ for (const id of chatIds) knownChatIds.add(id);
47
+ } catch {
48
+ log.warn("Could not pre-load authorized chatIds (DB may be corrupt)");
49
+ }
50
+
51
+ // --- Channel setup ---
52
+ const telegram = new TelegramChannel();
53
+
54
+ // --- Wire up notifications (lifecycle + autofix → Telegram) ---
55
+ const sendToAllChats = async (text: string) => {
56
+ for (const chatId of knownChatIds) {
57
+ await telegram.send(chatId, text).catch(() => {});
58
+ }
59
+ };
60
+
61
+ setLifecycleNotify(sendToAllChats);
62
+ setAutoFixNotify(sendToAllChats);
63
+
64
+ telegram.onMessage(async (msg) => {
65
+ knownChatIds.add(msg.chatId);
66
+ // Keep typing indicator alive while Core processes (expires every ~5s)
67
+ const typingInterval = setInterval(() => telegram.sendTyping(msg.chatId), 4000);
68
+
69
+ try {
70
+ const response = await sendToCore(msg, async (statusText) => {
71
+ try {
72
+ await telegram.send(msg.chatId, statusText);
73
+ } catch (e) {
74
+ log.error(`Failed to send status message: ${e}`);
75
+ }
76
+ });
77
+ clearInterval(typingInterval);
78
+
79
+ const raw = response.text || "";
80
+ const messageParts = raw.split(/\n---CHUNK---\n/g);
81
+ let sentText = false;
82
+
83
+ // Send audio first if present (voice messages should arrive before text)
84
+ if (response.audio) {
85
+ try {
86
+ await telegram.sendAudio(msg.chatId, response.audio);
87
+ } catch (error) {
88
+ log.error(`Audio send failed: ${error}`);
89
+ }
90
+ }
91
+
92
+ // Convert markdown to HTML first, then chunk the HTML
93
+ // (chunking must happen after HTML conversion so tag-aware splitting works)
94
+ for (const part of messageParts) {
95
+ if (!part.trim()) continue;
96
+ const html = markdownToTelegramHtml(part);
97
+ const chunks = chunkMessage(html);
98
+
99
+ log.info(`Format | rawChars: ${part.length} | htmlChars: ${html.length} | chunks: ${chunks.length}`);
100
+ log.debug(`Format raw >>>>\n${part}\n<<<<`);
101
+ log.debug(`Format html >>>>\n${html}\n<<<<`);
102
+
103
+ for (const chunk of chunks) {
104
+ log.debug(`Sending chunk (${chunk.length} chars) >>>>\n${chunk}\n<<<<`);
105
+ const sentId = await telegram.send(msg.chatId, chunk);
106
+ if (sentId) {
107
+ saveMessageRecord({
108
+ id: `${msg.chatId}_${sentId}`,
109
+ chatId: msg.chatId,
110
+ messageId: sentId,
111
+ direction: "out",
112
+ sender: "Arisa",
113
+ timestamp: Date.now(),
114
+ text: chunk,
115
+ }).catch((e) => log.error(`Failed to save outgoing message record: ${e}`));
116
+ }
117
+ }
118
+ sentText = true;
119
+ }
120
+
121
+ if (response.files) {
122
+ for (const filePath of response.files) {
123
+ await telegram.sendFile(msg.chatId, filePath);
124
+ }
125
+ }
126
+
127
+ // If neither text nor audio was sent, don't leave the user hanging
128
+ if (!sentText && !response.audio) {
129
+ log.warn("Empty response from Core — no text or audio to send");
130
+ }
131
+ } catch (error) {
132
+ clearInterval(typingInterval);
133
+ const errMsg = error instanceof Error ? error.message : String(error);
134
+ log.error(`Failed to process message from ${msg.sender}: ${errMsg}`);
135
+ try {
136
+ const summary = errMsg.length > 200 ? errMsg.slice(0, 200) + "..." : errMsg;
137
+ await telegram.send(msg.chatId, `Error: ${summary}`, "plain");
138
+ } catch {
139
+ log.error("Failed to send error message back to user");
140
+ }
141
+ }
142
+ });
143
+
144
+ // --- HTTP server for Core → Daemon pushes (scheduler) ---
145
+ const pushServer = await serveWithRetry({
146
+ port: config.daemonPort,
147
+ async fetch(req) {
148
+ const url = new URL(req.url);
149
+
150
+ if (url.pathname === "/send" && req.method === "POST") {
151
+ try {
152
+ const body = await req.json() as { chatId: string; text: string; files?: string[] };
153
+ if (!body.chatId || !body.text) {
154
+ return Response.json({ error: "Missing chatId or text" }, { status: 400 });
155
+ }
156
+
157
+ const html = markdownToTelegramHtml(body.text);
158
+ const chunks = chunkMessage(html);
159
+ for (const chunk of chunks) {
160
+ const sentId = await telegram.send(body.chatId, chunk);
161
+ if (sentId) {
162
+ saveMessageRecord({
163
+ id: `${body.chatId}_${sentId}`,
164
+ chatId: body.chatId,
165
+ messageId: sentId,
166
+ direction: "out",
167
+ sender: "Arisa",
168
+ timestamp: Date.now(),
169
+ text: chunk,
170
+ }).catch((e) => log.error(`Failed to save outgoing message record: ${e}`));
171
+ }
172
+ }
173
+
174
+ if (body.files) {
175
+ for (const filePath of body.files) {
176
+ await telegram.sendFile(body.chatId, filePath);
177
+ }
178
+ }
179
+
180
+ return Response.json({ ok: true });
181
+ } catch (error) {
182
+ log.error(`Push send error: ${error}`);
183
+ return Response.json({ error: "Send failed" }, { status: 500 });
184
+ }
185
+ }
186
+
187
+ return Response.json({ error: "Not found" }, { status: 404 });
188
+ },
189
+ });
190
+
191
+ log.info(`Daemon push server listening on port ${config.daemonPort}`);
192
+
193
+ // --- Start Core process ---
194
+ startCore();
195
+
196
+ // --- Connect Telegram ---
197
+ telegram.connect().catch((error) => {
198
+ log.error(`Telegram connection failed: ${error}`);
199
+ process.exit(1);
200
+ });
201
+
202
+ // --- Graceful shutdown ---
203
+ function shutdown() {
204
+ log.info("Shutting down Daemon...");
205
+ stopCore();
206
+ releaseProcess("daemon");
207
+ process.exit(0);
208
+ }
209
+
210
+ process.on("SIGINT", shutdown);
211
+ process.on("SIGTERM", shutdown);
212
+
213
+ log.info("Daemon started");