@locusai/locus-telegram 0.21.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Main entry point for locus-telegram.
3
+ *
4
+ * Dispatches to either:
5
+ * - PM2 service management (start/stop/restart/status/logs)
6
+ * - Direct bot execution (bot)
7
+ *
8
+ * Usage:
9
+ * locus pkg telegram start → start bot via PM2
10
+ * locus pkg telegram stop → stop bot via PM2
11
+ * locus pkg telegram restart → restart bot
12
+ * locus pkg telegram status → show PM2 process status
13
+ * locus pkg telegram logs → tail PM2 logs
14
+ * locus pkg telegram bot → run bot directly (foreground)
15
+ */
16
+
17
+ import { createLogger } from "@locusai/sdk";
18
+ import { createBot } from "./bot.js";
19
+ import { loadTelegramConfig } from "./config.js";
20
+ import {
21
+ pm2Delete,
22
+ pm2Logs,
23
+ pm2Restart,
24
+ pm2Start,
25
+ pm2Status,
26
+ pm2Stop,
27
+ } from "./pm2.js";
28
+
29
+ const logger = createLogger("telegram");
30
+
31
+ export async function main(args: string[]): Promise<void> {
32
+ const command = args[0] ?? "help";
33
+
34
+ switch (command) {
35
+ case "start":
36
+ return handleStart();
37
+ case "stop":
38
+ return handleStop();
39
+ case "restart":
40
+ return handleRestart();
41
+ case "delete":
42
+ return handleDelete();
43
+ case "status":
44
+ return handleStatus();
45
+ case "logs":
46
+ return handleLogs(args.slice(1));
47
+ case "bot":
48
+ return handleBot();
49
+ case "help":
50
+ case "--help":
51
+ case "-h":
52
+ return printHelp();
53
+ default:
54
+ console.error(`Unknown command: ${command}`);
55
+ printHelp();
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ // ─── PM2 Service Commands ───────────────────────────────────────────────────
61
+
62
+ function handleStart(): void {
63
+ const result = pm2Start();
64
+ logger.info(result);
65
+ }
66
+
67
+ function handleStop(): void {
68
+ const result = pm2Stop();
69
+ logger.info(result);
70
+ }
71
+
72
+ function handleRestart(): void {
73
+ const result = pm2Restart();
74
+ logger.info(result);
75
+ }
76
+
77
+ function handleDelete(): void {
78
+ const result = pm2Delete();
79
+ logger.info(result);
80
+ }
81
+
82
+ function handleStatus(): void {
83
+ const status = pm2Status();
84
+ if (!status) {
85
+ logger.warn("Bot is not running");
86
+ return;
87
+ }
88
+
89
+ const uptimeStr = status.uptime
90
+ ? `${Math.floor((Date.now() - status.uptime) / 1000)}s`
91
+ : "N/A";
92
+ const memStr = status.memory
93
+ ? `${(status.memory / (1024 * 1024)).toFixed(1)}MB`
94
+ : "N/A";
95
+
96
+ console.log(`
97
+ Name: ${status.name}
98
+ Status: ${status.status}
99
+ PID: ${status.pid ?? "N/A"}
100
+ Uptime: ${uptimeStr}
101
+ Memory: ${memStr}
102
+ Restarts: ${status.restarts}
103
+ `);
104
+ }
105
+
106
+ function handleLogs(args: string[]): void {
107
+ const lines = args[0] ? Number(args[0]) : 50;
108
+ const logs = pm2Logs(lines);
109
+ console.log(logs);
110
+ }
111
+
112
+ // ─── Direct Bot Execution ───────────────────────────────────────────────────
113
+
114
+ async function handleBot(): Promise<void> {
115
+ const config = loadTelegramConfig();
116
+ const bot = createBot(config);
117
+
118
+ logger.info("Starting Telegram bot...");
119
+ logger.info(`Allowed chat IDs: ${config.allowedChatIds.join(", ")}`);
120
+
121
+ // Graceful shutdown
122
+ const shutdown = () => {
123
+ logger.info("Shutting down bot...");
124
+ bot.stop();
125
+ process.exit(0);
126
+ };
127
+
128
+ process.on("SIGINT", shutdown);
129
+ process.on("SIGTERM", shutdown);
130
+
131
+ await bot.start({
132
+ onStart: () => {
133
+ logger.info("Bot is running. Send /help in Telegram to get started.");
134
+ },
135
+ });
136
+ }
137
+
138
+ // ─── Help ───────────────────────────────────────────────────────────────────
139
+
140
+ function printHelp(): void {
141
+ console.log(`
142
+ locus-telegram — Remote-control Locus via Telegram
143
+
144
+ Usage:
145
+ locus pkg telegram <command>
146
+
147
+ Commands:
148
+ start Start the bot via PM2 (background)
149
+ stop Stop the bot
150
+ restart Restart the bot
151
+ delete Remove the bot from PM2
152
+ status Show bot process status
153
+ logs [n] Show last n lines of logs (default: 50)
154
+ bot Run the bot directly (foreground)
155
+ help Show this help message
156
+
157
+ Configuration (via locus config):
158
+ packages.telegram.botToken Telegram Bot API token (from @BotFather)
159
+ packages.telegram.chatIds Comma-separated chat IDs or JSON array
160
+
161
+ Setup:
162
+ locus config packages.telegram.botToken "123456:ABC-DEF..."
163
+ locus config packages.telegram.chatIds "12345678"
164
+
165
+ Examples:
166
+ locus pkg telegram start # Start in background
167
+ locus pkg telegram bot # Run in foreground (development)
168
+ `);
169
+ }
package/src/pm2.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * PM2 programmatic wrapper for managing the Telegram bot process.
3
+ *
4
+ * Uses pm2 package API to start/stop/restart/status/logs the bot.
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const PROCESS_NAME = "locus-telegram";
12
+
13
+ function getPm2Bin(): string {
14
+ // Try local node_modules first, then global
15
+ try {
16
+ const result = execSync("which pm2", {
17
+ encoding: "utf-8",
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ }).trim();
20
+ if (result) return result;
21
+ } catch {
22
+ // fall through
23
+ }
24
+
25
+ // Try npx as fallback
26
+ return "npx pm2";
27
+ }
28
+
29
+ function pm2Exec(args: string): string {
30
+ const pm2 = getPm2Bin();
31
+ try {
32
+ return execSync(`${pm2} ${args}`, {
33
+ encoding: "utf-8",
34
+ stdio: ["pipe", "pipe", "pipe"],
35
+ env: process.env,
36
+ });
37
+ } catch (error: unknown) {
38
+ const err = error as { stderr?: string; message?: string };
39
+ throw new Error(err.stderr?.trim() || err.message || "PM2 command failed");
40
+ }
41
+ }
42
+
43
+ function getBotScriptPath(): string {
44
+ const currentFile = fileURLToPath(import.meta.url);
45
+ const packageRoot = dirname(dirname(currentFile));
46
+ return join(packageRoot, "bin", "locus-telegram.js");
47
+ }
48
+
49
+ export function pm2Start(): string {
50
+ const script = getBotScriptPath();
51
+ const pm2 = getPm2Bin();
52
+
53
+ try {
54
+ // Check if already running
55
+ const list = pm2Exec("jlist");
56
+ const processes = JSON.parse(list) as Array<{ name: string }>;
57
+ const existing = processes.find((p) => p.name === PROCESS_NAME);
58
+ if (existing) {
59
+ pm2Exec(`restart ${PROCESS_NAME}`);
60
+ return `Restarted ${PROCESS_NAME}`;
61
+ }
62
+ } catch {
63
+ // Not running, start fresh
64
+ }
65
+
66
+ execSync(
67
+ `${pm2} start ${JSON.stringify(script)} --name ${PROCESS_NAME} -- bot`,
68
+ {
69
+ encoding: "utf-8",
70
+ stdio: "inherit",
71
+ env: process.env,
72
+ }
73
+ );
74
+
75
+ return `Started ${PROCESS_NAME}`;
76
+ }
77
+
78
+ export function pm2Stop(): string {
79
+ pm2Exec(`stop ${PROCESS_NAME}`);
80
+ return `Stopped ${PROCESS_NAME}`;
81
+ }
82
+
83
+ export function pm2Restart(): string {
84
+ pm2Exec(`restart ${PROCESS_NAME}`);
85
+ return `Restarted ${PROCESS_NAME}`;
86
+ }
87
+
88
+ export function pm2Delete(): string {
89
+ pm2Exec(`delete ${PROCESS_NAME}`);
90
+ return `Deleted ${PROCESS_NAME}`;
91
+ }
92
+
93
+ export interface Pm2Status {
94
+ name: string;
95
+ status: string;
96
+ pid: number | null;
97
+ uptime: number | null;
98
+ memory: number | null;
99
+ restarts: number;
100
+ }
101
+
102
+ export function pm2Status(): Pm2Status | null {
103
+ try {
104
+ const list = pm2Exec("jlist");
105
+ const processes = JSON.parse(list) as Array<{
106
+ name: string;
107
+ pm2_env?: {
108
+ status?: string;
109
+ pm_uptime?: number;
110
+ restart_time?: number;
111
+ };
112
+ pid?: number;
113
+ monit?: { memory?: number };
114
+ }>;
115
+
116
+ const proc = processes.find((p) => p.name === PROCESS_NAME);
117
+ if (!proc) return null;
118
+
119
+ return {
120
+ name: PROCESS_NAME,
121
+ status: proc.pm2_env?.status ?? "unknown",
122
+ pid: proc.pid ?? null,
123
+ uptime: proc.pm2_env?.pm_uptime ?? null,
124
+ memory: proc.monit?.memory ?? null,
125
+ restarts: proc.pm2_env?.restart_time ?? 0,
126
+ };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ export function pm2Logs(lines = 50): string {
133
+ try {
134
+ return pm2Exec(`logs ${PROCESS_NAME} --nostream --lines ${lines}`);
135
+ } catch {
136
+ return "No logs available.";
137
+ }
138
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Output formatting utilities for Telegram messages.
3
+ *
4
+ * Telegram supports a subset of Markdown (MarkdownV2) and HTML.
5
+ * We use HTML mode for reliability — fewer escaping issues than MarkdownV2.
6
+ */
7
+
8
+ /** Maximum Telegram message length (4096 chars). */
9
+ const MAX_MESSAGE_LENGTH = 4096;
10
+
11
+ /** Maximum length for a code block inside a message. */
12
+ const MAX_CODE_LENGTH = 3800;
13
+
14
+ /** Escape HTML special characters for Telegram HTML mode. */
15
+ export function escapeHtml(text: string): string {
16
+ return text
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;");
20
+ }
21
+
22
+ /** Wrap text in a code block. */
23
+ export function codeBlock(text: string, language = ""): string {
24
+ const truncated = truncate(text, MAX_CODE_LENGTH);
25
+ return `<pre><code${language ? ` class="language-${language}"` : ""}>${escapeHtml(truncated)}</code></pre>`;
26
+ }
27
+
28
+ /** Wrap text in inline code. */
29
+ export function inlineCode(text: string): string {
30
+ return `<code>${escapeHtml(text)}</code>`;
31
+ }
32
+
33
+ /** Bold text. */
34
+ export function bold(text: string): string {
35
+ return `<b>${text}</b>`;
36
+ }
37
+
38
+ /** Italic text. */
39
+ export function italic(text: string): string {
40
+ return `<i>${text}</i>`;
41
+ }
42
+
43
+ /** Truncate text to a max length, appending "..." if truncated. */
44
+ export function truncate(text: string, maxLength = MAX_MESSAGE_LENGTH): string {
45
+ if (text.length <= maxLength) return text;
46
+ return `${text.slice(0, maxLength - 20)}\n\n... (truncated)`;
47
+ }
48
+
49
+ /** Split long output into multiple messages if needed. */
50
+ export function splitMessage(text: string): string[] {
51
+ if (text.length <= MAX_MESSAGE_LENGTH) return [text];
52
+
53
+ const chunks: string[] = [];
54
+ let remaining = text;
55
+
56
+ while (remaining.length > 0) {
57
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
58
+ chunks.push(remaining);
59
+ break;
60
+ }
61
+
62
+ // Try to split at a newline
63
+ let splitIdx = remaining.lastIndexOf("\n", MAX_MESSAGE_LENGTH);
64
+ if (splitIdx === -1 || splitIdx < MAX_MESSAGE_LENGTH / 2) {
65
+ splitIdx = MAX_MESSAGE_LENGTH;
66
+ }
67
+
68
+ chunks.push(remaining.slice(0, splitIdx));
69
+ remaining = remaining.slice(splitIdx);
70
+ }
71
+
72
+ return chunks;
73
+ }
74
+
75
+ /** Format a command result as a Telegram message. */
76
+ export function formatCommandResult(
77
+ command: string,
78
+ output: string,
79
+ exitCode: number
80
+ ): string {
81
+ const status = exitCode === 0 ? "✅" : "❌";
82
+ const header = `${status} ${bold(escapeHtml(command))}`;
83
+
84
+ if (!output.trim()) {
85
+ return exitCode === 0
86
+ ? `${header}\n\nCompleted successfully.`
87
+ : `${header}\n\nFailed with exit code ${exitCode}.`;
88
+ }
89
+
90
+ return `${header}\n\n${codeBlock(output.trim())}`;
91
+ }
92
+
93
+ /** Format a streaming progress message. */
94
+ export function formatStreamingMessage(
95
+ command: string,
96
+ output: string,
97
+ isComplete: boolean
98
+ ): string {
99
+ const status = isComplete ? "✅" : "⏳";
100
+ const header = `${status} ${bold(escapeHtml(command))}`;
101
+
102
+ if (!output.trim()) {
103
+ return `${header}\n\n${italic("Running...")}`;
104
+ }
105
+
106
+ // Take last N lines for streaming display
107
+ const lines = output.trim().split("\n");
108
+ const lastLines = lines.slice(-30).join("\n");
109
+
110
+ return `${header}\n\n${codeBlock(lastLines)}`;
111
+ }
112
+
113
+ /** Format an error message. */
114
+ export function formatError(message: string, detail?: string): string {
115
+ let text = `❌ ${bold("Error")}\n\n${escapeHtml(message)}`;
116
+ if (detail) {
117
+ text += `\n\n${codeBlock(detail)}`;
118
+ }
119
+ return text;
120
+ }
121
+
122
+ /** Format a success message. */
123
+ export function formatSuccess(message: string): string {
124
+ return `✅ ${escapeHtml(message)}`;
125
+ }
126
+
127
+ /** Format an info message. */
128
+ export function formatInfo(message: string): string {
129
+ return `ℹ️ ${escapeHtml(message)}`;
130
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Inline keyboard builders for interactive Telegram responses.
3
+ *
4
+ * Keyboards are attached to messages to let users take actions
5
+ * without typing commands manually.
6
+ */
7
+
8
+ import { InlineKeyboard } from "grammy";
9
+
10
+ // ─── Callback Data Prefixes ─────────────────────────────────────────────────
11
+
12
+ export const CB = {
13
+ APPROVE_PLAN: "plan:approve",
14
+ REJECT_PLAN: "plan:reject",
15
+ SHOW_PLAN_DETAILS: "plan:details",
16
+ CONFIRM_ACTION: "confirm:yes",
17
+ CANCEL_ACTION: "confirm:no",
18
+ VIEW_PR: "pr:view:",
19
+ VIEW_LOGS: "logs:view",
20
+ RUN_AGAIN: "run:again:",
21
+ APPROVE_REVIEW: "review:approve:",
22
+ REQUEST_CHANGES: "review:changes:",
23
+ VIEW_DIFF: "review:diff:",
24
+ RUN_SPRINT: "sprint:run",
25
+ VIEW_ISSUES: "issues:view",
26
+ STASH_POP: "stash:pop",
27
+ STASH_LIST: "stash:list",
28
+ STASH_DROP: "stash:drop",
29
+ } as const;
30
+
31
+ // ─── Keyboard Builders ──────────────────────────────────────────────────────
32
+
33
+ /** Keyboard shown after plan creation. */
34
+ export function planKeyboard(): InlineKeyboard {
35
+ return new InlineKeyboard()
36
+ .text("✅ Approve Plan", CB.APPROVE_PLAN)
37
+ .text("❌ Reject Plan", CB.REJECT_PLAN)
38
+ .row()
39
+ .text("📋 Show Details", CB.SHOW_PLAN_DETAILS);
40
+ }
41
+
42
+ /** Keyboard shown after a run completes. */
43
+ export function runCompleteKeyboard(issueNumber?: number): InlineKeyboard {
44
+ const kb = new InlineKeyboard();
45
+ kb.text("📄 View Logs", CB.VIEW_LOGS);
46
+ if (issueNumber) {
47
+ kb.text("🔄 Run Again", `${CB.RUN_AGAIN}${issueNumber}`);
48
+ }
49
+ return kb;
50
+ }
51
+
52
+ /** Keyboard shown after review. */
53
+ export function reviewKeyboard(prNumber: number): InlineKeyboard {
54
+ return new InlineKeyboard()
55
+ .text("✅ Approve", `${CB.APPROVE_REVIEW}${prNumber}`)
56
+ .text("🔄 Request Changes", `${CB.REQUEST_CHANGES}${prNumber}`)
57
+ .row()
58
+ .text("📝 View Diff", `${CB.VIEW_DIFF}${prNumber}`);
59
+ }
60
+
61
+ /** Confirmation keyboard for destructive operations. */
62
+ export function confirmKeyboard(): InlineKeyboard {
63
+ return new InlineKeyboard()
64
+ .text("✅ Confirm", CB.CONFIRM_ACTION)
65
+ .text("❌ Cancel", CB.CANCEL_ACTION);
66
+ }
67
+
68
+ /** Keyboard shown after status check. */
69
+ export function statusKeyboard(): InlineKeyboard {
70
+ return new InlineKeyboard()
71
+ .text("🚀 Run Sprint", CB.RUN_SPRINT)
72
+ .text("📋 Issues", CB.VIEW_ISSUES)
73
+ .row()
74
+ .text("📄 Logs", CB.VIEW_LOGS);
75
+ }
76
+
77
+ /** Keyboard for stash operations. */
78
+ export function stashKeyboard(): InlineKeyboard {
79
+ return new InlineKeyboard()
80
+ .text("📤 Pop", CB.STASH_POP)
81
+ .text("📋 List", CB.STASH_LIST)
82
+ .text("🗑 Drop", CB.STASH_DROP);
83
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Predefined response message templates for common bot interactions.
3
+ *
4
+ * All messages use HTML formatting for Telegram.
5
+ */
6
+
7
+ import { bold, codeBlock, escapeHtml, italic } from "./format.js";
8
+
9
+ // ─── Welcome & Help ─────────────────────────────────────────────────────────
10
+
11
+ export function welcomeMessage(): string {
12
+ return [
13
+ `${bold("🤖 Locus Telegram Bot")}`,
14
+ "",
15
+ "Control your Locus agent directly from Telegram.",
16
+ "",
17
+ `${bold("Locus Commands")}`,
18
+ "/run [issue#...] — Execute issues",
19
+ "/status — Dashboard view",
20
+ "/issues [filters] — List issues",
21
+ "/issue &lt;#&gt; — Show issue details",
22
+ "/sprint [sub] — Sprint management",
23
+ "/plan [args] — AI planning",
24
+ "/review &lt;pr#&gt; — Code review",
25
+ "/iterate &lt;pr#&gt; — Re-execute with feedback",
26
+ "/discuss [topic] — AI discussion",
27
+ "/exec [prompt] — REPL / one-shot",
28
+ "/logs — View logs",
29
+ "/config [path] — View config",
30
+ "/artifacts — View artifacts",
31
+ "",
32
+ `${bold("Git Commands")}`,
33
+ "/gitstatus — Git status",
34
+ "/stage [files|.] — Stage files",
35
+ "/commit &lt;message&gt; — Commit changes",
36
+ "/stash [pop|list|drop] — Stash operations",
37
+ "/branch [name] — List/create branches",
38
+ "/checkout &lt;branch&gt; — Switch branch",
39
+ "/diff — Show diff",
40
+ "/pr &lt;title&gt; — Create pull request",
41
+ "",
42
+ `${bold("Service Commands")}`,
43
+ "/service start|stop|restart|status|logs",
44
+ "",
45
+ "/help — Show this message",
46
+ ].join("\n");
47
+ }
48
+
49
+ // ─── Plan Messages ──────────────────────────────────────────────────────────
50
+
51
+ export function planCreatedMessage(planOutput: string): string {
52
+ return [
53
+ `📋 ${bold("Plan Created")}`,
54
+ "",
55
+ codeBlock(planOutput),
56
+ "",
57
+ italic("Use the buttons below to approve or reject the plan."),
58
+ ].join("\n");
59
+ }
60
+
61
+ export function planApprovedMessage(): string {
62
+ return `✅ ${bold("Plan Approved")} — Proceeding with execution.`;
63
+ }
64
+
65
+ export function planRejectedMessage(): string {
66
+ return `❌ ${bold("Plan Rejected")} — No changes will be made.`;
67
+ }
68
+
69
+ // ─── Run Messages ───────────────────────────────────────────────────────────
70
+
71
+ export function runStartedMessage(target: string): string {
72
+ return `🚀 ${bold("Run Started")} — ${escapeHtml(target)}`;
73
+ }
74
+
75
+ export function runCompletedMessage(target: string, prNumber?: number): string {
76
+ const lines = [`✅ ${bold("Run Completed")} — ${escapeHtml(target)}`];
77
+ if (prNumber) {
78
+ lines.push("", `Pull Request: #${prNumber}`);
79
+ }
80
+ return lines.join("\n");
81
+ }
82
+
83
+ export function runFailedMessage(target: string, error: string): string {
84
+ return [
85
+ `❌ ${bold("Run Failed")} — ${escapeHtml(target)}`,
86
+ "",
87
+ codeBlock(error),
88
+ ].join("\n");
89
+ }
90
+
91
+ // ─── Review Messages ────────────────────────────────────────────────────────
92
+
93
+ export function reviewStartedMessage(prNumber: number): string {
94
+ return `🔍 ${bold("Reviewing")} PR #${prNumber}...`;
95
+ }
96
+
97
+ export function reviewCompletedMessage(
98
+ prNumber: number,
99
+ output: string
100
+ ): string {
101
+ return [
102
+ `✅ ${bold("Review Complete")} — PR #${prNumber}`,
103
+ "",
104
+ codeBlock(output),
105
+ ].join("\n");
106
+ }
107
+
108
+ // ─── Git Messages ───────────────────────────────────────────────────────────
109
+
110
+ export function gitCommitMessage(message: string, hash: string): string {
111
+ return [
112
+ `✅ ${bold("Committed")}`,
113
+ "",
114
+ `Message: ${escapeHtml(message)}`,
115
+ `Hash: ${escapeHtml(hash)}`,
116
+ ].join("\n");
117
+ }
118
+
119
+ export function gitBranchCreatedMessage(name: string): string {
120
+ return `✅ Branch ${bold(escapeHtml(name))} created.`;
121
+ }
122
+
123
+ export function gitCheckoutMessage(branch: string): string {
124
+ return `✅ Switched to branch ${bold(escapeHtml(branch))}.`;
125
+ }
126
+
127
+ export function gitStashMessage(action: string): string {
128
+ return `✅ ${bold("Stash")} — ${escapeHtml(action)} completed.`;
129
+ }
130
+
131
+ export function prCreatedMessage(prNumber: number, url: string): string {
132
+ return [
133
+ `✅ ${bold("Pull Request Created")}`,
134
+ "",
135
+ `PR #${prNumber}: ${escapeHtml(url)}`,
136
+ ].join("\n");
137
+ }
138
+
139
+ // ─── Service Messages ───────────────────────────────────────────────────────
140
+
141
+ export function serviceStatusMessage(
142
+ status: string,
143
+ pid: number | null,
144
+ uptime: number | null,
145
+ memory: number | null,
146
+ restarts: number
147
+ ): string {
148
+ const uptimeStr = uptime ? formatUptime(uptime) : "N/A";
149
+ const memoryStr = memory ? formatMemory(memory) : "N/A";
150
+
151
+ return [
152
+ `🤖 ${bold("Locus Telegram Bot")}`,
153
+ "",
154
+ `Status: ${bold(status)}`,
155
+ `PID: ${pid ?? "N/A"}`,
156
+ `Uptime: ${uptimeStr}`,
157
+ `Memory: ${memoryStr}`,
158
+ `Restarts: ${restarts}`,
159
+ ].join("\n");
160
+ }
161
+
162
+ export function serviceNotRunningMessage(): string {
163
+ return [
164
+ `⚠️ ${bold("Bot Not Running")}`,
165
+ "",
166
+ "Start the bot with:",
167
+ `${bold("locus pkg telegram start")}`,
168
+ ].join("\n");
169
+ }
170
+
171
+ // ─── Helpers ────────────────────────────────────────────────────────────────
172
+
173
+ function formatUptime(startTimeMs: number): string {
174
+ const diff = Date.now() - startTimeMs;
175
+ const seconds = Math.floor(diff / 1000);
176
+ const minutes = Math.floor(seconds / 60);
177
+ const hours = Math.floor(minutes / 60);
178
+ const days = Math.floor(hours / 24);
179
+
180
+ if (days > 0) return `${days}d ${hours % 24}h`;
181
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
182
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
183
+ return `${seconds}s`;
184
+ }
185
+
186
+ function formatMemory(bytes: number): string {
187
+ const mb = bytes / (1024 * 1024);
188
+ return `${mb.toFixed(1)} MB`;
189
+ }