arisa 2.3.55 → 3.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.
- package/AGENTS.md +102 -0
- package/README.md +120 -165
- package/cli/openai-transcribe/index.js +51 -0
- package/cli/openai-transcribe/package.json +6 -0
- package/cli/openai-transcribe/tool.manifest.json +15 -0
- package/cli/openai-tts/index.js +58 -0
- package/cli/openai-tts/package.json +6 -0
- package/cli/openai-tts/tool.manifest.json +20 -0
- package/cli/web-browser/index.js +146 -0
- package/cli/web-browser/package.json +6 -0
- package/cli/web-browser/tool.manifest.json +8 -0
- package/package.json +26 -44
- package/src/core/agent/agent-manager.js +218 -0
- package/src/core/artifacts/artifact-store.js +102 -0
- package/src/core/config/config-store.js +20 -0
- package/src/core/tools/tool-registry.js +117 -0
- package/src/index.js +27 -0
- package/src/runtime/bootstrap.js +213 -0
- package/src/runtime/create-app.js +22 -0
- package/src/transport/telegram/auth.js +13 -0
- package/src/transport/telegram/bot.js +214 -0
- package/src/transport/telegram/media.js +75 -0
- package/CLAUDE.md +0 -191
- package/SOUL.md +0 -36
- package/bin/arisa.js +0 -644
- package/scripts/dump-commands.ts +0 -26
- package/scripts/test-secrets.ts +0 -22
- package/src/core/attachments.ts +0 -104
- package/src/core/auth.ts +0 -58
- package/src/core/context.ts +0 -30
- package/src/core/file-detector.ts +0 -39
- package/src/core/format.ts +0 -159
- package/src/core/index.ts +0 -456
- package/src/core/intent.ts +0 -119
- package/src/core/media.ts +0 -144
- package/src/core/onboarding.ts +0 -102
- package/src/core/processor.ts +0 -305
- package/src/core/router.ts +0 -64
- package/src/core/scheduler.ts +0 -193
- package/src/daemon/agent-cli.ts +0 -130
- package/src/daemon/auto-install.ts +0 -158
- package/src/daemon/autofix.ts +0 -116
- package/src/daemon/bridge.ts +0 -166
- package/src/daemon/channels/base.ts +0 -10
- package/src/daemon/channels/telegram.ts +0 -306
- package/src/daemon/claude-login.ts +0 -218
- package/src/daemon/codex-login.ts +0 -172
- package/src/daemon/fallback.ts +0 -73
- package/src/daemon/index.ts +0 -272
- package/src/daemon/lifecycle.ts +0 -313
- package/src/daemon/setup.ts +0 -329
- package/src/shared/ai-cli.ts +0 -165
- package/src/shared/config.ts +0 -137
- package/src/shared/db.ts +0 -304
- package/src/shared/deepbase-secure.ts +0 -39
- package/src/shared/ink-shim.js +0 -14
- package/src/shared/logger.ts +0 -42
- package/src/shared/paths.ts +0 -90
- package/src/shared/ports.ts +0 -120
- package/src/shared/secrets.ts +0 -136
- package/src/shared/types.ts +0 -103
- package/tsconfig.json +0 -19
package/src/daemon/bridge.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module daemon/bridge
|
|
3
|
-
* @role HTTP client from Daemon to Core with smart fallback to local AI CLI.
|
|
4
|
-
* @responsibilities
|
|
5
|
-
* - POST messages to Core via Unix socket
|
|
6
|
-
* - Respect Core lifecycle state (starting/up/down)
|
|
7
|
-
* - Wait for Core during startup, fallback only when truly down
|
|
8
|
-
* - Serialize fallback calls (one CLI process at a time)
|
|
9
|
-
* @dependencies shared/config, shared/types, daemon/fallback, daemon/lifecycle
|
|
10
|
-
* @effects Network (HTTP to Core), may spawn AI CLI process
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { config } from "../shared/config";
|
|
14
|
-
import { createLogger } from "../shared/logger";
|
|
15
|
-
import type { IncomingMessage, CoreResponse } from "../shared/types";
|
|
16
|
-
import { fallbackClaude } from "./fallback";
|
|
17
|
-
import { getCoreState, getCoreError, waitForCoreReady } from "./lifecycle";
|
|
18
|
-
|
|
19
|
-
const log = createLogger("daemon");
|
|
20
|
-
|
|
21
|
-
const CORE_URL = "http://localhost";
|
|
22
|
-
const STARTUP_WAIT_MS = 15_000;
|
|
23
|
-
const RETRY_DELAY = 3000;
|
|
24
|
-
|
|
25
|
-
type StatusCallback = (text: string) => Promise<void>;
|
|
26
|
-
|
|
27
|
-
// Serialize fallback calls — only one fallback CLI process at a time
|
|
28
|
-
let fallbackQueue: Promise<string> = Promise.resolve("");
|
|
29
|
-
|
|
30
|
-
export async function sendToCore(
|
|
31
|
-
message: IncomingMessage,
|
|
32
|
-
onStatus?: StatusCallback,
|
|
33
|
-
): Promise<CoreResponse> {
|
|
34
|
-
const state = getCoreState();
|
|
35
|
-
|
|
36
|
-
if (state === "starting") {
|
|
37
|
-
return await handleStarting(message, onStatus);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (state === "up") {
|
|
41
|
-
return await handleUp(message, onStatus);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// state === "down" — go straight to fallback
|
|
45
|
-
log.warn("Core is down, using fallback");
|
|
46
|
-
return await runFallback(message, onStatus);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Core is starting — wait for it, then send.
|
|
51
|
-
*/
|
|
52
|
-
async function handleStarting(
|
|
53
|
-
message: IncomingMessage,
|
|
54
|
-
onStatus?: StatusCallback,
|
|
55
|
-
): Promise<CoreResponse> {
|
|
56
|
-
log.info("Core is starting, waiting for it to be ready...");
|
|
57
|
-
await onStatus?.("Core starting, please wait...");
|
|
58
|
-
|
|
59
|
-
const ready = await waitForCoreReady(STARTUP_WAIT_MS);
|
|
60
|
-
|
|
61
|
-
if (ready) {
|
|
62
|
-
try {
|
|
63
|
-
return await postToCore(message);
|
|
64
|
-
} catch {
|
|
65
|
-
log.warn("Core ready but request failed, retrying...");
|
|
66
|
-
await sleep(RETRY_DELAY);
|
|
67
|
-
try {
|
|
68
|
-
return await postToCore(message);
|
|
69
|
-
} catch {
|
|
70
|
-
// Fall through to fallback
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
log.warn("Core didn't start in time, using fallback");
|
|
76
|
-
return await runFallback(message, onStatus);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Core is up — normal path with one retry.
|
|
81
|
-
*/
|
|
82
|
-
async function handleUp(
|
|
83
|
-
message: IncomingMessage,
|
|
84
|
-
onStatus?: StatusCallback,
|
|
85
|
-
): Promise<CoreResponse> {
|
|
86
|
-
try {
|
|
87
|
-
return await postToCore(message);
|
|
88
|
-
} catch {
|
|
89
|
-
// First failure
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
log.warn("Core unreachable, retrying in 3s...");
|
|
93
|
-
await onStatus?.("Core not responding, retrying...");
|
|
94
|
-
await sleep(RETRY_DELAY);
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
return await postToCore(message);
|
|
98
|
-
} catch {
|
|
99
|
-
// Still down
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
log.warn("Core still unreachable after retry, using fallback");
|
|
103
|
-
return await runFallback(message, onStatus);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Fallback: call local CLI directly (Claude -> Codex). Serialized so only one runs at a time.
|
|
108
|
-
*/
|
|
109
|
-
async function runFallback(
|
|
110
|
-
message: IncomingMessage,
|
|
111
|
-
onStatus?: StatusCallback,
|
|
112
|
-
): Promise<CoreResponse> {
|
|
113
|
-
const coreError = getCoreError();
|
|
114
|
-
|
|
115
|
-
if (coreError) {
|
|
116
|
-
const preview = coreError.length > 300 ? coreError.slice(-300) : coreError;
|
|
117
|
-
await onStatus?.(`Core is down. Error:\n<pre>${escapeHtml(preview)}</pre>\nFalling back to direct CLI (Claude/Codex)...`);
|
|
118
|
-
} else {
|
|
119
|
-
await onStatus?.("Core is down. Falling back to direct CLI (Claude/Codex)...");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const text = message.text || "[non-text message — media not available in fallback mode]";
|
|
123
|
-
|
|
124
|
-
// Chain onto the queue so only one fallback CLI runs at a time
|
|
125
|
-
const result = fallbackQueue.then(() => fallbackClaude(text, coreError ?? undefined));
|
|
126
|
-
fallbackQueue = result.catch(() => "");
|
|
127
|
-
|
|
128
|
-
const response = await result;
|
|
129
|
-
return { text: response };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async function postToCore(message: IncomingMessage): Promise<CoreResponse> {
|
|
133
|
-
const response = await fetch(`${CORE_URL}/message`, {
|
|
134
|
-
method: "POST",
|
|
135
|
-
headers: { "Content-Type": "application/json" },
|
|
136
|
-
body: JSON.stringify({ message }),
|
|
137
|
-
signal: AbortSignal.timeout(config.claudeTimeout + 5000),
|
|
138
|
-
unix: config.coreSocket,
|
|
139
|
-
} as any);
|
|
140
|
-
|
|
141
|
-
if (!response.ok) {
|
|
142
|
-
throw new Error(`Core returned ${response.status}`);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return (await response.json()) as CoreResponse;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function escapeHtml(s: string): string {
|
|
149
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function sleep(ms: number): Promise<void> {
|
|
153
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function isCoreHealthy(): Promise<boolean> {
|
|
157
|
-
try {
|
|
158
|
-
const response = await fetch(`${CORE_URL}/health`, {
|
|
159
|
-
signal: AbortSignal.timeout(2000),
|
|
160
|
-
unix: config.coreSocket,
|
|
161
|
-
} as any);
|
|
162
|
-
return response.ok;
|
|
163
|
-
} catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module daemon/channels/base
|
|
3
|
-
* @role Re-export the Channel interface from shared types.
|
|
4
|
-
* @responsibilities
|
|
5
|
-
* - Provide the Channel contract that all adapters must implement
|
|
6
|
-
* @dependencies shared/types
|
|
7
|
-
* @effects None
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export type { Channel, IncomingMessage } from "../../shared/types";
|
|
@@ -1,306 +0,0 @@
|
|
|
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(/&/g, "&")
|
|
251
|
-
.replace(/</g, "<")
|
|
252
|
-
.replace(/>/g, ">")
|
|
253
|
-
.replace(/"/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
|
-
}
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module daemon/claude-login
|
|
3
|
-
* @role Trigger Claude setup-token (OAuth) flow from Daemon when auth errors are detected.
|
|
4
|
-
* @responsibilities
|
|
5
|
-
* - Detect Claude auth-required signals in Core responses
|
|
6
|
-
* - Run `claude setup-token` with piped I/O
|
|
7
|
-
* - Parse OAuth URL from output, send to pending Telegram chats
|
|
8
|
-
* - Accept OAuth code from user message and pipe it to the waiting process stdin
|
|
9
|
-
* @effects Spawns claude CLI process, writes to daemon logs/terminal
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { config } from "../shared/config";
|
|
13
|
-
import { createLogger } from "../shared/logger";
|
|
14
|
-
import { buildBunWrappedAgentCliCommand } from "../shared/ai-cli";
|
|
15
|
-
|
|
16
|
-
const log = createLogger("daemon");
|
|
17
|
-
|
|
18
|
-
const AUTH_HINT_PATTERNS = [
|
|
19
|
-
/not logged in/i,
|
|
20
|
-
/please run \/login/i,
|
|
21
|
-
/invalid.*api.?key/i,
|
|
22
|
-
/authentication.*failed/i,
|
|
23
|
-
/failed to authenticat/i,
|
|
24
|
-
/not authenticated/i,
|
|
25
|
-
/authentication_error/i,
|
|
26
|
-
/invalid.*bearer.*token/i,
|
|
27
|
-
/ANTHROPIC_API_KEY/,
|
|
28
|
-
/api key not found/i,
|
|
29
|
-
/invalid x-api-key/i,
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
const RETRY_COOLDOWN_MS = 30_000;
|
|
33
|
-
|
|
34
|
-
let loginInProgress = false;
|
|
35
|
-
let lastLoginAttemptAt = 0;
|
|
36
|
-
const pendingChatIds = new Set<string>();
|
|
37
|
-
|
|
38
|
-
// The running setup-token process, so we can pipe the code to stdin
|
|
39
|
-
let pendingProc: ReturnType<typeof Bun.spawn> | null = null;
|
|
40
|
-
let urlSent = false;
|
|
41
|
-
|
|
42
|
-
type NotifyFn = (chatId: string, text: string) => Promise<void>;
|
|
43
|
-
let notifyFn: NotifyFn | null = null;
|
|
44
|
-
|
|
45
|
-
export function setClaudeLoginNotify(fn: NotifyFn) {
|
|
46
|
-
notifyFn = fn;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function needsClaudeLogin(text: string): boolean {
|
|
50
|
-
return AUTH_HINT_PATTERNS.some((pattern) => pattern.test(text));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function maybeStartClaudeSetupToken(rawCoreText: string, chatId?: string): void {
|
|
54
|
-
if (!rawCoreText || !needsClaudeLogin(rawCoreText)) return;
|
|
55
|
-
if (chatId) pendingChatIds.add(chatId);
|
|
56
|
-
|
|
57
|
-
if (loginInProgress) {
|
|
58
|
-
log.info("Claude setup-token already in progress; skipping duplicate trigger");
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const now = Date.now();
|
|
63
|
-
if (now - lastLoginAttemptAt < RETRY_COOLDOWN_MS) {
|
|
64
|
-
log.info("Claude setup-token trigger ignored (cooldown active)");
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
lastLoginAttemptAt = now;
|
|
69
|
-
loginInProgress = true;
|
|
70
|
-
void runClaudeSetupToken().finally(() => {
|
|
71
|
-
loginInProgress = false;
|
|
72
|
-
pendingProc = null;
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Start Claude setup-token proactively (e.g. during onboarding).
|
|
78
|
-
*/
|
|
79
|
-
export function startClaudeSetupToken(chatId: string): void {
|
|
80
|
-
pendingChatIds.add(chatId);
|
|
81
|
-
|
|
82
|
-
if (loginInProgress) {
|
|
83
|
-
log.info("Claude setup-token already in progress");
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
loginInProgress = true;
|
|
88
|
-
lastLoginAttemptAt = Date.now();
|
|
89
|
-
void runClaudeSetupToken().finally(() => {
|
|
90
|
-
loginInProgress = false;
|
|
91
|
-
pendingProc = null;
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Check if we're waiting for an OAuth code from this chat.
|
|
97
|
-
* If the message looks like a code, pipe it to the waiting setup-token process.
|
|
98
|
-
* Returns true if the message was consumed as a code.
|
|
99
|
-
*/
|
|
100
|
-
export function maybeFeedClaudeCode(chatId: string, text: string): boolean {
|
|
101
|
-
if (!pendingProc || !pendingChatIds.has(chatId)) return false;
|
|
102
|
-
|
|
103
|
-
const trimmed = text.trim();
|
|
104
|
-
// OAuth codes are typically short alphanumeric strings or URL params
|
|
105
|
-
// Reject obvious non-codes (long messages, commands, etc.)
|
|
106
|
-
if (trimmed.length > 200 || trimmed.startsWith("/") || trimmed.includes(" ")) return false;
|
|
107
|
-
|
|
108
|
-
log.info("Feeding OAuth code to claude setup-token process");
|
|
109
|
-
try {
|
|
110
|
-
const writer = pendingProc.stdin as WritableStream<Uint8Array>;
|
|
111
|
-
const w = writer.getWriter();
|
|
112
|
-
void w.write(new TextEncoder().encode(trimmed + "\n")).then(() => w.releaseLock());
|
|
113
|
-
} catch (e) {
|
|
114
|
-
log.error(`Failed to write to claude setup-token stdin: ${e}`);
|
|
115
|
-
}
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function notifyPending(text: string): Promise<void> {
|
|
120
|
-
if (!notifyFn || pendingChatIds.size === 0) return;
|
|
121
|
-
const chats = Array.from(pendingChatIds);
|
|
122
|
-
await Promise.all(
|
|
123
|
-
chats.map(async (chatId) => {
|
|
124
|
-
try { await notifyFn?.(chatId, text); } catch (e) {
|
|
125
|
-
log.error(`Failed to notify ${chatId}: ${e}`);
|
|
126
|
-
}
|
|
127
|
-
}),
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function runClaudeSetupToken(): Promise<void> {
|
|
132
|
-
log.warn("Claude auth required. Starting `claude setup-token`.");
|
|
133
|
-
urlSent = false;
|
|
134
|
-
|
|
135
|
-
let proc: ReturnType<typeof Bun.spawn>;
|
|
136
|
-
try {
|
|
137
|
-
proc = Bun.spawn(buildBunWrappedAgentCliCommand("claude", ["setup-token"], { skipPreload: true }), {
|
|
138
|
-
cwd: config.projectDir,
|
|
139
|
-
stdin: "pipe",
|
|
140
|
-
stdout: "pipe",
|
|
141
|
-
stderr: "pipe",
|
|
142
|
-
env: { ...process.env, BROWSER: "echo" }, // Prevent browser auto-open on headless servers
|
|
143
|
-
});
|
|
144
|
-
pendingProc = proc;
|
|
145
|
-
} catch (error) {
|
|
146
|
-
log.error(`Failed to start claude setup-token: ${error}`);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Read stdout incrementally to detect URL early and send to Telegram
|
|
151
|
-
const readAndNotify = async (stream: ReadableStream<Uint8Array> | null, target: NodeJS.WriteStream): Promise<string> => {
|
|
152
|
-
if (!stream) return "";
|
|
153
|
-
const chunks: string[] = [];
|
|
154
|
-
const reader = stream.getReader();
|
|
155
|
-
const decoder = new TextDecoder();
|
|
156
|
-
try {
|
|
157
|
-
while (true) {
|
|
158
|
-
const { done, value } = await reader.read();
|
|
159
|
-
if (done) break;
|
|
160
|
-
const text = decoder.decode(value, { stream: true });
|
|
161
|
-
chunks.push(text);
|
|
162
|
-
target.write(text);
|
|
163
|
-
|
|
164
|
-
// Try to parse and send URL as soon as we see it
|
|
165
|
-
if (!urlSent) {
|
|
166
|
-
const allText = chunks.join("");
|
|
167
|
-
const urlMatch = allText.match(/(https:\/\/claude\.ai\/oauth\/authorize\S+)/);
|
|
168
|
-
if (urlMatch) {
|
|
169
|
-
urlSent = true;
|
|
170
|
-
const msg = [
|
|
171
|
-
"<b>Claude login required</b>\n",
|
|
172
|
-
`1. Open this link:\n${urlMatch[1]}\n`,
|
|
173
|
-
"2. Authorize and copy the code",
|
|
174
|
-
"3. Reply here with the code",
|
|
175
|
-
].join("\n");
|
|
176
|
-
await notifyPending(msg);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
} finally {
|
|
181
|
-
reader.releaseLock();
|
|
182
|
-
}
|
|
183
|
-
return chunks.join("");
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
await Promise.all([
|
|
187
|
-
readAndNotify(proc.stdout, process.stdout),
|
|
188
|
-
readAndNotify(proc.stderr, process.stderr),
|
|
189
|
-
]);
|
|
190
|
-
|
|
191
|
-
const exitCode = await proc.exited;
|
|
192
|
-
if (exitCode === 0) {
|
|
193
|
-
log.info("Claude setup-token completed successfully.");
|
|
194
|
-
await notifySuccess();
|
|
195
|
-
} else {
|
|
196
|
-
log.error(`Claude setup-token finished with exit code ${exitCode}`);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function notifySuccess(): Promise<void> {
|
|
201
|
-
if (!notifyFn || pendingChatIds.size === 0) return;
|
|
202
|
-
|
|
203
|
-
const text = "<b>Claude login completed.</b>\nTry again.";
|
|
204
|
-
const chats = Array.from(pendingChatIds);
|
|
205
|
-
pendingChatIds.clear();
|
|
206
|
-
|
|
207
|
-
await Promise.all(
|
|
208
|
-
chats.map(async (chatId) => {
|
|
209
|
-
try { await notifyFn?.(chatId, text); } catch (e) {
|
|
210
|
-
log.error(`Failed to send Claude login success notice to ${chatId}: ${e}`);
|
|
211
|
-
}
|
|
212
|
-
}),
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function isClaudeLoginPending(): boolean {
|
|
217
|
-
return loginInProgress;
|
|
218
|
-
}
|