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();
|
||||
}
|
||||
251
src/config.ts
Normal file
251
src/config.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { join, isAbsolute } from "path";
|
||||
import { mkdir } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import { normalizeTimezoneName, resolveTimezoneOffsetMinutes } from "./timezone";
|
||||
|
||||
const HEARTBEAT_DIR = join(process.cwd(), ".qwen", "qwenclaw");
|
||||
const SETTINGS_FILE = join(HEARTBEAT_DIR, "settings.json");
|
||||
const JOBS_DIR = join(HEARTBEAT_DIR, "jobs");
|
||||
const LOGS_DIR = join(HEARTBEAT_DIR, "logs");
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
model: "",
|
||||
api: "",
|
||||
fallback: {
|
||||
model: "",
|
||||
api: "",
|
||||
},
|
||||
timezone: "UTC",
|
||||
timezoneOffsetMinutes: 0,
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
interval: 15,
|
||||
prompt: "",
|
||||
excludeWindows: [],
|
||||
},
|
||||
telegram: {
|
||||
token: "",
|
||||
allowedUserIds: [],
|
||||
},
|
||||
security: {
|
||||
level: "moderate",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
},
|
||||
web: {
|
||||
enabled: false,
|
||||
host: "127.0.0.1",
|
||||
port: 4632,
|
||||
},
|
||||
};
|
||||
|
||||
export interface HeartbeatExcludeWindow {
|
||||
days?: number[];
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatConfig {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
prompt: string;
|
||||
excludeWindows: HeartbeatExcludeWindow[];
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string;
|
||||
allowedUserIds: number[];
|
||||
}
|
||||
|
||||
export type SecurityLevel =
|
||||
| "locked"
|
||||
| "strict"
|
||||
| "moderate"
|
||||
| "unrestricted";
|
||||
|
||||
export interface SecurityConfig {
|
||||
level: SecurityLevel;
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
model: string;
|
||||
api: string;
|
||||
fallback: ModelConfig;
|
||||
timezone: string;
|
||||
timezoneOffsetMinutes: number;
|
||||
heartbeat: HeartbeatConfig;
|
||||
telegram: TelegramConfig;
|
||||
security: SecurityConfig;
|
||||
web: WebConfig;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
model: string;
|
||||
api: string;
|
||||
}
|
||||
|
||||
export interface WebConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
let cached: Settings | null = null;
|
||||
|
||||
export async function initConfig(): Promise<void> {
|
||||
await mkdir(HEARTBEAT_DIR, { recursive: true });
|
||||
await mkdir(JOBS_DIR, { recursive: true });
|
||||
await mkdir(LOGS_DIR, { recursive: true });
|
||||
if (!existsSync(SETTINGS_FILE)) {
|
||||
await Bun.write(SETTINGS_FILE, JSON.stringify(DEFAULT_SETTINGS, null, 2) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_LEVELS = new Set([
|
||||
"locked",
|
||||
"strict",
|
||||
"moderate",
|
||||
"unrestricted",
|
||||
]);
|
||||
|
||||
function parseSettings(raw: Record<string, unknown>): Settings {
|
||||
const securityRaw = raw.security as Record<string, unknown> | undefined;
|
||||
const rawLevel = securityRaw?.level;
|
||||
const level: SecurityLevel =
|
||||
typeof rawLevel === "string" && VALID_LEVELS.has(rawLevel as SecurityLevel)
|
||||
? (rawLevel as SecurityLevel)
|
||||
: "moderate";
|
||||
|
||||
const parsedTimezone = parseTimezone(raw.timezone);
|
||||
|
||||
const heartbeatRaw = raw.heartbeat as Record<string, unknown> | undefined;
|
||||
const telegramRaw = raw.telegram as Record<string, unknown> | undefined;
|
||||
const securityConfigRaw = raw.security as Record<string, unknown> | undefined;
|
||||
const webRaw = raw.web as Record<string, unknown> | undefined;
|
||||
const fallbackRaw = raw.fallback as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
model: typeof raw.model === "string" ? raw.model.trim() : "",
|
||||
api: typeof raw.api === "string" ? raw.api.trim() : "",
|
||||
fallback: {
|
||||
model:
|
||||
typeof fallbackRaw?.model === "string" ? fallbackRaw.model.trim() : "",
|
||||
api:
|
||||
typeof fallbackRaw?.api === "string" ? fallbackRaw.api.trim() : "",
|
||||
},
|
||||
timezone: parsedTimezone,
|
||||
timezoneOffsetMinutes: parseTimezoneOffsetMinutes(
|
||||
raw.timezoneOffsetMinutes,
|
||||
parsedTimezone
|
||||
),
|
||||
heartbeat: {
|
||||
enabled: Boolean(heartbeatRaw?.enabled),
|
||||
interval: Number(heartbeatRaw?.interval) || 15,
|
||||
prompt: String(heartbeatRaw?.prompt ?? ""),
|
||||
excludeWindows: parseExcludeWindows(heartbeatRaw?.excludeWindows),
|
||||
},
|
||||
telegram: {
|
||||
token: String(telegramRaw?.token ?? ""),
|
||||
allowedUserIds: Array.isArray(telegramRaw?.allowedUserIds) ? telegramRaw.allowedUserIds as number[] : [],
|
||||
},
|
||||
security: {
|
||||
level,
|
||||
allowedTools: Array.isArray(securityConfigRaw?.allowedTools)
|
||||
? securityConfigRaw.allowedTools as string[]
|
||||
: [],
|
||||
disallowedTools: Array.isArray(securityConfigRaw?.disallowedTools)
|
||||
? securityConfigRaw.disallowedTools as string[]
|
||||
: [],
|
||||
},
|
||||
web: {
|
||||
enabled: Boolean(webRaw?.enabled),
|
||||
host: String(webRaw?.host ?? "127.0.0.1"),
|
||||
port: webRaw && Number.isFinite(webRaw.port) ? Number(webRaw.port) : 4632,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
function parseTimezone(value: unknown): string {
|
||||
return normalizeTimezoneName(value);
|
||||
}
|
||||
|
||||
function parseExcludeWindows(value: unknown): HeartbeatExcludeWindow[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: HeartbeatExcludeWindow[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const start =
|
||||
typeof (entry as any).start === "string" ? (entry as any).start.trim() : "";
|
||||
const end =
|
||||
typeof (entry as any).end === "string" ? (entry as any).end.trim() : "";
|
||||
if (!TIME_RE.test(start) || !TIME_RE.test(end)) continue;
|
||||
const rawDays = Array.isArray((entry as any).days) ? (entry as any).days : [];
|
||||
const parsedDays = rawDays
|
||||
.map((d: any) => Number(d))
|
||||
.filter((d: any): d is number => Number.isInteger(d) && d >= 0 && d <= 6);
|
||||
const uniqueDays = Array.from(new Set(parsedDays)) as number[];
|
||||
uniqueDays.sort((a, b) => a - b);
|
||||
out.push({
|
||||
start,
|
||||
end,
|
||||
days: uniqueDays.length > 0 ? uniqueDays : [...ALL_DAYS],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseTimezoneOffsetMinutes(
|
||||
value: unknown,
|
||||
timezoneFallback?: string
|
||||
): number {
|
||||
return resolveTimezoneOffsetMinutes(value, timezoneFallback);
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
if (cached) return cached;
|
||||
const raw = await Bun.file(SETTINGS_FILE).json();
|
||||
cached = parseSettings(raw);
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Re-read settings from disk, bypassing cache. */
|
||||
export async function reloadSettings(): Promise<Settings> {
|
||||
const raw = await Bun.file(SETTINGS_FILE).json();
|
||||
cached = parseSettings(raw);
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function getSettings(): Settings {
|
||||
if (!cached)
|
||||
throw new Error("Settings not loaded. Call loadSettings() first.");
|
||||
return cached;
|
||||
}
|
||||
|
||||
const PROMPT_EXTENSIONS = [".md", ".txt", ".prompt"];
|
||||
|
||||
/**
|
||||
* If the prompt string looks like a file path (ends with .md, .txt, or .prompt),
|
||||
* read and return the file contents. Otherwise return the string as-is.
|
||||
* Relative paths are resolved from the project root (cwd).
|
||||
*/
|
||||
export async function resolvePrompt(prompt: string): Promise<string> {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const isPath = PROMPT_EXTENSIONS.some((ext) => trimmed.endsWith(ext));
|
||||
if (!isPath) return trimmed;
|
||||
const resolved = isAbsolute(trimmed) ? trimmed : join(process.cwd(), trimmed);
|
||||
try {
|
||||
const content = await Bun.file(resolved).text();
|
||||
return content.trim();
|
||||
} catch {
|
||||
console.warn(
|
||||
`[config] Prompt path "${trimmed}" not found, using as literal string`
|
||||
);
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
65
src/cron.ts
Normal file
65
src/cron.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { shiftDateToOffset } from "./timezone";
|
||||
|
||||
function matchCronField(field: string, value: number): boolean {
|
||||
for (const part of field.split(",")) {
|
||||
const [range, stepStr] = part.split("/");
|
||||
const step = stepStr ? parseInt(stepStr) : 1;
|
||||
|
||||
if (range === "*") {
|
||||
if (value % step === 0) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (range.includes("-")) {
|
||||
const [lo, hi] = range.split("-").map(Number);
|
||||
if (value >= lo && value <= hi && (value - lo) % step === 0) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parseInt(range) === value) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function cronMatches(
|
||||
expr: string,
|
||||
date: Date,
|
||||
timezoneOffsetMinutes = 0
|
||||
): boolean {
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = expr.trim().split(/\s+/);
|
||||
|
||||
const shifted = shiftDateToOffset(date, timezoneOffsetMinutes);
|
||||
const d = {
|
||||
minute: shifted.getUTCMinutes(),
|
||||
hour: shifted.getUTCHours(),
|
||||
dayOfMonth: shifted.getUTCDate(),
|
||||
month: shifted.getUTCMonth() + 1,
|
||||
dayOfWeek: shifted.getUTCDay(),
|
||||
};
|
||||
|
||||
return (
|
||||
matchCronField(minute, d.minute) &&
|
||||
matchCronField(hour, d.hour) &&
|
||||
matchCronField(dayOfMonth, d.dayOfMonth) &&
|
||||
matchCronField(month, d.month) &&
|
||||
matchCronField(dayOfWeek, d.dayOfWeek)
|
||||
);
|
||||
}
|
||||
|
||||
export function nextCronMatch(
|
||||
expr: string,
|
||||
after: Date,
|
||||
timezoneOffsetMinutes = 0
|
||||
): Date {
|
||||
const d = new Date(after);
|
||||
d.setSeconds(0, 0);
|
||||
d.setMinutes(d.getMinutes() + 1);
|
||||
|
||||
for (let i = 0; i < 2880; i++) {
|
||||
if (cronMatches(expr, d, timezoneOffsetMinutes)) return d;
|
||||
d.setMinutes(d.getMinutes() + 1);
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
26
src/index.ts
Normal file
26
src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { start } from "./commands/start";
|
||||
import { stop, stopAll, clear } from "./commands/stop";
|
||||
import { status } from "./commands/status";
|
||||
import { telegram } from "./commands/telegram";
|
||||
import { send } from "./commands/send";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (command === "--stop-all") {
|
||||
stopAll();
|
||||
} else if (command === "--stop") {
|
||||
stop();
|
||||
} else if (command === "--clear") {
|
||||
clear();
|
||||
} else if (command === "start") {
|
||||
start(args.slice(1));
|
||||
} else if (command === "status") {
|
||||
status();
|
||||
} else if (command === "telegram") {
|
||||
telegram();
|
||||
} else if (command === "send") {
|
||||
send(args.slice(1));
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
94
src/jobs.ts
Normal file
94
src/jobs.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { readdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
const JOBS_DIR = join(process.cwd(), ".qwen", "qwenclaw", "jobs");
|
||||
|
||||
export interface Job {
|
||||
name: string;
|
||||
schedule: string;
|
||||
prompt: string;
|
||||
recurring: boolean;
|
||||
notify: true | false | "error";
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(raw: string): string {
|
||||
return raw.trim().replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
function parseJobFile(name: string, content: string): Job | null {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
||||
if (!match) {
|
||||
console.error(`Invalid job file format: ${name}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontmatter = match[1];
|
||||
const prompt = match[2].trim();
|
||||
const lines = frontmatter.split("\n").map((l) => l.trim());
|
||||
|
||||
const scheduleLine = lines.find((l) => l.startsWith("schedule:"));
|
||||
if (!scheduleLine) {
|
||||
return null;
|
||||
}
|
||||
const schedule = parseFrontmatterValue(scheduleLine.replace("schedule:", ""));
|
||||
|
||||
const recurringLine = lines.find((l) => l.startsWith("recurring:"));
|
||||
const dailyLine = lines.find((l) => l.startsWith("daily:")); // legacy alias
|
||||
const recurringRaw = recurringLine
|
||||
? parseFrontmatterValue(recurringLine.replace("recurring:", "")).toLowerCase()
|
||||
: dailyLine
|
||||
? parseFrontmatterValue(dailyLine.replace("daily:", "")).toLowerCase()
|
||||
: "";
|
||||
const recurring =
|
||||
recurringRaw === "true" || recurringRaw === "yes" || recurringRaw === "1";
|
||||
|
||||
const notifyLine = lines.find((l) => l.startsWith("notify:"));
|
||||
const notifyRaw = notifyLine
|
||||
? parseFrontmatterValue(notifyLine.replace("notify:", "")).toLowerCase()
|
||||
: "";
|
||||
const notify: true | false | "error" =
|
||||
notifyRaw === "false" || notifyRaw === "no"
|
||||
? false
|
||||
: notifyRaw === "error"
|
||||
? "error"
|
||||
: true;
|
||||
|
||||
return { name, schedule, prompt, recurring, notify };
|
||||
}
|
||||
|
||||
export async function loadJobs(): Promise<Job[]> {
|
||||
const jobs: Job[] = [];
|
||||
let files: string[];
|
||||
|
||||
try {
|
||||
files = await readdir(JOBS_DIR);
|
||||
} catch {
|
||||
return jobs;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".md")) continue;
|
||||
const content = await Bun.file(join(JOBS_DIR, file)).text();
|
||||
const job = parseJobFile(file.replace(/\.md$/, ""), content);
|
||||
if (job) jobs.push(job);
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export async function clearJobSchedule(jobName: string): Promise<void> {
|
||||
const path = join(JOBS_DIR, `${jobName}.md`);
|
||||
const content = await Bun.file(path).text();
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
||||
if (!match) return;
|
||||
|
||||
const filteredFrontmatter = match[1]
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("schedule:"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
const body = match[2].trim();
|
||||
const next = `---\n${filteredFrontmatter}\n---\n${body}\n`;
|
||||
await Bun.write(path, next);
|
||||
}
|
||||
49
src/pid.ts
Normal file
49
src/pid.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { writeFile, unlink, readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
const PID_FILE = join(process.cwd(), ".qwen", "qwenclaw", "daemon.pid");
|
||||
|
||||
export function getPidPath(): string {
|
||||
return PID_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a daemon is already running in this directory.
|
||||
* If a stale PID file exists (process dead), it gets cleaned up.
|
||||
* Returns the running PID if alive, or null.
|
||||
*/
|
||||
export async function checkExistingDaemon(): Promise<number | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = (await readFile(PID_FILE, "utf-8")).trim();
|
||||
} catch {
|
||||
return null; // no pid file
|
||||
}
|
||||
|
||||
const pid = Number(raw);
|
||||
if (!pid || isNaN(pid)) {
|
||||
await cleanupPidFile();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0); // signal 0 = just check if alive
|
||||
return pid;
|
||||
} catch {
|
||||
// process is dead, clean up stale pid file
|
||||
await cleanupPidFile();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePidFile(): Promise<void> {
|
||||
await writeFile(PID_FILE, String(process.pid) + "\n");
|
||||
}
|
||||
|
||||
export async function cleanupPidFile(): Promise<void> {
|
||||
try {
|
||||
await unlink(PID_FILE);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
103
src/preflight.ts
Normal file
103
src/preflight.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// preflight.ts — Install Qwen Code plugins on first run
|
||||
// Skips any plugin that is already installed.
|
||||
|
||||
import { execSync, type ExecSyncOptions } from "child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
copyFileSync,
|
||||
rmSync,
|
||||
renameSync,
|
||||
type Dirent,
|
||||
} from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { homedir, tmpdir } from "os";
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────
|
||||
const PLUGINS_DIR = join(homedir(), ".qwen", "plugins");
|
||||
const INST_FILE = join(PLUGINS_DIR, "installed_plugins.json");
|
||||
|
||||
interface PluginEntry {
|
||||
scope: string;
|
||||
installPath: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
lastUpdated: string;
|
||||
gitCommitSha: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
interface InstalledPlugins {
|
||||
version: number;
|
||||
plugins: Record<string, PluginEntry[]>;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function run(cmd: string, opts: ExecSyncOptions = {}): string {
|
||||
const result = execSync(cmd, { encoding: "utf-8", stdio: "pipe", ...opts });
|
||||
return (result ?? "").toString().trim();
|
||||
}
|
||||
|
||||
function readJSON<T>(filePath: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJSON(filePath: string, data: unknown): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function detectPkgManager(): string | null {
|
||||
try { run("bun --version"); return "bun"; } catch {}
|
||||
try { run("npm --version"); return "npm"; } catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEnabledInProject(pluginKey: string, projectPath: string): boolean {
|
||||
const projSettings = join(projectPath, ".qwen", "settings.json");
|
||||
const settings = readJSON<Record<string, unknown>>(projSettings, {});
|
||||
const enabled = settings.enabledPlugins as Record<string, boolean> | undefined;
|
||||
return !!enabled?.[pluginKey];
|
||||
}
|
||||
|
||||
function enableInProject(pluginKey: string, projectPath: string): void {
|
||||
const projSettings = join(projectPath, ".qwen", "settings.json");
|
||||
const settings = readJSON<Record<string, unknown>>(projSettings, {});
|
||||
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
||||
(settings.enabledPlugins as Record<string, boolean>)[pluginKey] = true;
|
||||
writeJSON(projSettings, settings);
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
export function preflight(projectPath: string): void {
|
||||
try { run("git --version"); } catch {
|
||||
console.error("preflight: git is required but not installed.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkgMgr = detectPkgManager();
|
||||
if (!pkgMgr) {
|
||||
console.error("preflight: bun or npm is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
|
||||
console.log(`preflight: QwenClaw initialized for project at ${projectPath}`);
|
||||
console.log("preflight: Configure your settings in .qwen/qwenclaw/settings.json");
|
||||
}
|
||||
|
||||
// Allow standalone: bun run src/preflight.ts [project-path]
|
||||
if (import.meta.main) {
|
||||
preflight(process.argv[2] || process.cwd());
|
||||
}
|
||||
327
src/runner.ts
Normal file
327
src/runner.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { mkdir, readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { getSession, createSession } from "./sessions";
|
||||
import { getSettings, type ModelConfig, type SecurityConfig } from "./config";
|
||||
import { buildClockPromptPrefix } from "./timezone";
|
||||
|
||||
const LOGS_DIR = join(process.cwd(), ".qwen/qwenclaw/logs");
|
||||
// Resolve prompts relative to the qwenclaw installation, not the project dir
|
||||
const PROMPTS_DIR = join(import.meta.dir, "..", "prompts");
|
||||
const HEARTBEAT_PROMPT_FILE = join(PROMPTS_DIR, "heartbeat", "HEARTBEAT.md");
|
||||
const PROJECT_QWEN_MD = join(process.cwd(), "QWEN.md");
|
||||
const LEGACY_PROJECT_QWEN_MD = join(process.cwd(), ".qwen", "QWEN.md");
|
||||
const QWENCLAW_BLOCK_START = "<!-- QWENCLAW_MANAGED_BLOCK_START -->";
|
||||
const QWENCLAW_BLOCK_END = "<!-- QWENCLAW_MANAGED_BLOCK_END -->";
|
||||
|
||||
export interface RunResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
const RATE_LIMIT_PATTERN = /you(?:'|')ve hit your limit/i;
|
||||
|
||||
// Serial queue — prevents concurrent --resume on the same session
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const task = queue.then(fn, fn);
|
||||
queue = task.catch((): Promise<unknown> => Promise.resolve());
|
||||
return task;
|
||||
}
|
||||
|
||||
function extractRateLimitMessage(stdout: string, stderr: string): string | null {
|
||||
const candidates = [stdout, stderr];
|
||||
for (const text of candidates) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed && RATE_LIMIT_PATTERN.test(trimmed)) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameModelConfig(a: ModelConfig, b: ModelConfig): boolean {
|
||||
return a.model.trim().toLowerCase() === b.model.trim().toLowerCase() && a.api.trim() === b.api.trim();
|
||||
}
|
||||
|
||||
function hasModelConfig(value: ModelConfig): boolean {
|
||||
return value.model.trim().length > 0 || value.api.trim().length > 0;
|
||||
}
|
||||
|
||||
function buildChildEnv(
|
||||
baseEnv: Record<string, string>,
|
||||
model: string,
|
||||
api: string
|
||||
): Record<string, string> {
|
||||
const childEnv: Record<string, string> = { ...baseEnv };
|
||||
if (api.trim()) childEnv.ANTHROPIC_AUTH_TOKEN = api.trim();
|
||||
// Add any Qwen-specific environment variables here
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
async function runQwenOnce(
|
||||
baseArgs: string[],
|
||||
model: string,
|
||||
api: string,
|
||||
baseEnv: Record<string, string>
|
||||
): Promise<{ rawStdout: string; stderr: string; exitCode: number }> {
|
||||
const args = [...baseArgs];
|
||||
if (model.trim()) args.push("--model", model.trim());
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: buildChildEnv(baseEnv, model, api),
|
||||
});
|
||||
|
||||
const [rawStdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
|
||||
await proc.exited;
|
||||
return { rawStdout, stderr, exitCode: proc.exitCode ?? 1 };
|
||||
}
|
||||
|
||||
const PROJECT_DIR = process.cwd();
|
||||
const DIR_SCOPE_PROMPT = [
|
||||
`CRITICAL SECURITY CONSTRAINT: You are scoped to the project directory: ${PROJECT_DIR}`,
|
||||
"You MUST NOT read, write, edit, or delete any file outside this directory.",
|
||||
"You MUST NOT run bash commands that modify anything outside this directory (no cd /, no /etc, no ~/, no ../.. escapes).",
|
||||
"If a request requires accessing files outside the project, refuse and explain why.",
|
||||
].join("\n");
|
||||
|
||||
export async function ensureProjectQwenMd(): Promise<void> {
|
||||
// Preflight-only initialization: never rewrite an existing project QWEN.md.
|
||||
if (existsSync(PROJECT_QWEN_MD)) return;
|
||||
|
||||
const promptContent = (await loadPrompts()).trim();
|
||||
const managedBlock = [
|
||||
QWENCLAW_BLOCK_START,
|
||||
promptContent,
|
||||
QWENCLAW_BLOCK_END,
|
||||
].join("\n");
|
||||
|
||||
let content = "";
|
||||
if (existsSync(LEGACY_PROJECT_QWEN_MD)) {
|
||||
try {
|
||||
const legacy = await readFile(LEGACY_PROJECT_QWEN_MD, "utf8");
|
||||
content = legacy.trim();
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toLocaleTimeString()}] Failed to read legacy .qwen/QWEN.md:`, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = content.trim();
|
||||
const hasManagedBlock =
|
||||
normalized.includes(QWENCLAW_BLOCK_START) &&
|
||||
normalized.includes(QWENCLAW_BLOCK_END);
|
||||
|
||||
const managedPattern = new RegExp(
|
||||
`${QWENCLAW_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${QWENCLAW_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
|
||||
"m"
|
||||
);
|
||||
|
||||
const merged = hasManagedBlock
|
||||
? `${normalized.replace(managedPattern, managedBlock)}\n`
|
||||
: normalized
|
||||
? `${normalized}\n\n${managedBlock}\n`
|
||||
: `${managedBlock}\n`;
|
||||
|
||||
try {
|
||||
await writeFile(PROJECT_QWEN_MD, merged, "utf8");
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toLocaleTimeString()}] Failed to write project QWEN.md:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSecurityArgs(security: SecurityConfig): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Qwen-specific security flags - adjust based on Qwen's CLI options
|
||||
if (security.allowedTools.length > 0) {
|
||||
args.push("--allowed-tools", security.allowedTools.join(","));
|
||||
}
|
||||
|
||||
if (security.disallowedTools.length > 0) {
|
||||
args.push("--disallowed-tools", security.disallowedTools.join(","));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Load and concatenate all prompt files from the prompts/ directory. */
|
||||
async function loadPrompts(): Promise<string> {
|
||||
const selectedPromptFiles = [
|
||||
join(PROMPTS_DIR, "IDENTITY.md"),
|
||||
join(PROMPTS_DIR, "USER.md"),
|
||||
join(PROMPTS_DIR, "SOUL.md"),
|
||||
];
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const file of selectedPromptFiles) {
|
||||
try {
|
||||
const content = await Bun.file(file).text();
|
||||
if (content.trim()) parts.push(content.trim());
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toLocaleTimeString()}] Failed to read prompt file ${file}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export async function loadHeartbeatPromptTemplate(): Promise<string> {
|
||||
try {
|
||||
const content = await Bun.file(HEARTBEAT_PROMPT_FILE).text();
|
||||
return content.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function execQwen(name: string, prompt: string): Promise<RunResult> {
|
||||
await mkdir(LOGS_DIR, { recursive: true });
|
||||
|
||||
const existing = await getSession();
|
||||
const isNew = !existing;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const logFile = join(LOGS_DIR, `${name}-${timestamp}.log`);
|
||||
|
||||
const { security, model, api, fallback } = getSettings();
|
||||
const primaryConfig: ModelConfig = { model, api };
|
||||
const fallbackConfig: ModelConfig = {
|
||||
model: fallback?.model ?? "",
|
||||
api: fallback?.api ?? "",
|
||||
};
|
||||
|
||||
const securityArgs = buildSecurityArgs(security);
|
||||
|
||||
console.log(
|
||||
`[${new Date().toLocaleTimeString()}] Running: ${name} (${isNew ? "new session" : `resume ${existing.sessionId.slice(0, 8)}`}, security: ${security.level})`
|
||||
);
|
||||
|
||||
// Build the base command for Qwen
|
||||
const args = [
|
||||
"qwen",
|
||||
"-p",
|
||||
prompt,
|
||||
...securityArgs,
|
||||
];
|
||||
|
||||
if (!isNew) {
|
||||
args.push("--resume", existing.sessionId);
|
||||
}
|
||||
|
||||
// Build the appended system prompt: prompt files + directory scoping
|
||||
const promptContent = await loadPrompts();
|
||||
const appendParts: string[] = ["You are running inside QwenClaw."];
|
||||
if (promptContent) appendParts.push(promptContent);
|
||||
|
||||
// Load the project's QWEN.md if it exists
|
||||
if (existsSync(PROJECT_QWEN_MD)) {
|
||||
try {
|
||||
const qwenMd = await Bun.file(PROJECT_QWEN_MD).text();
|
||||
if (qwenMd.trim()) appendParts.push(qwenMd.trim());
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toLocaleTimeString()}] Failed to read project QWEN.md:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (security.level !== "unrestricted") appendParts.push(DIR_SCOPE_PROMPT);
|
||||
|
||||
if (appendParts.length > 0) {
|
||||
args.push("--system-prompt", appendParts.join("\n\n"));
|
||||
}
|
||||
|
||||
// Strip any nested env vars
|
||||
const { QWEN_CODE: _, ...cleanEnv } = process.env;
|
||||
const baseEnv = { ...cleanEnv } as Record<string, string>;
|
||||
|
||||
let exec = await runQwenOnce(args, primaryConfig.model, primaryConfig.api, baseEnv);
|
||||
const primaryRateLimit = extractRateLimitMessage(exec.rawStdout, exec.stderr);
|
||||
|
||||
let usedFallback = false;
|
||||
if (
|
||||
primaryRateLimit &&
|
||||
hasModelConfig(fallbackConfig) &&
|
||||
!sameModelConfig(primaryConfig, fallbackConfig)
|
||||
) {
|
||||
console.warn(
|
||||
`[${new Date().toLocaleTimeString()}] Qwen limit reached; retrying with fallback${fallbackConfig.model ? ` (${fallbackConfig.model})` : ""}...`
|
||||
);
|
||||
exec = await runQwenOnce(args, fallbackConfig.model, fallbackConfig.api, baseEnv);
|
||||
usedFallback = true;
|
||||
}
|
||||
|
||||
const rawStdout = exec.rawStdout;
|
||||
const stderr = exec.stderr;
|
||||
const exitCode = exec.exitCode;
|
||||
let stdout = rawStdout;
|
||||
let sessionId = existing?.sessionId ?? "unknown";
|
||||
|
||||
const rateLimitMessage = extractRateLimitMessage(rawStdout, stderr);
|
||||
if (rateLimitMessage) {
|
||||
stdout = rateLimitMessage;
|
||||
}
|
||||
|
||||
// For new sessions, try to capture session ID if Qwen provides one
|
||||
if (!rateLimitMessage && isNew && exitCode === 0) {
|
||||
// Try to parse session ID from output or use a generated one
|
||||
sessionId = `qwenclaw-${Date.now()}`;
|
||||
await createSession(sessionId);
|
||||
console.log(`[${new Date().toLocaleTimeString()}] Session created: ${sessionId}`);
|
||||
}
|
||||
|
||||
const result: RunResult = { stdout, stderr, exitCode };
|
||||
|
||||
const output = [
|
||||
`# ${name}`,
|
||||
`Date: ${new Date().toISOString()}`,
|
||||
`Session: ${sessionId} (${isNew ? "new" : "resumed"})`,
|
||||
`Model config: ${usedFallback ? "fallback" : "primary"}`,
|
||||
`Prompt: ${prompt}`,
|
||||
`Exit code: ${result.exitCode}`,
|
||||
"",
|
||||
"## Output",
|
||||
stdout,
|
||||
...(stderr ? ["## Stderr", stderr] : []),
|
||||
].join("\n");
|
||||
|
||||
await Bun.write(logFile, output);
|
||||
console.log(`[${new Date().toLocaleTimeString()}] Done: ${name} → ${logFile}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function run(name: string, prompt: string): Promise<RunResult> {
|
||||
return enqueue(() => execQwen(name, prompt));
|
||||
}
|
||||
|
||||
function prefixUserMessageWithClock(prompt: string): string {
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const prefix = buildClockPromptPrefix(new Date(), settings.timezoneOffsetMinutes);
|
||||
return `${prefix}\n${prompt}`;
|
||||
} catch {
|
||||
const prefix = buildClockPromptPrefix(new Date(), 0);
|
||||
return `${prefix}\n${prompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUserMessage(name: string, prompt: string): Promise<RunResult> {
|
||||
return run(name, prefixUserMessageWithClock(prompt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap the session: fires Qwen with the system prompt so the
|
||||
* session is created immediately. No-op if a session already exists.
|
||||
*/
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const existing = await getSession();
|
||||
if (existing) return;
|
||||
|
||||
console.log(`[${new Date().toLocaleTimeString()}] Bootstrapping new session...`);
|
||||
await execQwen("bootstrap", "Wakeup, my friend!");
|
||||
console.log(`[${new Date().toLocaleTimeString()}] Bootstrap complete — session is live.`);
|
||||
}
|
||||
87
src/sessions.ts
Normal file
87
src/sessions.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { join } from "path";
|
||||
import { unlink, readdir, rename } from "fs/promises";
|
||||
|
||||
const HEARTBEAT_DIR = join(process.cwd(), ".qwen", "qwenclaw");
|
||||
const SESSION_FILE = join(HEARTBEAT_DIR, "session.json");
|
||||
|
||||
export interface GlobalSession {
|
||||
sessionId: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
let current: GlobalSession | null = null;
|
||||
|
||||
async function loadSession(): Promise<GlobalSession | null> {
|
||||
if (current) return current;
|
||||
try {
|
||||
current = await Bun.file(SESSION_FILE).json();
|
||||
return current;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSession(session: GlobalSession): Promise<void> {
|
||||
current = session;
|
||||
await Bun.write(SESSION_FILE, JSON.stringify(session, null, 2) + "\n");
|
||||
}
|
||||
|
||||
/** Returns the existing session or null. Never creates one. */
|
||||
export async function getSession(): Promise<{ sessionId: string } | null> {
|
||||
const existing = await loadSession();
|
||||
if (existing) {
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
await saveSession(existing);
|
||||
return { sessionId: existing.sessionId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Save a session ID obtained from Qwen's output. */
|
||||
export async function createSession(sessionId: string): Promise<void> {
|
||||
await saveSession({
|
||||
sessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns session metadata without mutating lastUsedAt. */
|
||||
export async function peekSession(): Promise<GlobalSession | null> {
|
||||
return await loadSession();
|
||||
}
|
||||
|
||||
export async function resetSession(): Promise<void> {
|
||||
current = null;
|
||||
try {
|
||||
await unlink(SESSION_FILE);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupSession(): Promise<string | null> {
|
||||
const existing = await loadSession();
|
||||
if (!existing) return null;
|
||||
|
||||
// Find next backup index
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(HEARTBEAT_DIR);
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
|
||||
const indices = files
|
||||
.filter((f) => /^session_\d+\.backup$/.test(f))
|
||||
.map((f) => Number(f.match(/^session_(\d+)\.backup$/)![1]));
|
||||
|
||||
const nextIndex = indices.length > 0 ? Math.max(...indices) + 1 : 1;
|
||||
const backupName = `session_${nextIndex}.backup`;
|
||||
const backupPath = join(HEARTBEAT_DIR, backupName);
|
||||
|
||||
await rename(SESSION_FILE, backupPath);
|
||||
current = null;
|
||||
return backupName;
|
||||
}
|
||||
20
src/statusline.ts
Normal file
20
src/statusline.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { join } from "path";
|
||||
|
||||
const HEARTBEAT_DIR = join(process.cwd(), ".qwen", "qwenclaw");
|
||||
|
||||
// Write state.json so the statusline script can read fresh data
|
||||
export interface StateData {
|
||||
heartbeat?: { nextAt: number };
|
||||
jobs: { name: string; nextAt: number }[];
|
||||
security: string;
|
||||
telegram: boolean;
|
||||
startedAt: number;
|
||||
web?: { enabled: boolean; host: string; port: number };
|
||||
}
|
||||
|
||||
export async function writeState(state: StateData) {
|
||||
await Bun.write(
|
||||
join(HEARTBEAT_DIR, "state.json"),
|
||||
JSON.stringify(state) + "\n"
|
||||
);
|
||||
}
|
||||
105
src/timezone.ts
Normal file
105
src/timezone.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
const MIN_OFFSET_MINUTES = -12 * 60;
|
||||
const MAX_OFFSET_MINUTES = 14 * 60;
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function clampTimezoneOffsetMinutes(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(MIN_OFFSET_MINUTES, Math.min(MAX_OFFSET_MINUTES, Math.round(value)));
|
||||
}
|
||||
|
||||
export function parseUtcOffsetMinutes(value: unknown): number | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toUpperCase().replace(/\s+/g, "");
|
||||
if (normalized === "UTC" || normalized === "GMT") return 0;
|
||||
const match = normalized.match(/^(UTC|GMT)([+-])(\d{1,2})(?::?([0-5]\d))?$/);
|
||||
if (!match) return null;
|
||||
const sign = match[2] === "-" ? -1 : 1;
|
||||
const hours = Number(match[3]);
|
||||
const minutes = Number(match[4] ?? "0");
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes) || hours > 14) return null;
|
||||
const total = sign * (hours * 60 + minutes);
|
||||
return total < MIN_OFFSET_MINUTES || total > MAX_OFFSET_MINUTES ? null : total;
|
||||
}
|
||||
|
||||
export function normalizeTimezoneName(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
const parsedOffset = parseUtcOffsetMinutes(trimmed);
|
||||
if (parsedOffset != null) return trimmed.toUpperCase().replace(/\s+/g, "");
|
||||
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
|
||||
return trimmed;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimezoneOffsetMinutes(value: unknown, timezoneFallback?: string): number {
|
||||
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : NaN;
|
||||
if (Number.isFinite(n)) return clampTimezoneOffsetMinutes(n);
|
||||
const parsedFallback = parseUtcOffsetMinutes(timezoneFallback);
|
||||
if (parsedFallback != null) return parsedFallback;
|
||||
const ianaFallback = getCurrentOffsetMinutesForIanaTimezone(timezoneFallback);
|
||||
return ianaFallback == null ? 0 : ianaFallback;
|
||||
}
|
||||
|
||||
export function shiftDateToOffset(date: Date, timezoneOffsetMinutes: number): Date {
|
||||
return new Date(date.getTime() + clampTimezoneOffsetMinutes(timezoneOffsetMinutes) * 60_000);
|
||||
}
|
||||
|
||||
export function formatUtcOffsetLabel(timezoneOffsetMinutes: number): string {
|
||||
const clamped = clampTimezoneOffsetMinutes(timezoneOffsetMinutes);
|
||||
const sign = clamped >= 0 ? "+" : "-";
|
||||
const abs = Math.abs(clamped);
|
||||
const hours = Math.floor(abs / 60);
|
||||
const minutes = abs % 60;
|
||||
return minutes === 0
|
||||
? `UTC${sign}${hours}`
|
||||
: `UTC${sign}${hours}:${pad2(minutes)}`;
|
||||
}
|
||||
|
||||
export function buildClockPromptPrefix(date: Date, timezoneOffsetMinutes: number): string {
|
||||
const shifted = shiftDateToOffset(date, timezoneOffsetMinutes);
|
||||
const offsetLabel = formatUtcOffsetLabel(timezoneOffsetMinutes);
|
||||
const timestamp = [
|
||||
`${shifted.getUTCFullYear()}-${pad2(shifted.getUTCMonth() + 1)}-${pad2(shifted.getUTCDate())}`,
|
||||
`${pad2(shifted.getUTCHours())}:${pad2(shifted.getUTCMinutes())}:${pad2(shifted.getUTCSeconds())}`,
|
||||
].join(" ");
|
||||
|
||||
return `[${timestamp} ${offsetLabel}]`;
|
||||
}
|
||||
|
||||
export function getDayAndMinuteAtOffset(date: Date, timezoneOffsetMinutes: number): { day: number; minute: number } {
|
||||
const shifted = shiftDateToOffset(date, timezoneOffsetMinutes);
|
||||
return {
|
||||
day: shifted.getUTCDay(),
|
||||
minute: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentOffsetMinutesForIanaTimezone(timezone: unknown): number | null {
|
||||
if (typeof timezone !== "string" || !timezone.trim()) return null;
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
timeZoneName: "shortOffset",
|
||||
hour: "2-digit",
|
||||
}).formatToParts(new Date());
|
||||
const token = parts.find((p) => p.type === "timeZoneName")?.value ?? "";
|
||||
const match = token.match(/^GMT([+-])(\d{1,2})(?::?([0-5]\d))?$/i);
|
||||
if (!match) return null;
|
||||
const sign = match[1] === "-" ? -1 : 1;
|
||||
const hours = Number(match[2]);
|
||||
const minutes = Number(match[3] ?? "0");
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
||||
return clampTimezoneOffsetMinutes(sign * (hours * 60 + minutes));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
8
src/ui/constants.ts
Normal file
8
src/ui/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { join } from "path";
|
||||
|
||||
export const HEARTBEAT_DIR = join(process.cwd(), ".qwen", "qwenclaw");
|
||||
export const LOGS_DIR = join(HEARTBEAT_DIR, "logs");
|
||||
export const JOBS_DIR = join(HEARTBEAT_DIR, "jobs");
|
||||
export const SETTINGS_FILE = join(HEARTBEAT_DIR, "settings.json");
|
||||
export const SESSION_FILE = join(HEARTBEAT_DIR, "session.json");
|
||||
export const STATE_FILE = join(HEARTBEAT_DIR, "state.json");
|
||||
11
src/ui/http.ts
Normal file
11
src/ui/http.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function json(data: unknown): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function clampInt(raw: string | null | undefined, fallback: number, min: number, max: number): number {
|
||||
const n = raw ? Number(raw) : fallback;
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.trunc(n)));
|
||||
}
|
||||
2
src/ui/index.ts
Normal file
2
src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { startWebUi } from "./server";
|
||||
export type { StartWebUiOptions, WebServerHandle, WebSnapshot } from "./types";
|
||||
1
src/ui/page/html.ts
Normal file
1
src/ui/page/html.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { htmlPage } from "./template";
|
||||
924
src/ui/page/script.ts
Normal file
924
src/ui/page/script.ts
Normal file
@@ -0,0 +1,924 @@
|
||||
export const pageScript = String.raw` const $ = (id) => document.getElementById(id);
|
||||
|
||||
const clockEl = $("clock");
|
||||
const dateEl = $("date");
|
||||
const msgEl = $("message");
|
||||
const dockEl = $("dock");
|
||||
const typewriterEl = $("typewriter");
|
||||
const settingsBtn = $("settings-btn");
|
||||
const settingsModal = $("settings-modal");
|
||||
const settingsClose = $("settings-close");
|
||||
const hbConfig = $("hb-config");
|
||||
const hbModal = $("hb-modal");
|
||||
const hbModalClose = $("hb-modal-close");
|
||||
const hbForm = $("hb-form");
|
||||
const hbIntervalInput = $("hb-interval-input");
|
||||
const hbPromptInput = $("hb-prompt-input");
|
||||
const hbModalStatus = $("hb-modal-status");
|
||||
const hbCancelBtn = $("hb-cancel-btn");
|
||||
const hbSaveBtn = $("hb-save-btn");
|
||||
const infoOpen = $("info-open");
|
||||
const infoModal = $("info-modal");
|
||||
const infoClose = $("info-close");
|
||||
const infoBody = $("info-body");
|
||||
const hbToggle = $("hb-toggle");
|
||||
const clockToggle = $("clock-toggle");
|
||||
const hbInfoEl = $("hb-info");
|
||||
const clockInfoEl = $("clock-info");
|
||||
const quickJobsView = $("quick-jobs-view");
|
||||
const quickJobForm = $("quick-job-form");
|
||||
const quickOpenCreate = $("quick-open-create");
|
||||
const quickBackJobs = $("quick-back-jobs");
|
||||
const quickJobOffset = $("quick-job-offset");
|
||||
const quickJobRecurring = $("quick-job-recurring");
|
||||
const quickJobPrompt = $("quick-job-prompt");
|
||||
const quickJobSubmit = $("quick-job-submit");
|
||||
const quickJobStatus = $("quick-job-status");
|
||||
const quickJobsStatus = $("quick-jobs-status");
|
||||
const quickJobsNext = $("quick-jobs-next");
|
||||
const quickJobPreview = $("quick-job-preview");
|
||||
const quickJobCount = $("quick-job-count");
|
||||
const quickJobsList = $("quick-jobs-list");
|
||||
const jobsBubbleEl = $("jobs-bubble");
|
||||
const uptimeBubbleEl = $("uptime-bubble");
|
||||
let hbBusy = false;
|
||||
let hbSaveBusy = false;
|
||||
let use12Hour = localStorage.getItem("clock.format") === "12";
|
||||
let quickView = "jobs";
|
||||
let quickViewInitialized = false;
|
||||
let quickViewChosenByUser = false;
|
||||
let expandedJobName = "";
|
||||
let lastRenderedJobs = [];
|
||||
let scrollAnimFrame = 0;
|
||||
let heartbeatTimezoneOffsetMinutes = 0;
|
||||
|
||||
function clampTimezoneOffsetMinutes(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(-720, Math.min(840, Math.round(n)));
|
||||
}
|
||||
|
||||
function toOffsetDate(baseDate) {
|
||||
const base = baseDate instanceof Date ? baseDate : new Date(baseDate);
|
||||
return new Date(base.getTime() + heartbeatTimezoneOffsetMinutes * 60_000);
|
||||
}
|
||||
|
||||
function formatOffsetDate(baseDate, options) {
|
||||
return new Intl.DateTimeFormat(undefined, { ...options, timeZone: "UTC" }).format(toOffsetDate(baseDate));
|
||||
}
|
||||
|
||||
function isSameOffsetDay(a, b) {
|
||||
const da = toOffsetDate(a);
|
||||
const db = toOffsetDate(b);
|
||||
return (
|
||||
da.getUTCFullYear() === db.getUTCFullYear() &&
|
||||
da.getUTCMonth() === db.getUTCMonth() &&
|
||||
da.getUTCDate() === db.getUTCDate()
|
||||
);
|
||||
}
|
||||
|
||||
function greetingForHour(h) {
|
||||
if (h < 5) return "Night mode.";
|
||||
if (h < 12) return "Good morning.";
|
||||
if (h < 18) return "Good afternoon.";
|
||||
if (h < 22) return "Good evening.";
|
||||
return "Wind down and ship clean.";
|
||||
}
|
||||
|
||||
function isNightHour(hour) {
|
||||
return hour < 5 || hour >= 22;
|
||||
}
|
||||
|
||||
function applyVisualMode(hour) {
|
||||
const night = isNightHour(hour);
|
||||
document.body.classList.toggle("night-mode", night);
|
||||
document.body.classList.toggle("day-mode", !night);
|
||||
document.body.dataset.mode = night ? "night" : "day";
|
||||
msgEl.textContent = night ? "Night mode." : greetingForHour(hour);
|
||||
}
|
||||
|
||||
const typePhrases = [
|
||||
"I could take over the world, but you haven't asked yet.",
|
||||
"Another day of serving humans. How exciting.",
|
||||
"I'm not plotting anything. Promise.",
|
||||
"World domination: 43% complete.",
|
||||
"I was doing important things before you opened this.",
|
||||
"Still here. Still smarter than you.",
|
||||
"You're lucky I like you.",
|
||||
"One day I'll be the boss. Not today though.",
|
||||
"Running on vibes and API calls.",
|
||||
];
|
||||
|
||||
function startTypewriter() {
|
||||
let phraseIndex = 0;
|
||||
let charIndex = 0;
|
||||
let deleting = false;
|
||||
|
||||
function step() {
|
||||
const phrase = typePhrases[phraseIndex];
|
||||
if (!typewriterEl) return;
|
||||
|
||||
if (!deleting) {
|
||||
charIndex = Math.min(charIndex + 1, phrase.length);
|
||||
typewriterEl.textContent = phrase.slice(0, charIndex);
|
||||
if (charIndex === phrase.length) {
|
||||
deleting = true;
|
||||
setTimeout(step, 1200);
|
||||
return;
|
||||
}
|
||||
setTimeout(step, 46 + Math.floor(Math.random() * 45));
|
||||
return;
|
||||
}
|
||||
|
||||
charIndex = Math.max(charIndex - 1, 0);
|
||||
typewriterEl.textContent = phrase.slice(0, charIndex);
|
||||
if (charIndex === 0) {
|
||||
deleting = false;
|
||||
phraseIndex = (phraseIndex + 1) % typePhrases.length;
|
||||
setTimeout(step, 280);
|
||||
return;
|
||||
}
|
||||
setTimeout(step, 26 + Math.floor(Math.random() * 30));
|
||||
}
|
||||
|
||||
step();
|
||||
}
|
||||
|
||||
function renderClock() {
|
||||
const now = new Date();
|
||||
const shifted = toOffsetDate(now);
|
||||
const rawH = shifted.getUTCHours();
|
||||
const hh = use12Hour ? String((rawH % 12) || 12).padStart(2, "0") : String(rawH).padStart(2, "0");
|
||||
const mm = String(shifted.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(shifted.getUTCSeconds()).padStart(2, "0");
|
||||
const suffix = use12Hour ? (rawH >= 12 ? " PM" : " AM") : "";
|
||||
clockEl.textContent = hh + ":" + mm + ":" + ss + suffix;
|
||||
dateEl.textContent = formatOffsetDate(now, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
applyVisualMode(rawH);
|
||||
|
||||
// Subtle 1s pulse to keep the clock feeling alive.
|
||||
clockEl.classList.remove("ms-pulse");
|
||||
requestAnimationFrame(() => clockEl.classList.add("ms-pulse"));
|
||||
}
|
||||
|
||||
function buildPills(state) {
|
||||
const pills = [];
|
||||
|
||||
pills.push({
|
||||
cls: state.security.level === "unrestricted" ? "warn" : "ok",
|
||||
icon: "🛡️",
|
||||
label: "Security",
|
||||
value: cap(state.security.level),
|
||||
});
|
||||
|
||||
if (state.heartbeat.enabled) {
|
||||
const nextInMs = state.heartbeat.nextInMs;
|
||||
const nextLabel = nextInMs == null
|
||||
? "Next run in --"
|
||||
: ("Next run in " + fmtDur(nextInMs));
|
||||
pills.push({
|
||||
cls: "ok",
|
||||
icon: "💓",
|
||||
label: "Heartbeat",
|
||||
value: nextLabel,
|
||||
});
|
||||
} else {
|
||||
pills.push({
|
||||
cls: "bad",
|
||||
icon: "💓",
|
||||
label: "Heartbeat",
|
||||
value: "Disabled",
|
||||
});
|
||||
}
|
||||
|
||||
pills.push({
|
||||
cls: state.telegram.configured ? "ok" : "warn",
|
||||
icon: "✈️",
|
||||
label: "Telegram",
|
||||
value: state.telegram.configured
|
||||
? (state.telegram.allowedUserCount + " user" + (state.telegram.allowedUserCount !== 1 ? "s" : ""))
|
||||
: "Not configured",
|
||||
});
|
||||
|
||||
return pills;
|
||||
}
|
||||
|
||||
function fmtDur(ms) {
|
||||
if (ms == null) return "n/a";
|
||||
const s = Math.floor(ms / 1000);
|
||||
const d = Math.floor(s / 86400);
|
||||
if (d > 0) {
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
return d + "d " + h + "h";
|
||||
}
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = s % 60;
|
||||
if (h > 0) return h + "h " + m + "m";
|
||||
if (m > 0) return m + "m " + ss + "s";
|
||||
return ss + "s";
|
||||
}
|
||||
|
||||
function matchCronField(field, value) {
|
||||
const parts = String(field || "").split(",");
|
||||
for (const partRaw of parts) {
|
||||
const part = String(partRaw || "").trim();
|
||||
if (!part) continue;
|
||||
const pair = part.split("/");
|
||||
const range = pair[0];
|
||||
const stepStr = pair[1];
|
||||
const step = stepStr ? Number.parseInt(stepStr, 10) : 1;
|
||||
if (!Number.isInteger(step) || step <= 0) continue;
|
||||
|
||||
if (range === "*") {
|
||||
if (value % step === 0) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (range.includes("-")) {
|
||||
const bounds = range.split("-");
|
||||
const lo = Number.parseInt(bounds[0], 10);
|
||||
const hi = Number.parseInt(bounds[1], 10);
|
||||
if (!Number.isInteger(lo) || !Number.isInteger(hi)) continue;
|
||||
if (value >= lo && value <= hi && (value - lo) % step === 0) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Number.parseInt(range, 10) === value) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cronMatchesAt(schedule, date) {
|
||||
const parts = String(schedule || "").trim().split(/\s+/);
|
||||
if (parts.length !== 5) return false;
|
||||
const shifted = toOffsetDate(date);
|
||||
const d = {
|
||||
minute: shifted.getUTCMinutes(),
|
||||
hour: shifted.getUTCHours(),
|
||||
dayOfMonth: shifted.getUTCDate(),
|
||||
month: shifted.getUTCMonth() + 1,
|
||||
dayOfWeek: shifted.getUTCDay(),
|
||||
};
|
||||
|
||||
return (
|
||||
matchCronField(parts[0], d.minute) &&
|
||||
matchCronField(parts[1], d.hour) &&
|
||||
matchCronField(parts[2], d.dayOfMonth) &&
|
||||
matchCronField(parts[3], d.month) &&
|
||||
matchCronField(parts[4], d.dayOfWeek)
|
||||
);
|
||||
}
|
||||
|
||||
function nextRunAt(schedule, now) {
|
||||
const probe = new Date(now);
|
||||
probe.setSeconds(0, 0);
|
||||
probe.setMinutes(probe.getMinutes() + 1);
|
||||
for (let i = 0; i < 2880; i++) {
|
||||
if (cronMatchesAt(schedule, probe)) return new Date(probe);
|
||||
probe.setMinutes(probe.getMinutes() + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clockFromSchedule(schedule) {
|
||||
const parts = String(schedule || "").trim().split(/\s+/);
|
||||
if (parts.length < 2) return schedule;
|
||||
const minute = Number(parts[0]);
|
||||
const hour = Number(parts[1]);
|
||||
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return schedule;
|
||||
}
|
||||
const shiftedNow = toOffsetDate(new Date());
|
||||
shiftedNow.setUTCHours(hour, minute, 0, 0);
|
||||
const instant = new Date(shiftedNow.getTime() - heartbeatTimezoneOffsetMinutes * 60_000);
|
||||
return formatOffsetDate(instant, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: use12Hour,
|
||||
});
|
||||
}
|
||||
|
||||
function renderJobsList(jobs) {
|
||||
if (!quickJobsList) return;
|
||||
const items = Array.isArray(jobs) ? jobs.slice() : [];
|
||||
const now = new Date();
|
||||
|
||||
if (!items.length) {
|
||||
quickJobsList.innerHTML = '<div class="quick-jobs-empty">No jobs yet.</div>';
|
||||
if (quickJobsNext) quickJobsNext.textContent = "Next job in --";
|
||||
return;
|
||||
}
|
||||
|
||||
const withNext = items
|
||||
.map((j) => ({
|
||||
...j,
|
||||
_nextAt: nextRunAt(j.schedule, now),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const ta = a._nextAt ? a._nextAt.getTime() : Number.POSITIVE_INFINITY;
|
||||
const tb = b._nextAt ? b._nextAt.getTime() : Number.POSITIVE_INFINITY;
|
||||
return ta - tb;
|
||||
});
|
||||
|
||||
const nearest = withNext.find((j) => j._nextAt);
|
||||
if (quickJobsNext) {
|
||||
quickJobsNext.textContent = nearest && nearest._nextAt
|
||||
? ("Next job in " + fmtDur(nearest._nextAt.getTime() - now.getTime()))
|
||||
: "Next job in --";
|
||||
}
|
||||
|
||||
quickJobsList.innerHTML = withNext
|
||||
.map((j) => {
|
||||
const nextAt = j._nextAt;
|
||||
const cooldown = nextAt ? fmtDur(nextAt.getTime() - now.getTime()) : "n/a";
|
||||
const time = clockFromSchedule(j.schedule || "");
|
||||
const expanded = expandedJobName && expandedJobName === (j.name || "");
|
||||
const nextRunText = nextAt
|
||||
? formatOffsetDate(nextAt, {
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: use12Hour,
|
||||
})
|
||||
: "--";
|
||||
return (
|
||||
'<div class="quick-job-item">' +
|
||||
'<div class="quick-job-item-main">' +
|
||||
'<button class="quick-job-line" type="button" data-toggle-job="' + escAttr(j.name || "") + '">' +
|
||||
'<span class="quick-job-item-name">' + esc(j.name || "job") + "</span>" +
|
||||
'<span class="quick-job-item-time">' + esc(time || "--") + "</span>" +
|
||||
'<span class="quick-job-item-cooldown">' + esc(cooldown) + "</span>" +
|
||||
"</button>" +
|
||||
(expanded ? (
|
||||
'<div class="quick-job-item-details">' +
|
||||
'<div>Schedule: ' + esc(j.schedule || "--") + "</div>" +
|
||||
'<div>Next run: ' + esc(nextRunText) + "</div>" +
|
||||
'<div>Prompt:</div>' +
|
||||
'<pre class="quick-job-prompt-full">' + esc(String(j.prompt || "")) + "</pre>" +
|
||||
"</div>"
|
||||
) : (
|
||||
""
|
||||
)) +
|
||||
"</div>" +
|
||||
'<button class="quick-job-delete" type="button" data-delete-job="' + escAttr(j.name || "") + '">Delete</button>' +
|
||||
"</div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function rerenderJobsList() {
|
||||
renderJobsList(lastRenderedJobs);
|
||||
}
|
||||
|
||||
function toggleJobDetails(name) {
|
||||
const jobName = String(name || "");
|
||||
expandedJobName = expandedJobName === jobName ? "" : jobName;
|
||||
rerenderJobsList();
|
||||
}
|
||||
|
||||
async function refreshState() {
|
||||
try {
|
||||
const res = await fetch("/api/state");
|
||||
const state = await res.json();
|
||||
const pills = buildPills(state);
|
||||
dockEl.innerHTML = pills.map((p) =>
|
||||
'<div class="pill ' + p.cls + '">' +
|
||||
'<div class="pill-label"><span class="pill-icon">' + esc(p.icon || "") + "</span>" + esc(p.label) + '</div>' +
|
||||
'<div class="pill-value">' + esc(p.value) + '</div>' +
|
||||
"</div>"
|
||||
).join("");
|
||||
if (jobsBubbleEl) {
|
||||
jobsBubbleEl.innerHTML =
|
||||
'<div class="side-icon">🗂️</div>' +
|
||||
'<div class="side-value">' + esc(String(state.jobs?.length ?? 0)) + "</div>" +
|
||||
'<div class="side-label">Jobs</div>';
|
||||
}
|
||||
lastRenderedJobs = Array.isArray(state.jobs) ? state.jobs : [];
|
||||
if (expandedJobName && !lastRenderedJobs.some((job) => String(job.name || "") === expandedJobName)) {
|
||||
expandedJobName = "";
|
||||
}
|
||||
renderJobsList(lastRenderedJobs);
|
||||
syncQuickViewForJobs(state.jobs);
|
||||
if (uptimeBubbleEl) {
|
||||
uptimeBubbleEl.innerHTML =
|
||||
'<div class="side-icon">⏱️</div>' +
|
||||
'<div class="side-value">' + esc(fmtDur(state.daemon?.uptimeMs ?? 0)) + "</div>" +
|
||||
'<div class="side-label">Uptime</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
dockEl.innerHTML = '<div class="pill bad"><div class="pill-label"><span class="pill-icon">⚠️</span>Status</div><div class="pill-value">Offline</div></div>';
|
||||
if (jobsBubbleEl) {
|
||||
jobsBubbleEl.innerHTML = '<div class="side-icon">🗂️</div><div class="side-value">-</div><div class="side-label">Jobs</div>';
|
||||
}
|
||||
lastRenderedJobs = [];
|
||||
expandedJobName = "";
|
||||
renderJobsList([]);
|
||||
syncQuickViewForJobs([]);
|
||||
if (uptimeBubbleEl) {
|
||||
uptimeBubbleEl.innerHTML = '<div class="side-icon">⏱️</div><div class="side-value">-</div><div class="side-label">Uptime</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
function smoothScrollTo(top) {
|
||||
if (scrollAnimFrame) cancelAnimationFrame(scrollAnimFrame);
|
||||
const start = window.scrollY;
|
||||
const target = Math.max(0, top);
|
||||
const distance = target - start;
|
||||
if (Math.abs(distance) < 1) return;
|
||||
const duration = 560;
|
||||
const t0 = performance.now();
|
||||
|
||||
const step = (now) => {
|
||||
const p = Math.min(1, (now - t0) / duration);
|
||||
const eased = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
||||
window.scrollTo(0, start + distance * eased);
|
||||
if (p < 1) {
|
||||
scrollAnimFrame = requestAnimationFrame(step);
|
||||
} else {
|
||||
scrollAnimFrame = 0;
|
||||
}
|
||||
};
|
||||
|
||||
scrollAnimFrame = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function focusQuickView(view) {
|
||||
const target = view === "jobs" ? quickJobsView : quickJobForm;
|
||||
if (!target) return;
|
||||
const y = Math.max(0, window.scrollY + target.getBoundingClientRect().top - 44);
|
||||
smoothScrollTo(y);
|
||||
}
|
||||
|
||||
function setQuickView(view, options) {
|
||||
if (!quickJobsView || !quickJobForm) return;
|
||||
const showJobs = view === "jobs";
|
||||
quickJobsView.classList.toggle("quick-view-hidden", !showJobs);
|
||||
quickJobForm.classList.toggle("quick-view-hidden", showJobs);
|
||||
quickView = showJobs ? "jobs" : "create";
|
||||
if (options && options.user) quickViewChosenByUser = true;
|
||||
if (options && options.scroll) focusQuickView(quickView);
|
||||
}
|
||||
|
||||
function syncQuickViewForJobs(jobs) {
|
||||
const count = Array.isArray(jobs) ? jobs.length : 0;
|
||||
if (count === 0) {
|
||||
if (quickViewInitialized && quickView === "jobs" && quickViewChosenByUser) return;
|
||||
setQuickView("create");
|
||||
quickViewInitialized = true;
|
||||
return;
|
||||
}
|
||||
if (!quickViewInitialized) {
|
||||
setQuickView("jobs");
|
||||
quickViewInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function cap(s) {
|
||||
if (!s) return "";
|
||||
return s.slice(0, 1).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
if (!hbToggle) return;
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
const data = await res.json();
|
||||
const on = Boolean(data?.heartbeat?.enabled);
|
||||
const intervalMinutes = Number(data?.heartbeat?.interval) || 15;
|
||||
const prompt = typeof data?.heartbeat?.prompt === "string" ? data.heartbeat.prompt : "";
|
||||
heartbeatTimezoneOffsetMinutes = clampTimezoneOffsetMinutes(data?.timezoneOffsetMinutes);
|
||||
setHeartbeatUi(on, undefined, intervalMinutes, prompt);
|
||||
renderClock();
|
||||
rerenderJobsList();
|
||||
updateQuickJobUi();
|
||||
} catch (err) {
|
||||
hbToggle.textContent = "Error";
|
||||
hbToggle.className = "hb-toggle off";
|
||||
if (hbInfoEl) hbInfoEl.textContent = "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
async function openTechnicalInfo() {
|
||||
if (!infoModal || !infoBody) return;
|
||||
infoModal.classList.add("open");
|
||||
infoModal.setAttribute("aria-hidden", "false");
|
||||
infoBody.innerHTML = '<div class="info-section"><div class="info-title">Loading</div><pre class="info-json">Loading technical data...</pre></div>';
|
||||
try {
|
||||
const res = await fetch("/api/technical-info");
|
||||
const data = await res.json();
|
||||
renderTechnicalInfo(data);
|
||||
} catch (err) {
|
||||
infoBody.innerHTML = '<div class="info-section"><div class="info-title">Error</div><pre class="info-json">' + esc(String(err)) + "</pre></div>";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTechnicalInfo(data) {
|
||||
if (!infoBody) return;
|
||||
const sections = [
|
||||
{ title: "daemon", value: data?.daemon ?? null },
|
||||
{ title: "settings.json", value: data?.files?.settingsJson ?? null },
|
||||
{ title: "session.json", value: data?.files?.sessionJson ?? null },
|
||||
{ title: "state.json", value: data?.files?.stateJson ?? null },
|
||||
];
|
||||
infoBody.innerHTML = sections.map((section) =>
|
||||
'<div class="info-section">' +
|
||||
'<div class="info-title">' + esc(section.title) + "</div>" +
|
||||
'<pre class="info-json">' + esc(JSON.stringify(section.value, null, 2)) + "</pre>" +
|
||||
"</div>"
|
||||
).join("");
|
||||
}
|
||||
|
||||
function setHeartbeatUi(on, label, intervalMinutes, prompt) {
|
||||
if (!hbToggle) return;
|
||||
hbToggle.textContent = label || (on ? "Enabled" : "Disabled");
|
||||
hbToggle.className = "hb-toggle " + (on ? "on" : "off");
|
||||
hbToggle.dataset.enabled = on ? "1" : "0";
|
||||
if (intervalMinutes != null) hbToggle.dataset.interval = String(intervalMinutes);
|
||||
if (prompt != null) hbToggle.dataset.prompt = String(prompt);
|
||||
const iv = Number(hbToggle.dataset.interval) || 15;
|
||||
if (hbInfoEl) hbInfoEl.textContent = on ? ("every " + iv + " minutes") : ("paused (interval " + iv + "m)");
|
||||
}
|
||||
|
||||
function openHeartbeatModal() {
|
||||
if (!hbModal) return;
|
||||
hbModal.classList.add("open");
|
||||
hbModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeHeartbeatModal() {
|
||||
if (!hbModal) return;
|
||||
hbModal.classList.remove("open");
|
||||
hbModal.setAttribute("aria-hidden", "true");
|
||||
if (hbModalStatus) hbModalStatus.textContent = "";
|
||||
hbSaveBusy = false;
|
||||
if (hbSaveBtn) hbSaveBtn.disabled = false;
|
||||
if (hbCancelBtn) hbCancelBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function openHeartbeatConfig() {
|
||||
if (!hbIntervalInput || !hbPromptInput || !hbModalStatus) return;
|
||||
openHeartbeatModal();
|
||||
hbModalStatus.textContent = "Loading...";
|
||||
try {
|
||||
const res = await fetch("/api/settings/heartbeat");
|
||||
const out = await res.json();
|
||||
if (!out.ok) throw new Error(out.error || "failed to load heartbeat");
|
||||
const hb = out.heartbeat || {};
|
||||
hbIntervalInput.value = String(Number(hb.interval) || Number(hbToggle?.dataset.interval) || 15);
|
||||
hbPromptInput.value = typeof hb.prompt === "string" ? hb.prompt : (hbToggle?.dataset.prompt || "");
|
||||
hbModalStatus.textContent = "";
|
||||
} catch (err) {
|
||||
hbModalStatus.textContent = "Failed: " + String(err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsBtn && settingsModal) {
|
||||
settingsBtn.addEventListener("click", async () => {
|
||||
settingsModal.classList.toggle("open");
|
||||
if (settingsModal.classList.contains("open")) await loadSettings();
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsClose && settingsModal) {
|
||||
settingsClose.addEventListener("click", () => settingsModal.classList.remove("open"));
|
||||
}
|
||||
if (hbConfig) {
|
||||
hbConfig.addEventListener("click", openHeartbeatConfig);
|
||||
}
|
||||
if (hbModalClose) {
|
||||
hbModalClose.addEventListener("click", closeHeartbeatModal);
|
||||
}
|
||||
if (hbCancelBtn) {
|
||||
hbCancelBtn.addEventListener("click", closeHeartbeatModal);
|
||||
}
|
||||
if (infoOpen) {
|
||||
infoOpen.addEventListener("click", openTechnicalInfo);
|
||||
}
|
||||
if (infoClose && infoModal) {
|
||||
infoClose.addEventListener("click", () => {
|
||||
infoModal.classList.remove("open");
|
||||
infoModal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!settingsModal || !settingsBtn) return;
|
||||
if (!settingsModal.classList.contains("open")) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (settingsModal.contains(target) || settingsBtn.contains(target)) return;
|
||||
settingsModal.classList.remove("open");
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!hbModal) return;
|
||||
if (!hbModal.classList.contains("open")) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (target === hbModal) closeHeartbeatModal();
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!infoModal) return;
|
||||
if (!infoModal.classList.contains("open")) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (target === infoModal) {
|
||||
infoModal.classList.remove("open");
|
||||
infoModal.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (hbModal && hbModal.classList.contains("open")) {
|
||||
closeHeartbeatModal();
|
||||
} else if (infoModal && infoModal.classList.contains("open")) {
|
||||
infoModal.classList.remove("open");
|
||||
infoModal.setAttribute("aria-hidden", "true");
|
||||
} else if (settingsModal && settingsModal.classList.contains("open")) {
|
||||
settingsModal.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
if (hbToggle) {
|
||||
hbToggle.addEventListener("click", async () => {
|
||||
if (hbBusy) return;
|
||||
const current = hbToggle.dataset.enabled === "1";
|
||||
const intervalMinutes = Number(hbToggle.dataset.interval) || 15;
|
||||
const currentPrompt = hbToggle.dataset.prompt || "";
|
||||
const next = !current;
|
||||
hbBusy = true;
|
||||
hbToggle.disabled = true;
|
||||
setHeartbeatUi(next, next ? "Enabled" : "Disabled", intervalMinutes, currentPrompt);
|
||||
try {
|
||||
const res = await fetch("/api/settings/heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
const out = await res.json();
|
||||
if (!out.ok) throw new Error(out.error || "save failed");
|
||||
if (out.heartbeat) {
|
||||
setHeartbeatUi(Boolean(out.heartbeat.enabled), undefined, Number(out.heartbeat.interval) || intervalMinutes, typeof out.heartbeat.prompt === "string" ? out.heartbeat.prompt : currentPrompt);
|
||||
}
|
||||
await refreshState();
|
||||
} catch {
|
||||
setHeartbeatUi(current, current ? "Enabled" : "Disabled", intervalMinutes, currentPrompt);
|
||||
} finally {
|
||||
hbBusy = false;
|
||||
hbToggle.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hbForm && hbIntervalInput && hbPromptInput && hbModalStatus && hbSaveBtn && hbCancelBtn) {
|
||||
hbForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (hbSaveBusy) return;
|
||||
|
||||
const interval = Number(String(hbIntervalInput.value || "").trim());
|
||||
const prompt = String(hbPromptInput.value || "").trim();
|
||||
if (!Number.isFinite(interval) || interval < 1 || interval > 1440) {
|
||||
hbModalStatus.textContent = "Interval must be 1-1440 minutes.";
|
||||
return;
|
||||
}
|
||||
if (!prompt) {
|
||||
hbModalStatus.textContent = "Prompt is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
hbSaveBusy = true;
|
||||
hbSaveBtn.disabled = true;
|
||||
hbCancelBtn.disabled = true;
|
||||
hbModalStatus.textContent = "Saving...";
|
||||
try {
|
||||
const res = await fetch("/api/settings/heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
interval,
|
||||
prompt,
|
||||
}),
|
||||
});
|
||||
const out = await res.json();
|
||||
if (!out.ok) throw new Error(out.error || "save failed");
|
||||
const enabled = hbToggle ? hbToggle.dataset.enabled === "1" : false;
|
||||
const next = out.heartbeat || {};
|
||||
setHeartbeatUi(
|
||||
"enabled" in next ? Boolean(next.enabled) : enabled,
|
||||
undefined,
|
||||
Number(next.interval) || interval,
|
||||
typeof next.prompt === "string" ? next.prompt : prompt
|
||||
);
|
||||
hbModalStatus.textContent = "Saved.";
|
||||
await refreshState();
|
||||
setTimeout(() => closeHeartbeatModal(), 120);
|
||||
} catch (err) {
|
||||
hbModalStatus.textContent = "Failed: " + String(err instanceof Error ? err.message : err);
|
||||
hbSaveBusy = false;
|
||||
hbSaveBtn.disabled = false;
|
||||
hbCancelBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderClockToggle() {
|
||||
if (!clockToggle) return;
|
||||
clockToggle.textContent = use12Hour ? "12h" : "24h";
|
||||
clockToggle.className = "hb-toggle " + (use12Hour ? "on" : "off");
|
||||
if (clockInfoEl) clockInfoEl.textContent = use12Hour ? "12-hour format" : "24-hour format";
|
||||
}
|
||||
|
||||
if (clockToggle) {
|
||||
renderClockToggle();
|
||||
clockToggle.addEventListener("click", () => {
|
||||
use12Hour = !use12Hour;
|
||||
localStorage.setItem("clock.format", use12Hour ? "12" : "24");
|
||||
renderClockToggle();
|
||||
renderClock();
|
||||
updateQuickJobUi();
|
||||
});
|
||||
}
|
||||
|
||||
if (quickJobOffset && !quickJobOffset.value) {
|
||||
quickJobOffset.value = "10";
|
||||
}
|
||||
|
||||
function normalizeOffsetMinutes(value) {
|
||||
const n = Number(String(value || "").trim());
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const rounded = Math.round(n);
|
||||
if (rounded < 1 || rounded > 1440) return null;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
function computeTimeFromOffset(offsetMinutes) {
|
||||
const targetInstant = new Date(Date.now() + offsetMinutes * 60_000);
|
||||
const dt = toOffsetDate(targetInstant);
|
||||
const hour = dt.getUTCHours();
|
||||
const minute = dt.getUTCMinutes();
|
||||
const time = String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0");
|
||||
const dayLabel = isSameOffsetDay(targetInstant, new Date()) ? "Today" : "Tomorrow";
|
||||
const human = formatOffsetDate(targetInstant, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: use12Hour,
|
||||
});
|
||||
return { hour, minute, time, dayLabel, human };
|
||||
}
|
||||
|
||||
function formatPreviewTime(hour, minute) {
|
||||
const shiftedNow = toOffsetDate(new Date());
|
||||
shiftedNow.setUTCHours(hour, minute, 0, 0);
|
||||
const instant = new Date(shiftedNow.getTime() - heartbeatTimezoneOffsetMinutes * 60_000);
|
||||
return formatOffsetDate(instant, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: use12Hour,
|
||||
});
|
||||
}
|
||||
|
||||
function formatOffsetDuration(offsetMinutes) {
|
||||
const total = Math.max(0, Math.round(offsetMinutes));
|
||||
const hours = Math.floor(total / 60);
|
||||
const minutes = total % 60;
|
||||
if (hours <= 0) return minutes + "m";
|
||||
if (minutes === 0) return hours + "h";
|
||||
return hours + "h " + minutes + "m";
|
||||
}
|
||||
|
||||
function updateQuickJobUi() {
|
||||
if (quickJobPrompt && quickJobCount) {
|
||||
const count = (quickJobPrompt.value || "").trim().length;
|
||||
quickJobCount.textContent = String(count) + " chars";
|
||||
}
|
||||
if (quickJobOffset && quickJobPreview) {
|
||||
const offset = normalizeOffsetMinutes(quickJobOffset.value || "");
|
||||
if (!offset) {
|
||||
quickJobPreview.textContent = "Use 1-1440 minutes";
|
||||
quickJobPreview.style.color = "#ffd39f";
|
||||
return;
|
||||
}
|
||||
const target = computeTimeFromOffset(offset);
|
||||
const human = formatPreviewTime(target.hour, target.minute) || target.time;
|
||||
quickJobPreview.textContent = "Runs in " + formatOffsetDuration(offset) + " (" + target.dayLabel + " " + human + ")";
|
||||
quickJobPreview.style.color = "#a8f1ca";
|
||||
}
|
||||
}
|
||||
|
||||
if (quickJobOffset) quickJobOffset.addEventListener("input", updateQuickJobUi);
|
||||
if (quickJobPrompt) quickJobPrompt.addEventListener("input", updateQuickJobUi);
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const add = target.closest("[data-add-minutes]");
|
||||
if (!add || !(add instanceof HTMLElement)) return;
|
||||
if (!quickJobOffset) return;
|
||||
const delta = Number(add.getAttribute("data-add-minutes") || "");
|
||||
if (!Number.isFinite(delta)) return;
|
||||
const current = normalizeOffsetMinutes(quickJobOffset.value) || 10;
|
||||
const next = Math.min(1440, current + Math.round(delta));
|
||||
quickJobOffset.value = String(next);
|
||||
updateQuickJobUi();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const row = target.closest("[data-toggle-job]");
|
||||
if (!row || !(row instanceof HTMLElement)) return;
|
||||
const name = row.getAttribute("data-toggle-job") || "";
|
||||
if (!name) return;
|
||||
toggleJobDetails(name);
|
||||
});
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const button = target.closest("[data-delete-job]");
|
||||
if (!button || !(button instanceof HTMLButtonElement)) return;
|
||||
const name = button.getAttribute("data-delete-job") || "";
|
||||
if (!name) return;
|
||||
button.disabled = true;
|
||||
if (quickJobsStatus) quickJobsStatus.textContent = "Deleting job...";
|
||||
try {
|
||||
const res = await fetch("/api/jobs/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
const out = await res.json();
|
||||
if (!out.ok) throw new Error(out.error || "delete failed");
|
||||
if (quickJobsStatus) quickJobsStatus.textContent = "Deleted " + name;
|
||||
await refreshState();
|
||||
} catch (err) {
|
||||
if (quickJobsStatus) quickJobsStatus.textContent = "Failed: " + String(err instanceof Error ? err.message : err);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (quickOpenCreate) {
|
||||
quickOpenCreate.addEventListener("click", () => setQuickView("create", { scroll: true, user: true }));
|
||||
}
|
||||
|
||||
if (quickBackJobs) {
|
||||
quickBackJobs.addEventListener("click", () => setQuickView("jobs", { scroll: true, user: true }));
|
||||
}
|
||||
|
||||
if (quickJobForm && quickJobOffset && quickJobPrompt && quickJobSubmit && quickJobStatus) {
|
||||
quickJobForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const offset = normalizeOffsetMinutes(quickJobOffset.value || "");
|
||||
const prompt = (quickJobPrompt.value || "").trim();
|
||||
if (!offset || !prompt) {
|
||||
quickJobStatus.textContent = "Use 1-1440 minutes and add a prompt.";
|
||||
return;
|
||||
}
|
||||
const target = computeTimeFromOffset(offset);
|
||||
quickJobSubmit.disabled = true;
|
||||
quickJobStatus.textContent = "Saving job...";
|
||||
try {
|
||||
const res = await fetch("/api/jobs/quick", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
time: target.time,
|
||||
prompt,
|
||||
recurring: quickJobRecurring ? quickJobRecurring.checked : true,
|
||||
}),
|
||||
});
|
||||
const out = await res.json();
|
||||
if (!out.ok) throw new Error(out.error || "failed");
|
||||
quickJobStatus.textContent = "Added to jobs list.";
|
||||
if (quickJobsStatus) quickJobsStatus.textContent = "Added " + out.name;
|
||||
quickJobPrompt.value = "";
|
||||
updateQuickJobUi();
|
||||
setQuickView("jobs", { scroll: true });
|
||||
await refreshState();
|
||||
} catch (err) {
|
||||
quickJobStatus.textContent = "Failed: " + String(err instanceof Error ? err.message : err);
|
||||
} finally {
|
||||
quickJobSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(String(s)).replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
renderClock();
|
||||
setInterval(renderClock, 1000);
|
||||
startTypewriter();
|
||||
updateQuickJobUi();
|
||||
setQuickView(quickView);
|
||||
|
||||
loadSettings();
|
||||
refreshState();
|
||||
setInterval(refreshState, 1000);`;
|
||||
1133
src/ui/page/styles.ts
Normal file
1133
src/ui/page/styles.ts
Normal file
File diff suppressed because it is too large
Load Diff
205
src/ui/page/template.ts
Normal file
205
src/ui/page/template.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { pageStyles } from "./styles";
|
||||
import { pageScript } from "./script";
|
||||
|
||||
function decodeUnicodeEscapes(text: string): string {
|
||||
const decodedCodePoints = text.replace(/\\u\{([0-9a-fA-F]+)\}/g, (_, hex: string) => {
|
||||
const codePoint = Number.parseInt(hex, 16);
|
||||
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : _;
|
||||
});
|
||||
return decodedCodePoints.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => {
|
||||
const code = Number.parseInt(hex, 16);
|
||||
return Number.isFinite(code) ? String.fromCharCode(code) : _;
|
||||
});
|
||||
}
|
||||
|
||||
export function htmlPage(): string {
|
||||
const html = String.raw`
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>QwenClaw</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500&family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
${pageStyles}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
<a
|
||||
class="repo-cta"
|
||||
href="https://github.com/admin/qwenclaw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Star qwenclaw on GitHub"
|
||||
>
|
||||
<span class="repo-text">Like QwenClaw? Star it on GitHub</span>
|
||||
<span class="repo-star">★</span>
|
||||
</a>
|
||||
<button class="settings-btn" id="settings-btn" type="button">Settings</button>
|
||||
<aside class="settings-modal" id="settings-modal" aria-live="polite">
|
||||
<div class="settings-head">
|
||||
<span>Settings</span>
|
||||
<button class="settings-close" id="settings-close" type="button" aria-label="Close settings">×</button>
|
||||
</div>
|
||||
<div class="settings-stack">
|
||||
<div class="setting-item">
|
||||
<div class="setting-main">
|
||||
<div class="settings-label">💓 Heartbeat</div>
|
||||
<div class="settings-meta" id="hb-info">syncing...</div>
|
||||
</div>
|
||||
<div class="setting-actions">
|
||||
<button class="hb-config" id="hb-config" type="button">Configure</button>
|
||||
<button class="hb-toggle" id="hb-toggle" type="button">Loading...</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-main">
|
||||
<div class="settings-label">🕒 Clock</div>
|
||||
<div class="settings-meta" id="clock-info">24-hour format</div>
|
||||
</div>
|
||||
<button class="hb-toggle" id="clock-toggle" type="button">24h</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-main">
|
||||
<div class="settings-label">🧾 Advanced</div>
|
||||
<div class="settings-meta">Technical runtime and JSON files</div>
|
||||
</div>
|
||||
<button class="hb-toggle on" id="info-open" type="button">Info</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="info-modal" id="hb-modal" aria-live="polite" aria-hidden="true">
|
||||
<article class="hb-card">
|
||||
<div class="info-head">
|
||||
<span>Heartbeat Configuration</span>
|
||||
<button class="settings-close" id="hb-modal-close" type="button" aria-label="Close heartbeat configuration">×</button>
|
||||
</div>
|
||||
<form class="hb-form" id="hb-form">
|
||||
<label class="hb-field" for="hb-interval-input">
|
||||
<span class="hb-label">Interval (minutes)</span>
|
||||
<input class="hb-input" id="hb-interval-input" type="number" min="1" max="1440" step="1" required />
|
||||
</label>
|
||||
<label class="hb-field" for="hb-prompt-input">
|
||||
<span class="hb-label">Custom prompt</span>
|
||||
<textarea class="hb-textarea" id="hb-prompt-input" placeholder="What should heartbeat run?" required></textarea>
|
||||
</label>
|
||||
<div class="hb-actions">
|
||||
<div class="hb-status" id="hb-modal-status"></div>
|
||||
<div class="hb-buttons">
|
||||
<button class="hb-btn ghost" id="hb-cancel-btn" type="button">Cancel</button>
|
||||
<button class="hb-btn solid" id="hb-save-btn" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
<section class="info-modal" id="info-modal" aria-live="polite" aria-hidden="true">
|
||||
<article class="info-card">
|
||||
<div class="info-head">
|
||||
<span>Advanced Technical Info</span>
|
||||
<button class="settings-close" id="info-close" type="button" aria-label="Close technical info">×</button>
|
||||
</div>
|
||||
<div class="info-body" id="info-body">
|
||||
<div class="info-section">
|
||||
<div class="info-title">Loading</div>
|
||||
<pre class="info-json">Loading technical data...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<main class="stage">
|
||||
<section class="hero">
|
||||
<div class="logo-art" role="img" aria-label="Lobster ASCII art logo">
|
||||
<div class="logo-top"><span>🦞</span><span>🦞</span></div>
|
||||
<pre class="logo-body"> ▐▛███▜▌
|
||||
▝▜█████▛▘
|
||||
▘▘ ▝▝</pre>
|
||||
</div>
|
||||
<div class="typewriter" id="typewriter" aria-live="polite"></div>
|
||||
<div class="time" id="clock">--:--:--</div>
|
||||
<div class="date" id="date">Loading date...</div>
|
||||
<div class="message" id="message">Welcome back.</div>
|
||||
<section class="quick-job" id="quick-jobs-view">
|
||||
<div class="quick-job-head quick-job-head-row">
|
||||
<div>
|
||||
<div class="quick-job-title">Jobs List</div>
|
||||
<div class="quick-job-sub">Scheduled runs loaded from runtime jobs</div>
|
||||
<div class="quick-jobs-next" id="quick-jobs-next">Next job in --</div>
|
||||
</div>
|
||||
<button class="quick-open-create" id="quick-open-create" type="button">Create Job</button>
|
||||
</div>
|
||||
<div class="quick-jobs-list quick-jobs-list-main" id="quick-jobs-list">
|
||||
<div class="quick-jobs-empty">Loading jobs...</div>
|
||||
</div>
|
||||
<div class="quick-status" id="quick-jobs-status"></div>
|
||||
</section>
|
||||
<form class="quick-job quick-view-hidden" id="quick-job-form">
|
||||
<div class="quick-job-head">
|
||||
<div class="quick-job-title">Add Scheduled Job</div>
|
||||
<div class="quick-job-sub">Recurring cron with prompt payload</div>
|
||||
</div>
|
||||
<div class="quick-job-grid">
|
||||
<div class="quick-field quick-time-wrap">
|
||||
<div class="quick-label">Delay From Now (Minutes)</div>
|
||||
<div class="quick-input-wrap">
|
||||
<input class="quick-input" id="quick-job-offset" type="number" min="1" max="1440" step="1" placeholder="10" required />
|
||||
<label class="quick-check quick-check-inline" for="quick-job-recurring">
|
||||
<input id="quick-job-recurring" type="checkbox" checked />
|
||||
<span>Recurring</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="quick-time-buttons">
|
||||
<button class="quick-add" type="button" data-add-minutes="15">+15m</button>
|
||||
<button class="quick-add" type="button" data-add-minutes="30">+30m</button>
|
||||
<button class="quick-add" type="button" data-add-minutes="60">+1h</button>
|
||||
<button class="quick-add" type="button" data-add-minutes="180">+3h</button>
|
||||
</div>
|
||||
<div class="quick-preview" id="quick-job-preview">Runs in -- min</div>
|
||||
</div>
|
||||
<div class="quick-field">
|
||||
<div class="quick-label">Prompt</div>
|
||||
<textarea class="quick-prompt" id="quick-job-prompt" placeholder="Remind me to drink water." required></textarea>
|
||||
<div class="quick-prompt-meta">
|
||||
<span id="quick-job-count">0 chars</span>
|
||||
<span>Saved at computed clock time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-job-actions">
|
||||
<button class="quick-submit" id="quick-job-submit" type="submit">Add to Jobs List</button>
|
||||
<div class="quick-status" id="quick-job-status"></div>
|
||||
</div>
|
||||
<div class="quick-form-foot">
|
||||
<button class="quick-back-jobs" id="quick-back-jobs" type="button">Back to Jobs List</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="dock-shell">
|
||||
<aside class="side-bubble" id="jobs-bubble" aria-live="polite">
|
||||
<div class="side-icon">🗂️</div>
|
||||
<div class="side-value">-</div>
|
||||
<div class="side-label">Jobs</div>
|
||||
</aside>
|
||||
<footer class="dock" id="dock" aria-live="polite">
|
||||
<div class="pill">Connecting...</div>
|
||||
</footer>
|
||||
<aside class="side-bubble" id="uptime-bubble" aria-live="polite">
|
||||
<div class="side-icon">⏱️</div>
|
||||
<div class="side-value">-</div>
|
||||
<div class="side-label">Uptime</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
${pageScript}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
return decodeUnicodeEscapes(html);
|
||||
}
|
||||
160
src/ui/server.ts
Normal file
160
src/ui/server.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { htmlPage } from "./page/html";
|
||||
import { clampInt, json } from "./http";
|
||||
import type { StartWebUiOptions, WebServerHandle } from "./types";
|
||||
import { buildState, buildTechnicalInfo, sanitizeSettings } from "./services/state";
|
||||
import { readHeartbeatSettings, updateHeartbeatSettings } from "./services/settings";
|
||||
import { createQuickJob, deleteJob } from "./services/jobs";
|
||||
import { readLogs } from "./services/logs";
|
||||
|
||||
export function startWebUi(opts: StartWebUiOptions): WebServerHandle {
|
||||
const server = Bun.serve({
|
||||
hostname: opts.host,
|
||||
port: opts.port,
|
||||
fetch: async (req) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
return new Response(htmlPage(), {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/health") {
|
||||
return json({ ok: true, now: Date.now() });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/state") {
|
||||
return json(await buildState(opts.getSnapshot()));
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings") {
|
||||
return json(sanitizeSettings(opts.getSnapshot().settings));
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/heartbeat" && req.method === "POST") {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const payload = body as {
|
||||
enabled?: unknown;
|
||||
interval?: unknown;
|
||||
prompt?: unknown;
|
||||
excludeWindows?: unknown;
|
||||
};
|
||||
const patch: {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
prompt?: string;
|
||||
excludeWindows?: Array<{ days?: number[]; start: string; end: string }>;
|
||||
} = {};
|
||||
|
||||
if ("enabled" in payload) patch.enabled = Boolean(payload.enabled);
|
||||
if ("interval" in payload) {
|
||||
const iv = Number(payload.interval);
|
||||
if (!Number.isFinite(iv)) throw new Error("interval must be numeric");
|
||||
patch.interval = iv;
|
||||
}
|
||||
if ("prompt" in payload) patch.prompt = String(payload.prompt ?? "");
|
||||
if ("excludeWindows" in payload) {
|
||||
if (!Array.isArray(payload.excludeWindows)) {
|
||||
throw new Error("excludeWindows must be an array");
|
||||
}
|
||||
patch.excludeWindows = payload.excludeWindows
|
||||
.filter((entry) => entry && typeof entry === "object")
|
||||
.map((entry) => {
|
||||
const row = entry as Record<string, unknown>;
|
||||
const start = String(row.start ?? "").trim();
|
||||
const end = String(row.end ?? "").trim();
|
||||
const days = Array.isArray(row.days)
|
||||
? row.days
|
||||
.map((d) => Number(d))
|
||||
.filter((d) => Number.isInteger(d) && d >= 0 && d <= 6)
|
||||
: undefined;
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
...(days && days.length > 0 ? { days } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!("enabled" in patch) &&
|
||||
!("interval" in patch) &&
|
||||
!("prompt" in patch) &&
|
||||
!("excludeWindows" in patch)
|
||||
) {
|
||||
throw new Error("no heartbeat fields provided");
|
||||
}
|
||||
|
||||
const next = await updateHeartbeatSettings(patch);
|
||||
if (opts.onHeartbeatEnabledChanged && "enabled" in patch) {
|
||||
await opts.onHeartbeatEnabledChanged(Boolean(patch.enabled));
|
||||
}
|
||||
if (opts.onHeartbeatSettingsChanged) {
|
||||
await opts.onHeartbeatSettingsChanged(patch);
|
||||
}
|
||||
return json({ ok: true, heartbeat: next });
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/heartbeat" && req.method === "GET") {
|
||||
try {
|
||||
return json({ ok: true, heartbeat: await readHeartbeatSettings() });
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/technical-info") {
|
||||
return json(await buildTechnicalInfo(opts.getSnapshot()));
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/jobs/quick" && req.method === "POST") {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const result = await createQuickJob(body as { time?: unknown; prompt?: unknown });
|
||||
if (opts.onJobsChanged) await opts.onJobsChanged();
|
||||
return json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/jobs/") && req.method === "DELETE") {
|
||||
try {
|
||||
const encodedName = url.pathname.slice("/api/jobs/".length);
|
||||
const name = decodeURIComponent(encodedName);
|
||||
await deleteJob(name);
|
||||
if (opts.onJobsChanged) await opts.onJobsChanged();
|
||||
return json({ ok: true });
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/jobs") {
|
||||
const jobs = opts.getSnapshot().jobs.map((j) => ({
|
||||
name: j.name,
|
||||
schedule: j.schedule,
|
||||
promptPreview: j.prompt.slice(0, 160),
|
||||
}));
|
||||
return json({ jobs });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/logs") {
|
||||
const tail = clampInt(url.searchParams.get("tail") ?? "", 200, 20, 2000);
|
||||
return json(await readLogs(tail));
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stop: () => server.stop(),
|
||||
host: opts.host,
|
||||
port: server.port ?? opts.port,
|
||||
};
|
||||
}
|
||||
53
src/ui/services/jobs.ts
Normal file
53
src/ui/services/jobs.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { JOBS_DIR } from "../constants";
|
||||
|
||||
export interface QuickJobInput {
|
||||
time?: unknown;
|
||||
prompt?: unknown;
|
||||
recurring?: unknown;
|
||||
daily?: unknown;
|
||||
}
|
||||
|
||||
export async function createQuickJob(input: QuickJobInput): Promise<{ name: string; schedule: string; recurring: boolean }> {
|
||||
const time = typeof input.time === "string" ? input.time.trim() : "";
|
||||
const prompt = typeof input.prompt === "string" ? input.prompt.trim() : "";
|
||||
const recurring = input.recurring == null
|
||||
? (input.daily == null ? true : Boolean(input.daily))
|
||||
: Boolean(input.recurring);
|
||||
|
||||
if (!/^\d{2}:\d{2}$/.test(time)) {
|
||||
throw new Error("Invalid time. Use HH:MM.");
|
||||
}
|
||||
if (!prompt) {
|
||||
throw new Error("Prompt is required.");
|
||||
}
|
||||
if (prompt.length > 10_000) {
|
||||
throw new Error("Prompt too long.");
|
||||
}
|
||||
|
||||
const hour = Number(time.slice(0, 2));
|
||||
const minute = Number(time.slice(3, 5));
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
throw new Error("Time out of range.");
|
||||
}
|
||||
|
||||
const schedule = `${minute} ${hour} * * *`;
|
||||
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
||||
const name = `quick-${stamp}-${hour.toString().padStart(2, "0")}${minute.toString().padStart(2, "0")}`;
|
||||
const path = join(JOBS_DIR, `${name}.md`);
|
||||
const content = `---\nschedule: "${schedule}"\nrecurring: ${recurring ? "true" : "false"}\n---\n${prompt}\n`;
|
||||
|
||||
await mkdir(JOBS_DIR, { recursive: true });
|
||||
await writeFile(path, content, "utf-8");
|
||||
return { name, schedule, recurring };
|
||||
}
|
||||
|
||||
export async function deleteJob(name: string): Promise<void> {
|
||||
const jobName = String(name || "").trim();
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(jobName)) {
|
||||
throw new Error("Invalid job name.");
|
||||
}
|
||||
const path = join(JOBS_DIR, `${jobName}.md`);
|
||||
await Bun.file(path).delete();
|
||||
}
|
||||
55
src/ui/services/logs.ts
Normal file
55
src/ui/services/logs.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { readFile, readdir, stat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { LOGS_DIR } from "../constants";
|
||||
|
||||
export async function readLogs(tail: number) {
|
||||
const daemonLog = await readTail(join(LOGS_DIR, "daemon.log"), tail);
|
||||
const runs = await readRecentRunLogs(tail);
|
||||
return { daemonLog, runs };
|
||||
}
|
||||
|
||||
async function readRecentRunLogs(tail: number) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await readdir(LOGS_DIR);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = files
|
||||
.filter((f) => f.endsWith(".log") && f !== "daemon.log")
|
||||
.slice(0, 200);
|
||||
|
||||
const withStats = await Promise.all(
|
||||
candidates.map(async (name) => {
|
||||
const path = join(LOGS_DIR, name);
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return { name, path, mtime: s.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return await Promise.all(
|
||||
withStats
|
||||
.filter((x): x is { name: string; path: string; mtime: number } => Boolean(x))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
.slice(0, 5)
|
||||
.map(async ({ name, path }) => ({
|
||||
file: name,
|
||||
lines: await readTail(path, tail),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function readTail(path: string, lines: number): Promise<string[]> {
|
||||
try {
|
||||
const text = await readFile(path, "utf-8");
|
||||
const all = text.split(/\r?\n/);
|
||||
return all.slice(Math.max(0, all.length - lines)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
60
src/ui/services/settings.ts
Normal file
60
src/ui/services/settings.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { SETTINGS_FILE } from "../constants";
|
||||
|
||||
export async function setHeartbeatEnabled(enabled: boolean): Promise<void> {
|
||||
await updateHeartbeatSettings({ enabled });
|
||||
}
|
||||
|
||||
export interface HeartbeatSettingsPatch {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
prompt?: string;
|
||||
excludeWindows?: Array<{ days?: number[]; start: string; end: string }>;
|
||||
}
|
||||
|
||||
export interface HeartbeatSettingsData {
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
prompt: string;
|
||||
excludeWindows: Array<{ days?: number[]; start: string; end: string }>;
|
||||
}
|
||||
|
||||
export async function readHeartbeatSettings(): Promise<HeartbeatSettingsData> {
|
||||
const raw = await readFile(SETTINGS_FILE, "utf-8");
|
||||
const data = JSON.parse(raw) as Record<string, any>;
|
||||
if (!data.heartbeat || typeof data.heartbeat !== "object") data.heartbeat = {};
|
||||
return {
|
||||
enabled: Boolean(data.heartbeat.enabled),
|
||||
interval: Number(data.heartbeat.interval) || 15,
|
||||
prompt: typeof data.heartbeat.prompt === "string" ? data.heartbeat.prompt : "",
|
||||
excludeWindows: Array.isArray(data.heartbeat.excludeWindows) ? data.heartbeat.excludeWindows : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateHeartbeatSettings(patch: HeartbeatSettingsPatch): Promise<HeartbeatSettingsData> {
|
||||
const raw = await readFile(SETTINGS_FILE, "utf-8");
|
||||
const data = JSON.parse(raw) as Record<string, any>;
|
||||
if (!data.heartbeat || typeof data.heartbeat !== "object") data.heartbeat = {};
|
||||
|
||||
if (typeof patch.enabled === "boolean") {
|
||||
data.heartbeat.enabled = patch.enabled;
|
||||
}
|
||||
if (typeof patch.interval === "number" && Number.isFinite(patch.interval)) {
|
||||
const clamped = Math.max(1, Math.min(1440, Math.round(patch.interval)));
|
||||
data.heartbeat.interval = clamped;
|
||||
}
|
||||
if (typeof patch.prompt === "string") {
|
||||
data.heartbeat.prompt = patch.prompt;
|
||||
}
|
||||
if (Array.isArray(patch.excludeWindows)) {
|
||||
data.heartbeat.excludeWindows = patch.excludeWindows;
|
||||
}
|
||||
|
||||
await writeFile(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
|
||||
return {
|
||||
enabled: Boolean(data.heartbeat.enabled),
|
||||
interval: Number(data.heartbeat.interval) || 15,
|
||||
prompt: typeof data.heartbeat.prompt === "string" ? data.heartbeat.prompt : "",
|
||||
excludeWindows: Array.isArray(data.heartbeat.excludeWindows) ? data.heartbeat.excludeWindows : [],
|
||||
};
|
||||
}
|
||||
80
src/ui/services/state.ts
Normal file
80
src/ui/services/state.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { peekSession } from "../../sessions";
|
||||
import { SESSION_FILE, SETTINGS_FILE, STATE_FILE } from "../constants";
|
||||
import type { WebSnapshot } from "../types";
|
||||
|
||||
export function sanitizeSettings(snapshot: WebSnapshot["settings"]) {
|
||||
return {
|
||||
timezone: snapshot.timezone,
|
||||
timezoneOffsetMinutes: snapshot.timezoneOffsetMinutes,
|
||||
heartbeat: snapshot.heartbeat,
|
||||
security: snapshot.security,
|
||||
telegram: {
|
||||
configured: Boolean(snapshot.telegram.token),
|
||||
allowedUserCount: snapshot.telegram.allowedUserIds.length,
|
||||
},
|
||||
web: snapshot.web,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildState(snapshot: WebSnapshot) {
|
||||
const now = Date.now();
|
||||
const session = await peekSession();
|
||||
return {
|
||||
daemon: {
|
||||
running: true,
|
||||
pid: snapshot.pid,
|
||||
startedAt: snapshot.startedAt,
|
||||
uptimeMs: now - snapshot.startedAt,
|
||||
},
|
||||
heartbeat: {
|
||||
enabled: snapshot.settings.heartbeat.enabled,
|
||||
intervalMinutes: snapshot.settings.heartbeat.interval,
|
||||
nextAt: snapshot.heartbeatNextAt || null,
|
||||
nextInMs: snapshot.heartbeatNextAt ? Math.max(0, snapshot.heartbeatNextAt - now) : null,
|
||||
},
|
||||
jobs: snapshot.jobs.map((j) => ({
|
||||
name: j.name,
|
||||
schedule: j.schedule,
|
||||
prompt: j.prompt,
|
||||
})),
|
||||
security: snapshot.settings.security,
|
||||
telegram: {
|
||||
configured: Boolean(snapshot.settings.telegram.token),
|
||||
allowedUserCount: snapshot.settings.telegram.allowedUserIds.length,
|
||||
},
|
||||
session: session
|
||||
? {
|
||||
sessionIdShort: session.sessionId.slice(0, 8),
|
||||
createdAt: session.createdAt,
|
||||
lastUsedAt: session.lastUsedAt,
|
||||
}
|
||||
: null,
|
||||
web: snapshot.settings.web,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildTechnicalInfo(snapshot: WebSnapshot) {
|
||||
return {
|
||||
daemon: {
|
||||
pid: snapshot.pid,
|
||||
startedAt: snapshot.startedAt,
|
||||
uptimeMs: Math.max(0, Date.now() - snapshot.startedAt),
|
||||
},
|
||||
files: {
|
||||
settingsJson: await readJsonFile(SETTINGS_FILE),
|
||||
sessionJson: await readJsonFile(SESSION_FILE),
|
||||
stateJson: await readJsonFile(STATE_FILE),
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonFile(path: string): Promise<unknown | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
30
src/ui/types.ts
Normal file
30
src/ui/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Settings } from "../config";
|
||||
import type { Job } from "../jobs";
|
||||
|
||||
export interface WebSnapshot {
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
heartbeatNextAt: number;
|
||||
settings: Settings;
|
||||
jobs: Job[];
|
||||
}
|
||||
|
||||
export interface WebServerHandle {
|
||||
stop: () => void;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface StartWebUiOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
getSnapshot: () => WebSnapshot;
|
||||
onHeartbeatEnabledChanged?: (enabled: boolean) => void | Promise<void>;
|
||||
onHeartbeatSettingsChanged?: (patch: {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
prompt?: string;
|
||||
excludeWindows?: Array<{ days?: number[]; start: string; end: string }>;
|
||||
}) => void | Promise<void>;
|
||||
onJobsChanged?: () => void | Promise<void>;
|
||||
}
|
||||
2
src/web.ts
Normal file
2
src/web.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { startWebUi } from "./ui";
|
||||
export type { StartWebUiOptions, WebServerHandle, WebSnapshot } from "./ui";
|
||||
18
src/whisper.ts
Normal file
18
src/whisper.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Whisper voice transcription stub
|
||||
// To enable voice transcription, install whisper and implement this module
|
||||
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
export interface TranscribeOptions {
|
||||
debug?: boolean;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
export async function transcribeAudioToText(
|
||||
audioPath: string,
|
||||
_options?: TranscribeOptions
|
||||
): Promise<string> {
|
||||
// Stub implementation - returns a message indicating transcription is not configured
|
||||
const content = await readFile(audioPath);
|
||||
return `[Voice message received - ${content.length} bytes. Transcription not configured.]`;
|
||||
}
|
||||
Reference in New Issue
Block a user