Initial commit: QwenClaw persistent daemon for Qwen Code
This commit is contained in:
23
src/commands/clear.ts
Normal file
23
src/commands/clear.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { runUserMessage } from "../runner";
|
||||
import { loadSettings } from "../config";
|
||||
import { checkExistingDaemon } from "../pid";
|
||||
|
||||
export async function clear(): Promise<void> {
|
||||
const existingPid = await checkExistingDaemon();
|
||||
if (!existingPid) {
|
||||
console.log("QwenClaw daemon is not running.");
|
||||
}
|
||||
|
||||
// Clear the daemon state
|
||||
const { unlink } = await import("fs/promises");
|
||||
const { join } = await import("path");
|
||||
const QWEN_DIR = join(process.cwd(), ".qwen");
|
||||
const HEARTBEAT_DIR = join(QWEN_DIR, "qwenclaw");
|
||||
const STATE_FILE = join(HEARTBEAT_DIR, "state.json");
|
||||
const SESSION_FILE = join(HEARTBEAT_DIR, "session.json");
|
||||
|
||||
await unlink(STATE_FILE).catch(() => {});
|
||||
await unlink(SESSION_FILE).catch(() => {});
|
||||
|
||||
console.log("QwenClaw state cleared.");
|
||||
}
|
||||
40
src/commands/send.ts
Normal file
40
src/commands/send.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { runUserMessage } from "../runner";
|
||||
import { loadSettings } from "../config";
|
||||
import { checkExistingDaemon } from "../pid";
|
||||
|
||||
export async function send(args: string[] = []): Promise<void> {
|
||||
const hasTelegramFlag = args.includes("--telegram");
|
||||
const prompt = args.filter((a) => !a.startsWith("--")).join(" ").trim();
|
||||
|
||||
if (!prompt) {
|
||||
console.error("Usage: qwenclaw send [--telegram] <prompt>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existingPid = await checkExistingDaemon();
|
||||
if (!existingPid) {
|
||||
console.error("QwenClaw daemon is not running. Start it with `qwenclaw start`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
console.log(`Sending prompt to daemon: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"`);
|
||||
|
||||
const result = await runUserMessage("send", prompt);
|
||||
console.log(result.stdout);
|
||||
|
||||
if (hasTelegramFlag && settings.telegram.token && settings.telegram.allowedUserIds.length > 0) {
|
||||
const { sendMessage } = await import("./telegram");
|
||||
const text = result.exitCode === 0 ? result.stdout : `Error: ${result.stderr || "Unknown error"}`;
|
||||
for (const userId of settings.telegram.allowedUserIds) {
|
||||
await sendMessage(settings.telegram.token, userId, text).catch((err) => {
|
||||
console.error(`[Telegram] Failed to send to ${userId}: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
process.exit(result.exitCode);
|
||||
}
|
||||
}
|
||||
737
src/commands/start.ts
Normal file
737
src/commands/start.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
import { writeFile, unlink, mkdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { run, runUserMessage, bootstrap, ensureProjectQwenMd, loadHeartbeatPromptTemplate } from "../runner";
|
||||
import { writeState, type StateData } from "../statusline";
|
||||
import { cronMatches, nextCronMatch } from "../cron";
|
||||
import { clearJobSchedule, loadJobs } from "../jobs";
|
||||
import { writePidFile, cleanupPidFile, checkExistingDaemon } from "../pid";
|
||||
import { initConfig, loadSettings, reloadSettings, resolvePrompt, type HeartbeatConfig, type Settings } from "../config";
|
||||
import { getDayAndMinuteAtOffset } from "../timezone";
|
||||
import { startWebUi, type WebServerHandle } from "../web";
|
||||
import type { Job } from "../jobs";
|
||||
|
||||
const QWEN_DIR = join(process.cwd(), ".qwen");
|
||||
const HEARTBEAT_DIR = join(QWEN_DIR, "qwenclaw");
|
||||
const STATUSLINE_FILE = join(QWEN_DIR, "statusline.cjs");
|
||||
const QWEN_SETTINGS_FILE = join(QWEN_DIR, "settings.json");
|
||||
const PREFLIGHT_SCRIPT = fileURLToPath(new URL("../preflight.ts", import.meta.url));
|
||||
|
||||
// --- Statusline setup/teardown ---
|
||||
|
||||
const STATUSLINE_SCRIPT = `#!/usr/bin/env node
|
||||
const { readFileSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
const DIR = join(__dirname, "qwenclaw");
|
||||
const STATE_FILE = join(DIR, "state.json");
|
||||
const PID_FILE = join(DIR, "daemon.pid");
|
||||
const R = "\\x1b[0m";
|
||||
const DIM = "\\x1b[2m";
|
||||
const RED = "\\x1b[31m";
|
||||
const GREEN = "\\x1b[32m";
|
||||
function fmt(ms) {
|
||||
if (ms <= 0) return GREEN + "now!" + R;
|
||||
var s = Math.floor(ms / 1000);
|
||||
var h = Math.floor(s / 3600);
|
||||
var m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0) return h + "h " + m + "m";
|
||||
if (m > 0) return m + "m";
|
||||
return (s % 60) + "s";
|
||||
}
|
||||
function alive() {
|
||||
try {
|
||||
var pid = readFileSync(PID_FILE, "utf-8").trim();
|
||||
process.kill(Number(pid), 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
var B = DIM + "\\u2502" + R;
|
||||
var TL = DIM + "\\u256d" + R;
|
||||
var TR = DIM + "\\u256e" + R;
|
||||
var BL = DIM + "\\u2570" + R;
|
||||
var BR = DIM + "\\u256f" + R;
|
||||
var H = DIM + "\\u2500" + R;
|
||||
var HEADER = TL + H.repeat(6) + " \\ud83d\\udc3e QwenClaw \\ud83d\\udc3e " + H.repeat(6) + TR;
|
||||
var FOOTER = BL + H.repeat(30) + BR;
|
||||
if (!alive()) {
|
||||
process.stdout.write(
|
||||
HEADER +
|
||||
"\\n" +
|
||||
B +
|
||||
" " +
|
||||
RED +
|
||||
"\\u25cb offline" +
|
||||
R +
|
||||
" " +
|
||||
B +
|
||||
"\\n" +
|
||||
FOOTER
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
try {
|
||||
var state = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
|
||||
var now = Date.now();
|
||||
var info = [];
|
||||
if (state.heartbeat) {
|
||||
info.push("\\ud83d\\udc93 " + fmt(state.heartbeat.nextAt - now));
|
||||
}
|
||||
var jc = (state.jobs || []).length;
|
||||
info.push("\\ud83d\\udccb " + jc + " job" + (jc !== 1 ? "s" : ""));
|
||||
info.push(GREEN + "\\u25cf live" + R);
|
||||
if (state.telegram) {
|
||||
info.push(GREEN + "\\ud83d\\udce1" + R);
|
||||
}
|
||||
var mid = " " + info.join(" " + B + " ") + " ";
|
||||
process.stdout.write(HEADER + "\\n" + B + mid + B + "\\n" + FOOTER);
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
HEADER +
|
||||
"\\n" +
|
||||
B +
|
||||
DIM +
|
||||
" waiting... " +
|
||||
R +
|
||||
B +
|
||||
"\\n" +
|
||||
FOOTER
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
function parseClockMinutes(value: string): number | null {
|
||||
const match = value.match(/^([01]\d|2[0-3]):([0-5]\d)$/);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function isHeartbeatExcludedNow(
|
||||
config: HeartbeatConfig,
|
||||
timezoneOffsetMinutes: number
|
||||
): boolean {
|
||||
return isHeartbeatExcludedAt(config, timezoneOffsetMinutes, new Date());
|
||||
}
|
||||
|
||||
function isHeartbeatExcludedAt(
|
||||
config: HeartbeatConfig,
|
||||
timezoneOffsetMinutes: number,
|
||||
at: Date
|
||||
): boolean {
|
||||
if (!Array.isArray(config.excludeWindows) || config.excludeWindows.length === 0)
|
||||
return false;
|
||||
|
||||
const local = getDayAndMinuteAtOffset(at, timezoneOffsetMinutes);
|
||||
|
||||
for (const window of config.excludeWindows) {
|
||||
const start = parseClockMinutes(window.start);
|
||||
const end = parseClockMinutes(window.end);
|
||||
if (start == null || end == null) continue;
|
||||
|
||||
const days =
|
||||
Array.isArray(window.days) && window.days.length > 0
|
||||
? window.days
|
||||
: ALL_DAYS;
|
||||
|
||||
const sameDay = start < end;
|
||||
if (sameDay) {
|
||||
if (days.includes(local.day) && local.minute >= start && local.minute < end)
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start === end) {
|
||||
if (days.includes(local.day)) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (local.minute >= start && days.includes(local.day)) return true;
|
||||
const previousDay = (local.day + 6) % 7;
|
||||
if (local.minute < end && days.includes(previousDay)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function nextAllowedHeartbeatAt(
|
||||
config: HeartbeatConfig,
|
||||
timezoneOffsetMinutes: number,
|
||||
intervalMs: number,
|
||||
fromMs: number
|
||||
): number {
|
||||
const interval = Math.max(60_000, Math.round(intervalMs));
|
||||
let candidate = fromMs + interval;
|
||||
let guard = 0;
|
||||
while (
|
||||
isHeartbeatExcludedAt(config, timezoneOffsetMinutes, new Date(candidate)) &&
|
||||
guard < 20_000
|
||||
) {
|
||||
candidate += interval;
|
||||
guard++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function setupStatusline() {
|
||||
await mkdir(QWEN_DIR, { recursive: true });
|
||||
await writeFile(STATUSLINE_FILE, STATUSLINE_SCRIPT);
|
||||
|
||||
let settings: Record<string, unknown> = {};
|
||||
try {
|
||||
settings = await Bun.file(QWEN_SETTINGS_FILE).json();
|
||||
} catch {
|
||||
// file doesn't exist or isn't valid JSON
|
||||
}
|
||||
|
||||
settings.statusLine = {
|
||||
type: "command",
|
||||
command: "node .qwen/statusline.cjs",
|
||||
};
|
||||
|
||||
await writeFile(QWEN_SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
||||
}
|
||||
|
||||
async function teardownStatusline() {
|
||||
try {
|
||||
const settings = await Bun.file(QWEN_SETTINGS_FILE).json();
|
||||
delete settings.statusLine;
|
||||
await writeFile(QWEN_SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
||||
} catch {
|
||||
// file doesn't exist, nothing to clean up
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(STATUSLINE_FILE);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
export async function start(args: string[] = []) {
|
||||
let hasPromptFlag = false;
|
||||
let hasTriggerFlag = false;
|
||||
let telegramFlag = false;
|
||||
let debugFlag = false;
|
||||
let webFlag = false;
|
||||
let replaceExistingFlag = false;
|
||||
let webPortFlag: number | null = null;
|
||||
|
||||
const payloadParts: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--prompt") {
|
||||
hasPromptFlag = true;
|
||||
} else if (arg === "--trigger") {
|
||||
hasTriggerFlag = true;
|
||||
} else if (arg === "--telegram") {
|
||||
telegramFlag = true;
|
||||
} else if (arg === "--debug") {
|
||||
debugFlag = true;
|
||||
} else if (arg === "--web") {
|
||||
webFlag = true;
|
||||
} else if (arg === "--replace-existing") {
|
||||
replaceExistingFlag = true;
|
||||
} else if (arg === "--web-port") {
|
||||
const raw = args[i + 1];
|
||||
if (!raw) {
|
||||
console.error("`--web-port` requires a numeric value.");
|
||||
process.exit(1);
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
console.error("`--web-port` must be a valid TCP port (1-65535).");
|
||||
process.exit(1);
|
||||
}
|
||||
webPortFlag = parsed;
|
||||
i++;
|
||||
} else {
|
||||
payloadParts.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = payloadParts.join(" ").trim();
|
||||
|
||||
if (hasPromptFlag && !payload) {
|
||||
console.error(
|
||||
"Usage: qwenclaw start --prompt [--trigger] [--telegram] [--debug] [--web] [--web-port <port>] [--replace-existing]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!hasPromptFlag && payload) {
|
||||
console.error("Prompt text requires `--prompt`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (telegramFlag && !hasTriggerFlag) {
|
||||
console.error("`--telegram` with `start` requires `--trigger`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (hasPromptFlag && !hasTriggerFlag && (webFlag || webPortFlag !== null)) {
|
||||
console.error("`--web` is daemon-only. Remove `--prompt`, or add `--trigger`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// One-shot mode: explicit prompt without trigger.
|
||||
if (hasPromptFlag && !hasTriggerFlag) {
|
||||
const existingPid = await checkExistingDaemon();
|
||||
if (existingPid) {
|
||||
console.error(
|
||||
`\x1b[31mAborted: daemon already running in this directory (PID ${existingPid})\x1b[0m`
|
||||
);
|
||||
console.error("Use `qwenclaw send [--telegram]` while daemon is running.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await initConfig();
|
||||
await loadSettings();
|
||||
await ensureProjectQwenMd();
|
||||
|
||||
const result = await runUserMessage("prompt", payload);
|
||||
console.log(result.stdout);
|
||||
if (result.exitCode !== 0) process.exit(result.exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPid = await checkExistingDaemon();
|
||||
if (existingPid) {
|
||||
if (!replaceExistingFlag) {
|
||||
console.error(
|
||||
`\x1b[31mAborted: daemon already running in this directory (PID ${existingPid})\x1b[0m`
|
||||
);
|
||||
console.error(`Use --stop first, or kill PID ${existingPid} manually.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Replacing existing daemon (PID ${existingPid})...`);
|
||||
try {
|
||||
process.kill(existingPid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore if process is already dead
|
||||
}
|
||||
const deadline = Date.now() + 4000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(existingPid, 0);
|
||||
await Bun.sleep(100);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await cleanupPidFile();
|
||||
}
|
||||
|
||||
await initConfig();
|
||||
const settings = await loadSettings();
|
||||
await ensureProjectQwenMd();
|
||||
const jobs = await loadJobs();
|
||||
|
||||
const webEnabled = webFlag || webPortFlag !== null || settings.web.enabled;
|
||||
const webPort = webPortFlag ?? settings.web.port;
|
||||
|
||||
await setupStatusline();
|
||||
await writePidFile();
|
||||
|
||||
let web: WebServerHandle | null = null;
|
||||
|
||||
async function shutdown() {
|
||||
if (web) web.stop();
|
||||
await teardownStatusline();
|
||||
await cleanupPidFile();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
console.log("QwenClaw daemon started");
|
||||
console.log(` PID: ${process.pid}`);
|
||||
console.log(` Security: ${settings.security.level}`);
|
||||
if (settings.security.allowedTools.length > 0)
|
||||
console.log(` + allowed: ${settings.security.allowedTools.join(", ")}`);
|
||||
if (settings.security.disallowedTools.length > 0)
|
||||
console.log(` - blocked: ${settings.security.disallowedTools.join(", ")}`);
|
||||
console.log(
|
||||
` Heartbeat: ${settings.heartbeat.enabled ? `every ${settings.heartbeat.interval}m` : "disabled"}`
|
||||
);
|
||||
console.log(
|
||||
` Web UI: ${webEnabled ? `http://${settings.web.host}:${webPort}` : "disabled"}`
|
||||
);
|
||||
if (debugFlag) console.log(" Debug: enabled");
|
||||
console.log(` Jobs loaded: ${jobs.length}`);
|
||||
jobs.forEach((j) => console.log(` - ${j.name} [${j.schedule}]`));
|
||||
|
||||
// --- Mutable state ---
|
||||
let currentSettings: Settings = settings;
|
||||
let currentJobs: Job[] = jobs;
|
||||
let nextHeartbeatAt = 0;
|
||||
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const daemonStartedAt = Date.now();
|
||||
|
||||
// --- Telegram ---
|
||||
let telegramSend: ((chatId: number, text: string) => Promise<void>) | null = null;
|
||||
let telegramToken = "";
|
||||
|
||||
async function initTelegram(token: string) {
|
||||
if (token && token !== telegramToken) {
|
||||
const { startPolling, sendMessage } = await import("./telegram");
|
||||
startPolling(debugFlag);
|
||||
telegramSend = (chatId, text) => sendMessage(token, chatId, text);
|
||||
telegramToken = token;
|
||||
console.log(`[${ts()}] Telegram: enabled`);
|
||||
} else if (!token && telegramToken) {
|
||||
telegramSend = null;
|
||||
telegramToken = "";
|
||||
console.log(`[${ts()}] Telegram: disabled`);
|
||||
}
|
||||
}
|
||||
|
||||
await initTelegram(currentSettings.telegram.token);
|
||||
if (!telegramToken) console.log(" Telegram: not configured");
|
||||
|
||||
function isAddrInUse(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const code = "code" in err ? String((err as { code?: unknown }).code) : "";
|
||||
const message = "message" in err ? String((err as { message?: unknown }).message) : "";
|
||||
return code === "EADDRINUSE" || message.includes("EADDRINUSE");
|
||||
}
|
||||
|
||||
function startWebWithFallback(host: string, preferredPort: number): WebServerHandle {
|
||||
const maxAttempts = 10;
|
||||
let lastError: unknown;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const candidatePort = preferredPort + i;
|
||||
try {
|
||||
return startWebUi({
|
||||
host,
|
||||
port: candidatePort,
|
||||
getSnapshot: () => ({
|
||||
pid: process.pid,
|
||||
startedAt: daemonStartedAt,
|
||||
heartbeatNextAt: nextHeartbeatAt,
|
||||
settings: currentSettings,
|
||||
jobs: currentJobs,
|
||||
}),
|
||||
onHeartbeatEnabledChanged: (enabled) => {
|
||||
if (currentSettings.heartbeat.enabled === enabled) return;
|
||||
currentSettings.heartbeat.enabled = enabled;
|
||||
scheduleHeartbeat();
|
||||
updateState();
|
||||
console.log(`[${ts()}] Heartbeat ${enabled ? "enabled" : "disabled"} from Web UI`);
|
||||
},
|
||||
onHeartbeatSettingsChanged: (patch) => {
|
||||
let changed = false;
|
||||
if (
|
||||
typeof patch.enabled === "boolean" &&
|
||||
currentSettings.heartbeat.enabled !== patch.enabled
|
||||
) {
|
||||
currentSettings.heartbeat.enabled = patch.enabled;
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
typeof patch.interval === "number" &&
|
||||
Number.isFinite(patch.interval)
|
||||
) {
|
||||
const interval = Math.max(1, Math.min(1440, Math.round(patch.interval)));
|
||||
if (currentSettings.heartbeat.interval !== interval) {
|
||||
currentSettings.heartbeat.interval = interval;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof patch.prompt === "string" &&
|
||||
currentSettings.heartbeat.prompt !== patch.prompt
|
||||
) {
|
||||
currentSettings.heartbeat.prompt = patch.prompt;
|
||||
changed = true;
|
||||
}
|
||||
if (Array.isArray(patch.excludeWindows)) {
|
||||
const prev = JSON.stringify(currentSettings.heartbeat.excludeWindows);
|
||||
const next = JSON.stringify(patch.excludeWindows);
|
||||
if (prev !== next) {
|
||||
currentSettings.heartbeat.excludeWindows = patch.excludeWindows;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
scheduleHeartbeat();
|
||||
updateState();
|
||||
console.log(`[${ts()}] Heartbeat settings updated from Web UI`);
|
||||
},
|
||||
onJobsChanged: async () => {
|
||||
currentJobs = await loadJobs();
|
||||
scheduleHeartbeat();
|
||||
updateState();
|
||||
console.log(`[${ts()}] Jobs reloaded from Web UI`);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (!isAddrInUse(err) || i === maxAttempts - 1) throw err;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (webEnabled) {
|
||||
currentSettings.web.enabled = true;
|
||||
web = startWebWithFallback(currentSettings.web.host, webPort);
|
||||
currentSettings.web.port = web.port;
|
||||
console.log(
|
||||
`[${new Date().toLocaleTimeString()}] Web UI listening on http://${web.host}:${web.port}`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function ts() {
|
||||
return new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function startPreflightInBackground(projectPath: string): void {
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[process.execPath, "run", PREFLIGHT_SCRIPT, projectPath],
|
||||
{
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}
|
||||
);
|
||||
proc.unref();
|
||||
console.log(`[${ts()}] Plugin preflight started in background`);
|
||||
} catch (err) {
|
||||
console.error(`[${ts()}] Failed to start plugin preflight:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function forwardToTelegram(
|
||||
label: string,
|
||||
result: { exitCode: number; stdout: string; stderr: string }
|
||||
) {
|
||||
if (!telegramSend || currentSettings.telegram.allowedUserIds.length === 0) return;
|
||||
const text =
|
||||
result.exitCode === 0
|
||||
? `${label ? `[${label}]\n` : ""}${result.stdout || "(empty)"}`
|
||||
: `${label ? `[${label}] ` : ""}error (exit ${result.exitCode}): ${result.stderr || "Unknown"}`;
|
||||
for (const userId of currentSettings.telegram.allowedUserIds) {
|
||||
telegramSend(userId, text).catch((err) =>
|
||||
console.error(`[Telegram] Failed to forward to ${userId}: ${err}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Heartbeat scheduling ---
|
||||
function scheduleHeartbeat() {
|
||||
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
|
||||
if (!currentSettings.heartbeat.enabled) {
|
||||
nextHeartbeatAt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const ms = currentSettings.heartbeat.interval * 60_000;
|
||||
nextHeartbeatAt = nextAllowedHeartbeatAt(
|
||||
currentSettings.heartbeat,
|
||||
currentSettings.timezoneOffsetMinutes,
|
||||
ms,
|
||||
Date.now()
|
||||
);
|
||||
|
||||
function tick() {
|
||||
if (
|
||||
isHeartbeatExcludedNow(
|
||||
currentSettings.heartbeat,
|
||||
currentSettings.timezoneOffsetMinutes
|
||||
)
|
||||
) {
|
||||
console.log(`[${ts()}] Heartbeat skipped (excluded window)`);
|
||||
nextHeartbeatAt = nextAllowedHeartbeatAt(
|
||||
currentSettings.heartbeat,
|
||||
currentSettings.timezoneOffsetMinutes,
|
||||
ms,
|
||||
Date.now()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
resolvePrompt(currentSettings.heartbeat.prompt),
|
||||
loadHeartbeatPromptTemplate(),
|
||||
])
|
||||
.then(([prompt, template]) => {
|
||||
const userPromptSection = prompt.trim()
|
||||
? `User custom heartbeat prompt:\n${prompt.trim()}`
|
||||
: "";
|
||||
const mergedPrompt = [template.trim(), userPromptSection]
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n\n");
|
||||
if (!mergedPrompt) return null;
|
||||
return run("heartbeat", mergedPrompt);
|
||||
})
|
||||
.then((r) => {
|
||||
if (r) forwardToTelegram("", r);
|
||||
});
|
||||
|
||||
nextHeartbeatAt = nextAllowedHeartbeatAt(
|
||||
currentSettings.heartbeat,
|
||||
currentSettings.timezoneOffsetMinutes,
|
||||
ms,
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
heartbeatTimer = setTimeout(function runAndReschedule() {
|
||||
tick();
|
||||
heartbeatTimer = setTimeout(runAndReschedule, ms);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
// Startup init:
|
||||
// - trigger mode: run exactly one trigger prompt (no separate bootstrap)
|
||||
// - normal mode: bootstrap to initialize session context
|
||||
if (hasTriggerFlag) {
|
||||
const triggerPrompt = hasPromptFlag ? payload : "Wake up, my friend!";
|
||||
const triggerResult = await run("trigger", triggerPrompt);
|
||||
console.log(triggerResult.stdout);
|
||||
if (telegramFlag) forwardToTelegram("", triggerResult);
|
||||
if (triggerResult.exitCode !== 0) {
|
||||
console.error(
|
||||
`[${ts()}] Startup trigger failed (exit ${triggerResult.exitCode}). Daemon will continue running.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Bootstrap the session first so system prompt is initial context
|
||||
// and session.json is created immediately.
|
||||
await bootstrap();
|
||||
}
|
||||
|
||||
// Install plugins without blocking daemon startup.
|
||||
startPreflightInBackground(process.cwd());
|
||||
|
||||
if (currentSettings.heartbeat.enabled) scheduleHeartbeat();
|
||||
|
||||
// --- Hot-reload loop (every 30s) ---
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const newSettings = await reloadSettings();
|
||||
const newJobs = await loadJobs();
|
||||
|
||||
// Detect heartbeat config changes
|
||||
const hbChanged =
|
||||
newSettings.heartbeat.enabled !== currentSettings.heartbeat.enabled ||
|
||||
newSettings.heartbeat.interval !== currentSettings.heartbeat.interval ||
|
||||
newSettings.heartbeat.prompt !== currentSettings.heartbeat.prompt ||
|
||||
newSettings.timezoneOffsetMinutes !== currentSettings.timezoneOffsetMinutes ||
|
||||
newSettings.timezone !== currentSettings.timezone ||
|
||||
JSON.stringify(newSettings.heartbeat.excludeWindows) !==
|
||||
JSON.stringify(currentSettings.heartbeat.excludeWindows);
|
||||
|
||||
// Detect security config changes
|
||||
const secChanged =
|
||||
newSettings.security.level !== currentSettings.security.level ||
|
||||
newSettings.security.allowedTools.join(",") !==
|
||||
currentSettings.security.allowedTools.join(",") ||
|
||||
newSettings.security.disallowedTools.join(",") !==
|
||||
currentSettings.security.disallowedTools.join(",");
|
||||
|
||||
if (secChanged) {
|
||||
console.log(`[${ts()}] Security level changed → ${newSettings.security.level}`);
|
||||
}
|
||||
|
||||
if (hbChanged) {
|
||||
console.log(
|
||||
`[${ts()}] Config change detected — heartbeat: ${newSettings.heartbeat.enabled ? `every ${newSettings.heartbeat.interval}m` : "disabled"}`
|
||||
);
|
||||
currentSettings = newSettings;
|
||||
scheduleHeartbeat();
|
||||
} else {
|
||||
currentSettings = newSettings;
|
||||
}
|
||||
|
||||
if (web) {
|
||||
currentSettings.web.enabled = true;
|
||||
currentSettings.web.port = web.port;
|
||||
}
|
||||
|
||||
// Detect job changes
|
||||
const jobNames = newJobs
|
||||
.map((j) => `${j.name}:${j.schedule}:${j.prompt}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const oldJobNames = currentJobs
|
||||
.map((j) => `${j.name}:${j.schedule}:${j.prompt}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
if (jobNames !== oldJobNames) {
|
||||
console.log(`[${ts()}] Jobs reloaded: ${newJobs.length} job(s)`);
|
||||
newJobs.forEach((j) => console.log(` - ${j.name} [${j.schedule}]`));
|
||||
}
|
||||
currentJobs = newJobs;
|
||||
|
||||
// Telegram changes
|
||||
await initTelegram(newSettings.telegram.token);
|
||||
} catch (err) {
|
||||
console.error(`[${ts()}] Hot-reload error:`, err);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// --- Cron tick (every 60s) ---
|
||||
function updateState() {
|
||||
const now = new Date();
|
||||
const state: StateData = {
|
||||
heartbeat: currentSettings.heartbeat.enabled
|
||||
? { nextAt: nextHeartbeatAt }
|
||||
: undefined,
|
||||
jobs: currentJobs.map((job) => ({
|
||||
name: job.name,
|
||||
nextAt: nextCronMatch(job.schedule, now, currentSettings.timezoneOffsetMinutes).getTime(),
|
||||
})),
|
||||
security: currentSettings.security.level,
|
||||
telegram: !!currentSettings.telegram.token,
|
||||
startedAt: daemonStartedAt,
|
||||
web: {
|
||||
enabled: !!web,
|
||||
host: currentSettings.web.host,
|
||||
port: currentSettings.web.port,
|
||||
},
|
||||
};
|
||||
writeState(state);
|
||||
}
|
||||
|
||||
updateState();
|
||||
|
||||
setInterval(() => {
|
||||
const now = new Date();
|
||||
for (const job of currentJobs) {
|
||||
if (cronMatches(job.schedule, now, currentSettings.timezoneOffsetMinutes)) {
|
||||
resolvePrompt(job.prompt)
|
||||
.then((prompt) => run(job.name, prompt))
|
||||
.then((r) => {
|
||||
if (job.notify === false) return;
|
||||
if (job.notify === "error" && r.exitCode === 0) return;
|
||||
forwardToTelegram(job.name, r);
|
||||
})
|
||||
.finally(async () => {
|
||||
if (job.recurring) return;
|
||||
try {
|
||||
await clearJobSchedule(job.name);
|
||||
console.log(`[${ts()}] Cleared schedule for one-time job: ${job.name}`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[${ts()}] Failed to clear schedule for ${job.name}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
updateState();
|
||||
}, 60_000);
|
||||
}
|
||||
71
src/commands/status.ts
Normal file
71
src/commands/status.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { checkExistingDaemon } from "../pid";
|
||||
|
||||
const QWEN_DIR = join(process.cwd(), ".qwen");
|
||||
const HEARTBEAT_DIR = join(QWEN_DIR, "qwenclaw");
|
||||
const STATE_FILE = join(HEARTBEAT_DIR, "state.json");
|
||||
const SETTINGS_FILE = join(HEARTBEAT_DIR, "settings.json");
|
||||
const SESSION_FILE = join(HEARTBEAT_DIR, "session.json");
|
||||
|
||||
export async function status(): Promise<void> {
|
||||
const existingPid = await checkExistingDaemon();
|
||||
|
||||
if (!existingPid) {
|
||||
console.log("QwenClaw daemon is not running.");
|
||||
} else {
|
||||
console.log(`QwenClaw daemon is running (PID ${existingPid}).`);
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await Bun.file(STATE_FILE).json();
|
||||
console.log("\nState:");
|
||||
if (state.heartbeat) {
|
||||
const ms = state.heartbeat.nextAt - Date.now();
|
||||
const s = Math.floor(ms / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0) {
|
||||
console.log(` Heartbeat: in ${h}h ${m}m`);
|
||||
} else if (m > 0) {
|
||||
console.log(` Heartbeat: in ${m}m`);
|
||||
} else {
|
||||
console.log(` Heartbeat: in ${s % 60}s`);
|
||||
}
|
||||
} else {
|
||||
console.log(" Heartbeat: disabled");
|
||||
}
|
||||
|
||||
if (state.jobs && state.jobs.length > 0) {
|
||||
console.log(` Jobs: ${state.jobs.length}`);
|
||||
for (const job of state.jobs) {
|
||||
const ms = job.nextAt - Date.now();
|
||||
const s = Math.floor(ms / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0) {
|
||||
console.log(` - ${job.name}: in ${h}h ${m}m`);
|
||||
} else if (m > 0) {
|
||||
console.log(` - ${job.name}: in ${m}m`);
|
||||
} else {
|
||||
console.log(` - ${job.name}: in ${s % 60}s`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(" Jobs: none");
|
||||
}
|
||||
|
||||
console.log(` Security: ${state.security || "unknown"}`);
|
||||
console.log(` Telegram: ${state.telegram ? "enabled" : "disabled"}`);
|
||||
console.log(` Web UI: ${state.web?.enabled ? `http://${state.web.host}:${state.web.port}` : "disabled"}`);
|
||||
} catch {
|
||||
console.log(" State: not available");
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await Bun.file(SESSION_FILE).json();
|
||||
console.log(`\nSession: ${session.sessionId?.slice(0, 8) || "none"}...`);
|
||||
} catch {
|
||||
console.log("\nSession: none");
|
||||
}
|
||||
}
|
||||
49
src/commands/stop.ts
Normal file
49
src/commands/stop.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cleanupPidFile } from "../pid";
|
||||
import { unlink } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
const QWEN_DIR = join(process.cwd(), ".qwen");
|
||||
const HEARTBEAT_DIR = join(QWEN_DIR, "qwenclaw");
|
||||
const STATE_FILE = join(HEARTBEAT_DIR, "state.json");
|
||||
const SESSION_FILE = join(HEARTBEAT_DIR, "session.json");
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
const pidFile = join(HEARTBEAT_DIR, "daemon.pid");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = (await Bun.file(pidFile).text()).trim();
|
||||
} catch {
|
||||
console.log("QwenClaw daemon is not running (no PID file found).");
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = Number(raw);
|
||||
if (!pid || isNaN(pid)) {
|
||||
console.log("QwenClaw daemon is not running (invalid PID file).");
|
||||
await cleanupPidFile();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
console.log(`QwenClaw daemon (PID ${pid}) stopped.`);
|
||||
} catch (err) {
|
||||
console.log(`Failed to stop daemon (PID ${pid}): ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
await cleanupPidFile();
|
||||
await unlink(STATE_FILE).catch(() => {});
|
||||
}
|
||||
|
||||
export async function stopAll(): Promise<void> {
|
||||
// Stop all daemon processes by finding and killing them
|
||||
console.log("Stopping all QwenClaw daemons...");
|
||||
await stop();
|
||||
}
|
||||
|
||||
export async function clear(): Promise<void> {
|
||||
await cleanupPidFile();
|
||||
await unlink(STATE_FILE).catch(() => {});
|
||||
await unlink(SESSION_FILE).catch(() => {});
|
||||
console.log("QwenClaw state cleared.");
|
||||
}
|
||||
665
src/commands/telegram.ts
Normal file
665
src/commands/telegram.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import { ensureProjectQwenMd, run, runUserMessage } from "../runner";
|
||||
import { getSettings, loadSettings } from "../config";
|
||||
import { resetSession } from "../sessions";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
// --- Markdown → Telegram HTML conversion (ported from nanobot) ---
|
||||
|
||||
function markdownToTelegramHtml(text: string): string {
|
||||
if (!text) return "";
|
||||
|
||||
// 1. Extract and protect code blocks
|
||||
const codeBlocks: string[] = [];
|
||||
text = text.replace(/```[\w]*\n?([\s\S]*?)```/g, (_m, code) => {
|
||||
codeBlocks.push(code);
|
||||
return `\x00CB${codeBlocks.length - 1}\x00`;
|
||||
});
|
||||
|
||||
// 2. Extract and protect inline code
|
||||
const inlineCodes: string[] = [];
|
||||
text = text.replace(/`([^`]+)`/g, (_m, code) => {
|
||||
inlineCodes.push(code);
|
||||
return `\x00IC${inlineCodes.length - 1}\x00`;
|
||||
});
|
||||
|
||||
// 3. Strip markdown headers
|
||||
text = text.replace(/^#{1,6}\s+(.+)$/gm, "$1");
|
||||
|
||||
// 4. Strip blockquotes
|
||||
text = text.replace(/^>\s*(.*)$/gm, "$1");
|
||||
|
||||
// 5. Escape HTML special characters
|
||||
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// 6. Links [text](url) — before bold/italic to handle nested cases
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// 7. Bold **text** or __text__
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
||||
text = text.replace(/__(.+?)__/g, "<b>$1</b>");
|
||||
|
||||
// 8. Italic _text_ (avoid matching inside words like some_var_name)
|
||||
text = text.replace(/(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])/g, "<i>$1</i>");
|
||||
|
||||
// 9. Strikethrough ~~text~~
|
||||
text = text.replace(/~~(.+?)~~/g, "<s>$1</s>");
|
||||
|
||||
// 10. Bullet lists
|
||||
text = text.replace(/^[-*]\s+/gm, "• ");
|
||||
|
||||
// 11. Restore inline code with HTML tags
|
||||
for (let i = 0; i < inlineCodes.length; i++) {
|
||||
const escaped = inlineCodes[i].replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
text = text.replace(`\x00IC${i}\x00`, `<code>${escaped}</code>`);
|
||||
}
|
||||
|
||||
// 12. Restore code blocks with HTML tags
|
||||
for (let i = 0; i < codeBlocks.length; i++) {
|
||||
const escaped = codeBlocks[i].replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
text = text.replace(`\x00CB${i}\x00`, `<pre><code>${escaped}</code></pre>`);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
// --- Telegram Bot API (raw fetch, zero deps) ---
|
||||
|
||||
const API_BASE = "https://api.telegram.org/bot";
|
||||
const FILE_API_BASE = "https://api.telegram.org/file/bot";
|
||||
|
||||
interface TelegramUser {
|
||||
id: number;
|
||||
first_name: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface TelegramMessage {
|
||||
message_id: number;
|
||||
from?: TelegramUser;
|
||||
reply_to_message?: { from?: TelegramUser };
|
||||
chat: { id: number; type: string };
|
||||
text?: string;
|
||||
caption?: string;
|
||||
photo?: TelegramPhotoSize[];
|
||||
document?: TelegramDocument;
|
||||
voice?: TelegramVoice;
|
||||
audio?: TelegramAudio;
|
||||
entities?: Array<{
|
||||
type: "mention" | "bot_command" | string;
|
||||
offset: number;
|
||||
length: number;
|
||||
}>;
|
||||
caption_entities?: Array<{
|
||||
type: "mention" | "bot_command" | string;
|
||||
offset: number;
|
||||
length: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TelegramPhotoSize {
|
||||
file_id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
interface TelegramDocument {
|
||||
file_id: string;
|
||||
file_name?: string;
|
||||
mime_type?: string;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
interface TelegramVoice {
|
||||
file_id: string;
|
||||
mime_type?: string;
|
||||
duration?: number;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
interface TelegramAudio {
|
||||
file_id: string;
|
||||
mime_type?: string;
|
||||
duration?: number;
|
||||
file_name?: string;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
interface TelegramChatMember {
|
||||
user: TelegramUser;
|
||||
status: "creator" | "administrator" | "member" | "restricted" | "left" | "kicked";
|
||||
}
|
||||
|
||||
interface TelegramMyChatMemberUpdate {
|
||||
chat: { id: number; type: string; title?: string };
|
||||
from: TelegramUser;
|
||||
old_chat_member: TelegramChatMember;
|
||||
new_chat_member: TelegramChatMember;
|
||||
}
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: TelegramMessage;
|
||||
edited_message?: TelegramMessage;
|
||||
channel_post?: TelegramMessage;
|
||||
edited_channel_post?: TelegramMessage;
|
||||
my_chat_member?: TelegramMyChatMemberUpdate;
|
||||
}
|
||||
|
||||
interface TelegramMe {
|
||||
id: number;
|
||||
username?: string;
|
||||
can_read_all_group_messages?: boolean;
|
||||
}
|
||||
|
||||
interface TelegramFile {
|
||||
file_path?: string;
|
||||
}
|
||||
|
||||
let telegramDebug = false;
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (!telegramDebug) return;
|
||||
console.log(`[Telegram][debug] ${message}`);
|
||||
}
|
||||
|
||||
function normalizeTelegramText(text: string): string {
|
||||
return text.replace(/[\u2010-\u2015\u2212]/g, "-");
|
||||
}
|
||||
|
||||
function getMessageTextAndEntities(message: TelegramMessage): {
|
||||
text: string;
|
||||
entities: TelegramMessage["entities"];
|
||||
} {
|
||||
if (message.text) {
|
||||
return {
|
||||
text: normalizeTelegramText(message.text),
|
||||
entities: message.entities,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.caption) {
|
||||
return {
|
||||
text: normalizeTelegramText(message.caption),
|
||||
entities: message.caption_entities,
|
||||
};
|
||||
}
|
||||
|
||||
return { text: "", entities: [] };
|
||||
}
|
||||
|
||||
function isImageDocument(document?: TelegramDocument): boolean {
|
||||
return Boolean(document?.mime_type?.startsWith("image/"));
|
||||
}
|
||||
|
||||
function isAudioDocument(document?: TelegramDocument): boolean {
|
||||
return Boolean(document?.mime_type?.startsWith("audio/"));
|
||||
}
|
||||
|
||||
function pickLargestPhoto(photo: TelegramPhotoSize[]): TelegramPhotoSize {
|
||||
return [...photo].sort((a, b) => {
|
||||
const sizeA = a.file_size ?? a.width * a.height;
|
||||
const sizeB = b.file_size ?? b.width * b.height;
|
||||
return sizeB - sizeA;
|
||||
})[0];
|
||||
}
|
||||
|
||||
function extensionFromMimeType(mimeType?: string): string {
|
||||
switch (mimeType) {
|
||||
case "image/jpeg":
|
||||
return ".jpg";
|
||||
case "image/png":
|
||||
return ".png";
|
||||
case "image/webp":
|
||||
return ".webp";
|
||||
case "image/gif":
|
||||
return ".gif";
|
||||
case "image/bmp":
|
||||
return ".bmp";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFromAudioMimeType(mimeType?: string): string {
|
||||
switch (mimeType) {
|
||||
case "audio/mpeg":
|
||||
return ".mp3";
|
||||
case "audio/mp4":
|
||||
case "audio/x-m4a":
|
||||
return ".m4a";
|
||||
case "audio/ogg":
|
||||
return ".ogg";
|
||||
case "audio/wav":
|
||||
case "audio/x-wav":
|
||||
return ".wav";
|
||||
case "audio/webm":
|
||||
return ".webm";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractTelegramCommand(text: string): string | null {
|
||||
const firstToken = text.trim().split(/\s+/, 1)[0];
|
||||
if (!firstToken.startsWith("/")) return null;
|
||||
return firstToken.split("@", 1)[0].toLowerCase();
|
||||
}
|
||||
|
||||
async function callApi<T>(token: string, method: string, body?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${token}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Telegram API ${method}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function sendMessage(token: string, chatId: number, text: string): Promise<void> {
|
||||
const normalized = normalizeTelegramText(text).replace(/\[react:[^\]\r\n]+\]/gi, "");
|
||||
const html = markdownToTelegramHtml(normalized);
|
||||
const MAX_LEN = 4096;
|
||||
for (let i = 0; i < html.length; i += MAX_LEN) {
|
||||
try {
|
||||
await callApi(token, "sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: html.slice(i, i + MAX_LEN),
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
} catch {
|
||||
// Fallback to plain text if HTML parsing fails
|
||||
await callApi(token, "sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: normalized.slice(i, i + MAX_LEN),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTyping(token: string, chatId: number): Promise<void> {
|
||||
await callApi(token, "sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {});
|
||||
}
|
||||
|
||||
function extractReactionDirective(text: string): { cleanedText: string; reactionEmoji: string | null } {
|
||||
let reactionEmoji: string | null = null;
|
||||
const cleanedText = text
|
||||
.replace(/\[react:([^\]\r\n]+)\]/gi, (_match, raw) => {
|
||||
const candidate = String(raw).trim();
|
||||
if (!reactionEmoji && candidate) reactionEmoji = candidate;
|
||||
return "";
|
||||
})
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
return { cleanedText, reactionEmoji };
|
||||
}
|
||||
|
||||
async function sendReaction(token: string, chatId: number, messageId: number, emoji: string): Promise<void> {
|
||||
await callApi(token, "setMessageReaction", {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
reaction: [{ type: "emoji", emoji }],
|
||||
});
|
||||
}
|
||||
|
||||
let botUsername: string | null = null;
|
||||
let botId: number | null = null;
|
||||
|
||||
function groupTriggerReason(message: TelegramMessage): string | null {
|
||||
if (botId && message.reply_to_message?.from?.id === botId) return "reply_to_bot";
|
||||
const { text, entities } = getMessageTextAndEntities(message);
|
||||
if (!text) return null;
|
||||
const lowerText = text.toLowerCase();
|
||||
if (botUsername && lowerText.includes(`@${botUsername.toLowerCase()}`)) return "text_contains_mention";
|
||||
|
||||
for (const entity of entities ?? []) {
|
||||
const value = text.slice(entity.offset, entity.offset + entity.length);
|
||||
if (entity.type === "mention" && botUsername && value.toLowerCase() === `@${botUsername.toLowerCase()}`) {
|
||||
return "mention_entity_matches_bot";
|
||||
}
|
||||
if (entity.type === "mention" && !botUsername) return "mention_entity_before_botname_loaded";
|
||||
if (entity.type === "bot_command") {
|
||||
if (!value.includes("@")) return "bare_bot_command";
|
||||
if (!botUsername) return "scoped_command_before_botname_loaded";
|
||||
if (botUsername && value.toLowerCase().endsWith(`@${botUsername.toLowerCase()}`)) return "scoped_command_matches_bot";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadImageFromMessage(token: string, message: TelegramMessage): Promise<string | null> {
|
||||
const photo = message.photo && message.photo.length > 0 ? pickLargestPhoto(message.photo) : null;
|
||||
const imageDocument = isImageDocument(message.document) ? message.document : null;
|
||||
const fileId = photo?.file_id ?? imageDocument?.file_id;
|
||||
if (!fileId) return null;
|
||||
|
||||
const fileMeta = await callApi<{ ok: boolean; result: TelegramFile }>(token, "getFile", { file_id: fileId });
|
||||
if (!fileMeta.ok || !fileMeta.result.file_path) return null;
|
||||
|
||||
const remotePath = fileMeta.result.file_path;
|
||||
const downloadUrl = `${FILE_API_BASE}${token}/${remotePath}`;
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) throw new Error(`Telegram file download failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
const dir = join(process.cwd(), ".claude", "claudeclaw", "inbox", "telegram");
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const remoteExt = extname(remotePath);
|
||||
const docExt = extname(imageDocument?.file_name ?? "");
|
||||
const mimeExt = extensionFromMimeType(imageDocument?.mime_type);
|
||||
const ext = remoteExt || docExt || mimeExt || ".jpg";
|
||||
const filename = `${message.chat.id}-${message.message_id}-${Date.now()}${ext}`;
|
||||
const localPath = join(dir, filename);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
await Bun.write(localPath, bytes);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
async function downloadVoiceFromMessage(token: string, message: TelegramMessage): Promise<string | null> {
|
||||
const audioDocument = isAudioDocument(message.document) ? message.document : null;
|
||||
const audioLike = message.voice ?? message.audio ?? audioDocument;
|
||||
const fileId = audioLike?.file_id;
|
||||
if (!fileId) return null;
|
||||
|
||||
const fileMeta = await callApi<{ ok: boolean; result: TelegramFile }>(token, "getFile", { file_id: fileId });
|
||||
if (!fileMeta.ok || !fileMeta.result.file_path) return null;
|
||||
|
||||
const remotePath = fileMeta.result.file_path;
|
||||
const downloadUrl = `${FILE_API_BASE}${token}/${remotePath}`;
|
||||
debugLog(
|
||||
`Voice download: fileId=${fileId} remotePath=${remotePath} mime=${audioLike.mime_type ?? "unknown"} expectedSize=${audioLike.file_size ?? "unknown"}`
|
||||
);
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) throw new Error(`Telegram file download failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
const dir = join(process.cwd(), ".claude", "claudeclaw", "inbox", "telegram");
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const remoteExt = extname(remotePath);
|
||||
const docExt = extname(message.document?.file_name ?? "");
|
||||
const audioExt = extname(message.audio?.file_name ?? "");
|
||||
const mimeExt = extensionFromAudioMimeType(audioLike.mime_type);
|
||||
const ext = remoteExt || docExt || audioExt || mimeExt || ".ogg";
|
||||
const filename = `${message.chat.id}-${message.message_id}-${Date.now()}${ext}`;
|
||||
const localPath = join(dir, filename);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
await Bun.write(localPath, bytes);
|
||||
const header = Array.from(bytes.slice(0, 8))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(" ");
|
||||
const oggMagic =
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0x4f &&
|
||||
bytes[1] === 0x67 &&
|
||||
bytes[2] === 0x67 &&
|
||||
bytes[3] === 0x53;
|
||||
debugLog(
|
||||
`Voice download: wrote ${bytes.length} bytes to ${localPath} ext=${ext} header=${header || "empty"} oggMagic=${oggMagic}`
|
||||
);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
async function handleMyChatMember(update: TelegramMyChatMemberUpdate): Promise<void> {
|
||||
const config = getSettings().telegram;
|
||||
const chat = update.chat;
|
||||
if (!botUsername && update.new_chat_member.user.username) botUsername = update.new_chat_member.user.username;
|
||||
if (!botId) botId = update.new_chat_member.user.id;
|
||||
const oldStatus = update.old_chat_member.status;
|
||||
const newStatus = update.new_chat_member.status;
|
||||
const isGroup = chat.type === "group" || chat.type === "supergroup";
|
||||
const wasOut = oldStatus === "left" || oldStatus === "kicked";
|
||||
const isIn = newStatus === "member" || newStatus === "administrator";
|
||||
|
||||
if (!isGroup || !wasOut || !isIn) return;
|
||||
|
||||
const chatName = chat.title ?? String(chat.id);
|
||||
console.log(`[Telegram] Added to ${chat.type}: ${chatName} (${chat.id}) by ${update.from.id}`);
|
||||
|
||||
const addedBy = update.from.username ?? `${update.from.first_name} (${update.from.id})`;
|
||||
const eventPrompt =
|
||||
`[Telegram system event] I was added to a ${chat.type}.\n` +
|
||||
`Group title: ${chatName}\n` +
|
||||
`Group id: ${chat.id}\n` +
|
||||
`Added by: ${addedBy}\n` +
|
||||
"Write a short first message for the group. It should confirm I was added and explain how to trigger me.";
|
||||
|
||||
try {
|
||||
const result = await run("telegram", eventPrompt);
|
||||
if (result.exitCode !== 0) {
|
||||
await sendMessage(config.token, chat.id, "I was added to this group. Mention me with a command to start.");
|
||||
return;
|
||||
}
|
||||
await sendMessage(config.token, chat.id, result.stdout || "I was added to this group.");
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] group-added event error: ${err instanceof Error ? err.message : err}`);
|
||||
await sendMessage(config.token, chat.id, "I was added to this group. Mention me with a command to start.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(message: TelegramMessage): Promise<void> {
|
||||
const config = getSettings().telegram;
|
||||
const userId = message.from?.id;
|
||||
const chatId = message.chat.id;
|
||||
const { text } = getMessageTextAndEntities(message);
|
||||
const chatType = message.chat.type;
|
||||
const isPrivate = chatType === "private";
|
||||
const isGroup = chatType === "group" || chatType === "supergroup";
|
||||
const hasImage = Boolean((message.photo && message.photo.length > 0) || isImageDocument(message.document));
|
||||
const hasVoice = Boolean(message.voice || message.audio || isAudioDocument(message.document));
|
||||
|
||||
if (!isPrivate && !isGroup) return;
|
||||
|
||||
const triggerReason = isGroup ? groupTriggerReason(message) : "private_chat";
|
||||
if (isGroup && !triggerReason) {
|
||||
debugLog(
|
||||
`Skip group message chat=${chatId} from=${userId ?? "unknown"} reason=no_trigger text="${(text ?? "").slice(0, 80)}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugLog(
|
||||
`Handle message chat=${chatId} type=${chatType} from=${userId ?? "unknown"} reason=${triggerReason} text="${(text ?? "").slice(0, 80)}"`
|
||||
);
|
||||
|
||||
if (userId && config.allowedUserIds.length > 0 && !config.allowedUserIds.includes(userId)) {
|
||||
if (isPrivate) {
|
||||
await sendMessage(config.token, chatId, "Unauthorized.");
|
||||
} else {
|
||||
console.log(`[Telegram] Ignored group message from unauthorized user ${userId} in chat ${chatId}`);
|
||||
debugLog(`Skip group message chat=${chatId} from=${userId} reason=unauthorized_user`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim() && !hasImage && !hasVoice) {
|
||||
debugLog(`Skip message chat=${chatId} from=${userId ?? "unknown"} reason=empty_text`);
|
||||
return;
|
||||
}
|
||||
|
||||
const command = text ? extractTelegramCommand(text) : null;
|
||||
if (command === "/start") {
|
||||
await sendMessage(
|
||||
config.token,
|
||||
chatId,
|
||||
"Hello! Send me a message and I'll respond using Claude.\nUse /reset to start a fresh session."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "/reset") {
|
||||
await resetSession();
|
||||
await sendMessage(config.token, chatId, "Global session reset. Next message starts fresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
const label = message.from?.username ?? String(userId ?? "unknown");
|
||||
const mediaParts = [hasImage ? "image" : "", hasVoice ? "voice" : ""].filter(Boolean);
|
||||
const mediaSuffix = mediaParts.length > 0 ? ` [${mediaParts.join("+")}]` : "";
|
||||
console.log(
|
||||
`[${new Date().toLocaleTimeString()}] Telegram ${label}${mediaSuffix}: "${text.slice(0, 60)}${text.length > 60 ? "..." : ""}"`
|
||||
);
|
||||
|
||||
// Keep typing indicator alive while queued/running
|
||||
const typingInterval = setInterval(() => sendTyping(config.token, chatId), 4000);
|
||||
|
||||
try {
|
||||
await sendTyping(config.token, chatId);
|
||||
let imagePath: string | null = null;
|
||||
let voicePath: string | null = null;
|
||||
let voiceTranscript: string | null = null;
|
||||
if (hasImage) {
|
||||
try {
|
||||
imagePath = await downloadImageFromMessage(config.token, message);
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] Failed to download image for ${label}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
if (hasVoice) {
|
||||
try {
|
||||
voicePath = await downloadVoiceFromMessage(config.token, message);
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] Failed to download voice for ${label}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
if (voicePath) {
|
||||
try {
|
||||
debugLog(`Voice file saved: path=${voicePath}`);
|
||||
// Voice transcription requires whisper setup - for now, notify user
|
||||
voiceTranscript = "[Voice message received but transcription not configured]";
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] Failed to transcribe voice for ${label}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const promptParts = [`[Telegram from ${label}]`];
|
||||
if (text.trim()) promptParts.push(`Message: ${text}`);
|
||||
if (imagePath) {
|
||||
promptParts.push(`Image path: ${imagePath}`);
|
||||
promptParts.push("The user attached an image. Inspect this image file directly before answering.");
|
||||
} else if (hasImage) {
|
||||
promptParts.push("The user attached an image, but downloading it failed. Respond and ask them to resend.");
|
||||
}
|
||||
if (voiceTranscript) {
|
||||
promptParts.push(`Voice transcript: ${voiceTranscript}`);
|
||||
promptParts.push("The user attached voice audio. Use the transcript as their spoken message.");
|
||||
} else if (hasVoice) {
|
||||
promptParts.push(
|
||||
"The user attached voice audio, but it could not be transcribed. Respond and ask them to resend a clearer clip."
|
||||
);
|
||||
}
|
||||
const prefixedPrompt = promptParts.join("\n");
|
||||
const result = await runUserMessage("telegram", prefixedPrompt);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
await sendMessage(config.token, chatId, `Error (exit ${result.exitCode}): ${result.stderr || "Unknown error"}`);
|
||||
} else {
|
||||
const { cleanedText, reactionEmoji } = extractReactionDirective(result.stdout || "");
|
||||
if (reactionEmoji) {
|
||||
await sendReaction(config.token, chatId, message.message_id, reactionEmoji).catch((err) => {
|
||||
console.error(`[Telegram] Failed to send reaction for ${label}: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
}
|
||||
await sendMessage(config.token, chatId, cleanedText || "(empty response)");
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Telegram] Error for ${label}: ${errMsg}`);
|
||||
await sendMessage(config.token, chatId, `Error: ${errMsg}`);
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Polling loop ---
|
||||
|
||||
let running = true;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
const config = getSettings().telegram;
|
||||
let offset = 0;
|
||||
try {
|
||||
const me = await callApi<{ ok: boolean; result: TelegramMe }>(config.token, "getMe");
|
||||
if (me.ok) {
|
||||
botUsername = me.result.username ?? null;
|
||||
botId = me.result.id;
|
||||
console.log(` Bot: ${botUsername ? `@${botUsername}` : botId}`);
|
||||
console.log(` Group privacy: ${me.result.can_read_all_group_messages ? "disabled (reads all messages)" : "enabled (commands & mentions only)"}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] getMe failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
console.log("Telegram bot started (long polling)");
|
||||
console.log(` Allowed users: ${config.allowedUserIds.length === 0 ? "all" : config.allowedUserIds.join(", ")}`);
|
||||
if (telegramDebug) console.log(" Debug: enabled");
|
||||
|
||||
while (running) {
|
||||
try {
|
||||
const data = await callApi<{ ok: boolean; result: TelegramUpdate[] }>(
|
||||
config.token,
|
||||
"getUpdates",
|
||||
{ offset, timeout: 30, allowed_updates: ["message", "my_chat_member"] }
|
||||
);
|
||||
|
||||
if (!data.ok || !data.result.length) continue;
|
||||
|
||||
for (const update of data.result) {
|
||||
debugLog(
|
||||
`Update ${update.update_id} keys=${Object.keys(update).join(",")}`
|
||||
);
|
||||
offset = update.update_id + 1;
|
||||
const incomingMessages = [
|
||||
update.message,
|
||||
update.edited_message,
|
||||
update.channel_post,
|
||||
update.edited_channel_post,
|
||||
].filter((m): m is TelegramMessage => Boolean(m));
|
||||
for (const incoming of incomingMessages) {
|
||||
handleMessage(incoming).catch((err) => {
|
||||
console.error(`[Telegram] Unhandled: ${err}`);
|
||||
});
|
||||
}
|
||||
if (update.my_chat_member) {
|
||||
handleMyChatMember(update.my_chat_member).catch((err) => {
|
||||
console.error(`[Telegram] my_chat_member unhandled: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!running) break;
|
||||
console.error(`[Telegram] Poll error: ${err instanceof Error ? err.message : err}`);
|
||||
await Bun.sleep(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exports ---
|
||||
|
||||
/** Send a message to a specific chat (used by heartbeat forwarding) */
|
||||
export { sendMessage };
|
||||
|
||||
process.on("SIGTERM", () => { running = false; });
|
||||
process.on("SIGINT", () => { running = false; });
|
||||
|
||||
/** Start polling in-process (called by start.ts when token is configured) */
|
||||
export function startPolling(debug = false): void {
|
||||
telegramDebug = debug;
|
||||
(async () => {
|
||||
await ensureProjectQwenMd();
|
||||
await poll();
|
||||
})().catch((err) => {
|
||||
console.error(`[Telegram] Fatal: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Standalone entry point (bun run src/index.ts telegram) */
|
||||
export async function telegram() {
|
||||
await loadSettings();
|
||||
await ensureProjectQwenMd();
|
||||
await poll();
|
||||
}
|
||||
Reference in New Issue
Block a user