@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/README.md +117 -0
- package/bin/locus-telegram.js +7 -0
- package/dist/bot.d.ts +7 -0
- package/dist/bot.js +203 -0
- package/dist/commands/git.d.ts +23 -0
- package/dist/commands/git.js +255 -0
- package/dist/commands/locus.d.ts +12 -0
- package/dist/commands/locus.js +169 -0
- package/dist/commands/service.d.ts +6 -0
- package/dist/commands/service.js +58 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +32 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +144 -0
- package/dist/pm2.d.ts +19 -0
- package/dist/pm2.js +112 -0
- package/dist/ui/format.d.ts +30 -0
- package/dist/ui/format.js +100 -0
- package/dist/ui/keyboards.d.ts +37 -0
- package/dist/ui/keyboards.js +73 -0
- package/dist/ui/messages.d.ts +21 -0
- package/dist/ui/messages.js +155 -0
- package/package.json +35 -0
- package/src/bot.ts +256 -0
- package/src/commands/git.ts +309 -0
- package/src/commands/locus.ts +232 -0
- package/src/commands/service.ts +89 -0
- package/src/config.ts +78 -0
- package/src/index.ts +169 -0
- package/src/pm2.ts +138 -0
- package/src/ui/format.ts +130 -0
- package/src/ui/keyboards.ts +83 -0
- package/src/ui/messages.ts +189 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
/** Maximum Telegram message length (4096 chars). */
|
|
8
|
+
const MAX_MESSAGE_LENGTH = 4096;
|
|
9
|
+
/** Maximum length for a code block inside a message. */
|
|
10
|
+
const MAX_CODE_LENGTH = 3800;
|
|
11
|
+
/** Escape HTML special characters for Telegram HTML mode. */
|
|
12
|
+
export function escapeHtml(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">");
|
|
17
|
+
}
|
|
18
|
+
/** Wrap text in a code block. */
|
|
19
|
+
export function codeBlock(text, language = "") {
|
|
20
|
+
const truncated = truncate(text, MAX_CODE_LENGTH);
|
|
21
|
+
return `<pre><code${language ? ` class="language-${language}"` : ""}>${escapeHtml(truncated)}</code></pre>`;
|
|
22
|
+
}
|
|
23
|
+
/** Wrap text in inline code. */
|
|
24
|
+
export function inlineCode(text) {
|
|
25
|
+
return `<code>${escapeHtml(text)}</code>`;
|
|
26
|
+
}
|
|
27
|
+
/** Bold text. */
|
|
28
|
+
export function bold(text) {
|
|
29
|
+
return `<b>${text}</b>`;
|
|
30
|
+
}
|
|
31
|
+
/** Italic text. */
|
|
32
|
+
export function italic(text) {
|
|
33
|
+
return `<i>${text}</i>`;
|
|
34
|
+
}
|
|
35
|
+
/** Truncate text to a max length, appending "..." if truncated. */
|
|
36
|
+
export function truncate(text, maxLength = MAX_MESSAGE_LENGTH) {
|
|
37
|
+
if (text.length <= maxLength)
|
|
38
|
+
return text;
|
|
39
|
+
return `${text.slice(0, maxLength - 20)}\n\n... (truncated)`;
|
|
40
|
+
}
|
|
41
|
+
/** Split long output into multiple messages if needed. */
|
|
42
|
+
export function splitMessage(text) {
|
|
43
|
+
if (text.length <= MAX_MESSAGE_LENGTH)
|
|
44
|
+
return [text];
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let remaining = text;
|
|
47
|
+
while (remaining.length > 0) {
|
|
48
|
+
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
|
49
|
+
chunks.push(remaining);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
// Try to split at a newline
|
|
53
|
+
let splitIdx = remaining.lastIndexOf("\n", MAX_MESSAGE_LENGTH);
|
|
54
|
+
if (splitIdx === -1 || splitIdx < MAX_MESSAGE_LENGTH / 2) {
|
|
55
|
+
splitIdx = MAX_MESSAGE_LENGTH;
|
|
56
|
+
}
|
|
57
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
58
|
+
remaining = remaining.slice(splitIdx);
|
|
59
|
+
}
|
|
60
|
+
return chunks;
|
|
61
|
+
}
|
|
62
|
+
/** Format a command result as a Telegram message. */
|
|
63
|
+
export function formatCommandResult(command, output, exitCode) {
|
|
64
|
+
const status = exitCode === 0 ? "✅" : "❌";
|
|
65
|
+
const header = `${status} ${bold(escapeHtml(command))}`;
|
|
66
|
+
if (!output.trim()) {
|
|
67
|
+
return exitCode === 0
|
|
68
|
+
? `${header}\n\nCompleted successfully.`
|
|
69
|
+
: `${header}\n\nFailed with exit code ${exitCode}.`;
|
|
70
|
+
}
|
|
71
|
+
return `${header}\n\n${codeBlock(output.trim())}`;
|
|
72
|
+
}
|
|
73
|
+
/** Format a streaming progress message. */
|
|
74
|
+
export function formatStreamingMessage(command, output, isComplete) {
|
|
75
|
+
const status = isComplete ? "✅" : "⏳";
|
|
76
|
+
const header = `${status} ${bold(escapeHtml(command))}`;
|
|
77
|
+
if (!output.trim()) {
|
|
78
|
+
return `${header}\n\n${italic("Running...")}`;
|
|
79
|
+
}
|
|
80
|
+
// Take last N lines for streaming display
|
|
81
|
+
const lines = output.trim().split("\n");
|
|
82
|
+
const lastLines = lines.slice(-30).join("\n");
|
|
83
|
+
return `${header}\n\n${codeBlock(lastLines)}`;
|
|
84
|
+
}
|
|
85
|
+
/** Format an error message. */
|
|
86
|
+
export function formatError(message, detail) {
|
|
87
|
+
let text = `❌ ${bold("Error")}\n\n${escapeHtml(message)}`;
|
|
88
|
+
if (detail) {
|
|
89
|
+
text += `\n\n${codeBlock(detail)}`;
|
|
90
|
+
}
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
/** Format a success message. */
|
|
94
|
+
export function formatSuccess(message) {
|
|
95
|
+
return `✅ ${escapeHtml(message)}`;
|
|
96
|
+
}
|
|
97
|
+
/** Format an info message. */
|
|
98
|
+
export function formatInfo(message) {
|
|
99
|
+
return `ℹ️ ${escapeHtml(message)}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
import { InlineKeyboard } from "grammy";
|
|
8
|
+
export declare const CB: {
|
|
9
|
+
readonly APPROVE_PLAN: "plan:approve";
|
|
10
|
+
readonly REJECT_PLAN: "plan:reject";
|
|
11
|
+
readonly SHOW_PLAN_DETAILS: "plan:details";
|
|
12
|
+
readonly CONFIRM_ACTION: "confirm:yes";
|
|
13
|
+
readonly CANCEL_ACTION: "confirm:no";
|
|
14
|
+
readonly VIEW_PR: "pr:view:";
|
|
15
|
+
readonly VIEW_LOGS: "logs:view";
|
|
16
|
+
readonly RUN_AGAIN: "run:again:";
|
|
17
|
+
readonly APPROVE_REVIEW: "review:approve:";
|
|
18
|
+
readonly REQUEST_CHANGES: "review:changes:";
|
|
19
|
+
readonly VIEW_DIFF: "review:diff:";
|
|
20
|
+
readonly RUN_SPRINT: "sprint:run";
|
|
21
|
+
readonly VIEW_ISSUES: "issues:view";
|
|
22
|
+
readonly STASH_POP: "stash:pop";
|
|
23
|
+
readonly STASH_LIST: "stash:list";
|
|
24
|
+
readonly STASH_DROP: "stash:drop";
|
|
25
|
+
};
|
|
26
|
+
/** Keyboard shown after plan creation. */
|
|
27
|
+
export declare function planKeyboard(): InlineKeyboard;
|
|
28
|
+
/** Keyboard shown after a run completes. */
|
|
29
|
+
export declare function runCompleteKeyboard(issueNumber?: number): InlineKeyboard;
|
|
30
|
+
/** Keyboard shown after review. */
|
|
31
|
+
export declare function reviewKeyboard(prNumber: number): InlineKeyboard;
|
|
32
|
+
/** Confirmation keyboard for destructive operations. */
|
|
33
|
+
export declare function confirmKeyboard(): InlineKeyboard;
|
|
34
|
+
/** Keyboard shown after status check. */
|
|
35
|
+
export declare function statusKeyboard(): InlineKeyboard;
|
|
36
|
+
/** Keyboard for stash operations. */
|
|
37
|
+
export declare function stashKeyboard(): InlineKeyboard;
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
import { InlineKeyboard } from "grammy";
|
|
8
|
+
// ─── Callback Data Prefixes ─────────────────────────────────────────────────
|
|
9
|
+
export const CB = {
|
|
10
|
+
APPROVE_PLAN: "plan:approve",
|
|
11
|
+
REJECT_PLAN: "plan:reject",
|
|
12
|
+
SHOW_PLAN_DETAILS: "plan:details",
|
|
13
|
+
CONFIRM_ACTION: "confirm:yes",
|
|
14
|
+
CANCEL_ACTION: "confirm:no",
|
|
15
|
+
VIEW_PR: "pr:view:",
|
|
16
|
+
VIEW_LOGS: "logs:view",
|
|
17
|
+
RUN_AGAIN: "run:again:",
|
|
18
|
+
APPROVE_REVIEW: "review:approve:",
|
|
19
|
+
REQUEST_CHANGES: "review:changes:",
|
|
20
|
+
VIEW_DIFF: "review:diff:",
|
|
21
|
+
RUN_SPRINT: "sprint:run",
|
|
22
|
+
VIEW_ISSUES: "issues:view",
|
|
23
|
+
STASH_POP: "stash:pop",
|
|
24
|
+
STASH_LIST: "stash:list",
|
|
25
|
+
STASH_DROP: "stash:drop",
|
|
26
|
+
};
|
|
27
|
+
// ─── Keyboard Builders ──────────────────────────────────────────────────────
|
|
28
|
+
/** Keyboard shown after plan creation. */
|
|
29
|
+
export function planKeyboard() {
|
|
30
|
+
return new InlineKeyboard()
|
|
31
|
+
.text("✅ Approve Plan", CB.APPROVE_PLAN)
|
|
32
|
+
.text("❌ Reject Plan", CB.REJECT_PLAN)
|
|
33
|
+
.row()
|
|
34
|
+
.text("📋 Show Details", CB.SHOW_PLAN_DETAILS);
|
|
35
|
+
}
|
|
36
|
+
/** Keyboard shown after a run completes. */
|
|
37
|
+
export function runCompleteKeyboard(issueNumber) {
|
|
38
|
+
const kb = new InlineKeyboard();
|
|
39
|
+
kb.text("📄 View Logs", CB.VIEW_LOGS);
|
|
40
|
+
if (issueNumber) {
|
|
41
|
+
kb.text("🔄 Run Again", `${CB.RUN_AGAIN}${issueNumber}`);
|
|
42
|
+
}
|
|
43
|
+
return kb;
|
|
44
|
+
}
|
|
45
|
+
/** Keyboard shown after review. */
|
|
46
|
+
export function reviewKeyboard(prNumber) {
|
|
47
|
+
return new InlineKeyboard()
|
|
48
|
+
.text("✅ Approve", `${CB.APPROVE_REVIEW}${prNumber}`)
|
|
49
|
+
.text("🔄 Request Changes", `${CB.REQUEST_CHANGES}${prNumber}`)
|
|
50
|
+
.row()
|
|
51
|
+
.text("📝 View Diff", `${CB.VIEW_DIFF}${prNumber}`);
|
|
52
|
+
}
|
|
53
|
+
/** Confirmation keyboard for destructive operations. */
|
|
54
|
+
export function confirmKeyboard() {
|
|
55
|
+
return new InlineKeyboard()
|
|
56
|
+
.text("✅ Confirm", CB.CONFIRM_ACTION)
|
|
57
|
+
.text("❌ Cancel", CB.CANCEL_ACTION);
|
|
58
|
+
}
|
|
59
|
+
/** Keyboard shown after status check. */
|
|
60
|
+
export function statusKeyboard() {
|
|
61
|
+
return new InlineKeyboard()
|
|
62
|
+
.text("🚀 Run Sprint", CB.RUN_SPRINT)
|
|
63
|
+
.text("📋 Issues", CB.VIEW_ISSUES)
|
|
64
|
+
.row()
|
|
65
|
+
.text("📄 Logs", CB.VIEW_LOGS);
|
|
66
|
+
}
|
|
67
|
+
/** Keyboard for stash operations. */
|
|
68
|
+
export function stashKeyboard() {
|
|
69
|
+
return new InlineKeyboard()
|
|
70
|
+
.text("📤 Pop", CB.STASH_POP)
|
|
71
|
+
.text("📋 List", CB.STASH_LIST)
|
|
72
|
+
.text("🗑 Drop", CB.STASH_DROP);
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predefined response message templates for common bot interactions.
|
|
3
|
+
*
|
|
4
|
+
* All messages use HTML formatting for Telegram.
|
|
5
|
+
*/
|
|
6
|
+
export declare function welcomeMessage(): string;
|
|
7
|
+
export declare function planCreatedMessage(planOutput: string): string;
|
|
8
|
+
export declare function planApprovedMessage(): string;
|
|
9
|
+
export declare function planRejectedMessage(): string;
|
|
10
|
+
export declare function runStartedMessage(target: string): string;
|
|
11
|
+
export declare function runCompletedMessage(target: string, prNumber?: number): string;
|
|
12
|
+
export declare function runFailedMessage(target: string, error: string): string;
|
|
13
|
+
export declare function reviewStartedMessage(prNumber: number): string;
|
|
14
|
+
export declare function reviewCompletedMessage(prNumber: number, output: string): string;
|
|
15
|
+
export declare function gitCommitMessage(message: string, hash: string): string;
|
|
16
|
+
export declare function gitBranchCreatedMessage(name: string): string;
|
|
17
|
+
export declare function gitCheckoutMessage(branch: string): string;
|
|
18
|
+
export declare function gitStashMessage(action: string): string;
|
|
19
|
+
export declare function prCreatedMessage(prNumber: number, url: string): string;
|
|
20
|
+
export declare function serviceStatusMessage(status: string, pid: number | null, uptime: number | null, memory: number | null, restarts: number): string;
|
|
21
|
+
export declare function serviceNotRunningMessage(): string;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predefined response message templates for common bot interactions.
|
|
3
|
+
*
|
|
4
|
+
* All messages use HTML formatting for Telegram.
|
|
5
|
+
*/
|
|
6
|
+
import { bold, codeBlock, escapeHtml, italic } from "./format.js";
|
|
7
|
+
// ─── Welcome & Help ─────────────────────────────────────────────────────────
|
|
8
|
+
export function welcomeMessage() {
|
|
9
|
+
return [
|
|
10
|
+
`${bold("🤖 Locus Telegram Bot")}`,
|
|
11
|
+
"",
|
|
12
|
+
"Control your Locus agent directly from Telegram.",
|
|
13
|
+
"",
|
|
14
|
+
`${bold("Locus Commands")}`,
|
|
15
|
+
"/run [issue#...] — Execute issues",
|
|
16
|
+
"/status — Dashboard view",
|
|
17
|
+
"/issues [filters] — List issues",
|
|
18
|
+
"/issue <#> — Show issue details",
|
|
19
|
+
"/sprint [sub] — Sprint management",
|
|
20
|
+
"/plan [args] — AI planning",
|
|
21
|
+
"/review <pr#> — Code review",
|
|
22
|
+
"/iterate <pr#> — Re-execute with feedback",
|
|
23
|
+
"/discuss [topic] — AI discussion",
|
|
24
|
+
"/exec [prompt] — REPL / one-shot",
|
|
25
|
+
"/logs — View logs",
|
|
26
|
+
"/config [path] — View config",
|
|
27
|
+
"/artifacts — View artifacts",
|
|
28
|
+
"",
|
|
29
|
+
`${bold("Git Commands")}`,
|
|
30
|
+
"/gitstatus — Git status",
|
|
31
|
+
"/stage [files|.] — Stage files",
|
|
32
|
+
"/commit <message> — Commit changes",
|
|
33
|
+
"/stash [pop|list|drop] — Stash operations",
|
|
34
|
+
"/branch [name] — List/create branches",
|
|
35
|
+
"/checkout <branch> — Switch branch",
|
|
36
|
+
"/diff — Show diff",
|
|
37
|
+
"/pr <title> — Create pull request",
|
|
38
|
+
"",
|
|
39
|
+
`${bold("Service Commands")}`,
|
|
40
|
+
"/service start|stop|restart|status|logs",
|
|
41
|
+
"",
|
|
42
|
+
"/help — Show this message",
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
// ─── Plan Messages ──────────────────────────────────────────────────────────
|
|
46
|
+
export function planCreatedMessage(planOutput) {
|
|
47
|
+
return [
|
|
48
|
+
`📋 ${bold("Plan Created")}`,
|
|
49
|
+
"",
|
|
50
|
+
codeBlock(planOutput),
|
|
51
|
+
"",
|
|
52
|
+
italic("Use the buttons below to approve or reject the plan."),
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
export function planApprovedMessage() {
|
|
56
|
+
return `✅ ${bold("Plan Approved")} — Proceeding with execution.`;
|
|
57
|
+
}
|
|
58
|
+
export function planRejectedMessage() {
|
|
59
|
+
return `❌ ${bold("Plan Rejected")} — No changes will be made.`;
|
|
60
|
+
}
|
|
61
|
+
// ─── Run Messages ───────────────────────────────────────────────────────────
|
|
62
|
+
export function runStartedMessage(target) {
|
|
63
|
+
return `🚀 ${bold("Run Started")} — ${escapeHtml(target)}`;
|
|
64
|
+
}
|
|
65
|
+
export function runCompletedMessage(target, prNumber) {
|
|
66
|
+
const lines = [`✅ ${bold("Run Completed")} — ${escapeHtml(target)}`];
|
|
67
|
+
if (prNumber) {
|
|
68
|
+
lines.push("", `Pull Request: #${prNumber}`);
|
|
69
|
+
}
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
export function runFailedMessage(target, error) {
|
|
73
|
+
return [
|
|
74
|
+
`❌ ${bold("Run Failed")} — ${escapeHtml(target)}`,
|
|
75
|
+
"",
|
|
76
|
+
codeBlock(error),
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
// ─── Review Messages ────────────────────────────────────────────────────────
|
|
80
|
+
export function reviewStartedMessage(prNumber) {
|
|
81
|
+
return `🔍 ${bold("Reviewing")} PR #${prNumber}...`;
|
|
82
|
+
}
|
|
83
|
+
export function reviewCompletedMessage(prNumber, output) {
|
|
84
|
+
return [
|
|
85
|
+
`✅ ${bold("Review Complete")} — PR #${prNumber}`,
|
|
86
|
+
"",
|
|
87
|
+
codeBlock(output),
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
// ─── Git Messages ───────────────────────────────────────────────────────────
|
|
91
|
+
export function gitCommitMessage(message, hash) {
|
|
92
|
+
return [
|
|
93
|
+
`✅ ${bold("Committed")}`,
|
|
94
|
+
"",
|
|
95
|
+
`Message: ${escapeHtml(message)}`,
|
|
96
|
+
`Hash: ${escapeHtml(hash)}`,
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
export function gitBranchCreatedMessage(name) {
|
|
100
|
+
return `✅ Branch ${bold(escapeHtml(name))} created.`;
|
|
101
|
+
}
|
|
102
|
+
export function gitCheckoutMessage(branch) {
|
|
103
|
+
return `✅ Switched to branch ${bold(escapeHtml(branch))}.`;
|
|
104
|
+
}
|
|
105
|
+
export function gitStashMessage(action) {
|
|
106
|
+
return `✅ ${bold("Stash")} — ${escapeHtml(action)} completed.`;
|
|
107
|
+
}
|
|
108
|
+
export function prCreatedMessage(prNumber, url) {
|
|
109
|
+
return [
|
|
110
|
+
`✅ ${bold("Pull Request Created")}`,
|
|
111
|
+
"",
|
|
112
|
+
`PR #${prNumber}: ${escapeHtml(url)}`,
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
// ─── Service Messages ───────────────────────────────────────────────────────
|
|
116
|
+
export function serviceStatusMessage(status, pid, uptime, memory, restarts) {
|
|
117
|
+
const uptimeStr = uptime ? formatUptime(uptime) : "N/A";
|
|
118
|
+
const memoryStr = memory ? formatMemory(memory) : "N/A";
|
|
119
|
+
return [
|
|
120
|
+
`🤖 ${bold("Locus Telegram Bot")}`,
|
|
121
|
+
"",
|
|
122
|
+
`Status: ${bold(status)}`,
|
|
123
|
+
`PID: ${pid ?? "N/A"}`,
|
|
124
|
+
`Uptime: ${uptimeStr}`,
|
|
125
|
+
`Memory: ${memoryStr}`,
|
|
126
|
+
`Restarts: ${restarts}`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
export function serviceNotRunningMessage() {
|
|
130
|
+
return [
|
|
131
|
+
`⚠️ ${bold("Bot Not Running")}`,
|
|
132
|
+
"",
|
|
133
|
+
"Start the bot with:",
|
|
134
|
+
`${bold("locus pkg telegram start")}`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
138
|
+
function formatUptime(startTimeMs) {
|
|
139
|
+
const diff = Date.now() - startTimeMs;
|
|
140
|
+
const seconds = Math.floor(diff / 1000);
|
|
141
|
+
const minutes = Math.floor(seconds / 60);
|
|
142
|
+
const hours = Math.floor(minutes / 60);
|
|
143
|
+
const days = Math.floor(hours / 24);
|
|
144
|
+
if (days > 0)
|
|
145
|
+
return `${days}d ${hours % 24}h`;
|
|
146
|
+
if (hours > 0)
|
|
147
|
+
return `${hours}h ${minutes % 60}m`;
|
|
148
|
+
if (minutes > 0)
|
|
149
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
150
|
+
return `${seconds}s`;
|
|
151
|
+
}
|
|
152
|
+
function formatMemory(bytes) {
|
|
153
|
+
const mb = bytes / (1024 * 1024);
|
|
154
|
+
return `${mb.toFixed(1)} MB`;
|
|
155
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@locusai/locus-telegram",
|
|
3
|
+
"version": "0.21.6",
|
|
4
|
+
"description": "Remote-control Locus via Telegram with full CLI mapping, git operations, and PM2 management",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"locus-telegram": "./bin/locus-telegram.js"
|
|
8
|
+
},
|
|
9
|
+
"locus": {
|
|
10
|
+
"displayName": "Telegram",
|
|
11
|
+
"description": "Remote-control your Locus agent from Telegram",
|
|
12
|
+
"commands": [
|
|
13
|
+
"telegram"
|
|
14
|
+
],
|
|
15
|
+
"version": "0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"lint": "biome lint .",
|
|
21
|
+
"format": "biome format --write ."
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@locusai/sdk": "0.21.6",
|
|
25
|
+
"grammy": "^1.35.0",
|
|
26
|
+
"pm2": "^6.0.5"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"typescript": "^5.8.3"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot instance — sets up grammy bot, registers middleware, commands,
|
|
3
|
+
* and callback query handlers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createLogger } from "@locusai/sdk";
|
|
7
|
+
import { Bot } from "grammy";
|
|
8
|
+
import {
|
|
9
|
+
handleBranch,
|
|
10
|
+
handleCheckout,
|
|
11
|
+
handleCommit,
|
|
12
|
+
handleDiff,
|
|
13
|
+
handleGitStatus,
|
|
14
|
+
handlePR,
|
|
15
|
+
handleStage,
|
|
16
|
+
handleStash,
|
|
17
|
+
} from "./commands/git.js";
|
|
18
|
+
import { handleLocusCommand } from "./commands/locus.js";
|
|
19
|
+
import { handleService } from "./commands/service.js";
|
|
20
|
+
import type { TelegramConfig } from "./config.js";
|
|
21
|
+
import { formatInfo, formatSuccess } from "./ui/format.js";
|
|
22
|
+
import { CB } from "./ui/keyboards.js";
|
|
23
|
+
import { welcomeMessage } from "./ui/messages.js";
|
|
24
|
+
|
|
25
|
+
const logger = createLogger("telegram");
|
|
26
|
+
|
|
27
|
+
// ─── Bot Factory ────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function createBot(config: TelegramConfig): Bot {
|
|
30
|
+
const bot = new Bot(config.botToken);
|
|
31
|
+
|
|
32
|
+
// ── Auth Middleware ─────────────────────────────────────────────────────
|
|
33
|
+
bot.use(async (ctx, next) => {
|
|
34
|
+
const chatId = ctx.chat?.id;
|
|
35
|
+
if (!chatId || !config.allowedChatIds.includes(chatId)) {
|
|
36
|
+
logger.warn("Unauthorized access attempt", {
|
|
37
|
+
chatId,
|
|
38
|
+
from: ctx.from?.username,
|
|
39
|
+
});
|
|
40
|
+
return; // silently ignore
|
|
41
|
+
}
|
|
42
|
+
await next();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── Help / Start ────────────────────────────────────────────────────────
|
|
46
|
+
bot.command("start", async (ctx) => {
|
|
47
|
+
await ctx.reply(welcomeMessage(), { parse_mode: "HTML" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
bot.command("help", async (ctx) => {
|
|
51
|
+
await ctx.reply(welcomeMessage(), { parse_mode: "HTML" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── Locus CLI Commands ──────────────────────────────────────────────────
|
|
55
|
+
const locusCommands = [
|
|
56
|
+
"run",
|
|
57
|
+
"status",
|
|
58
|
+
"issues",
|
|
59
|
+
"issue",
|
|
60
|
+
"sprint",
|
|
61
|
+
"plan",
|
|
62
|
+
"review",
|
|
63
|
+
"iterate",
|
|
64
|
+
"discuss",
|
|
65
|
+
"exec",
|
|
66
|
+
"logs",
|
|
67
|
+
"config",
|
|
68
|
+
"artifacts",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const cmd of locusCommands) {
|
|
72
|
+
bot.command(cmd, async (ctx) => {
|
|
73
|
+
const args = parseArgs(ctx.message?.text ?? "", cmd);
|
|
74
|
+
await handleLocusCommand(ctx, cmd, args);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Git Commands ────────────────────────────────────────────────────────
|
|
79
|
+
bot.command("gitstatus", async (ctx) => {
|
|
80
|
+
await handleGitStatus(ctx);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
bot.command("stage", async (ctx) => {
|
|
84
|
+
const args = parseArgs(ctx.message?.text ?? "", "stage");
|
|
85
|
+
await handleStage(ctx, args);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
bot.command("commit", async (ctx) => {
|
|
89
|
+
const args = parseArgs(ctx.message?.text ?? "", "commit");
|
|
90
|
+
await handleCommit(ctx, args);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
bot.command("stash", async (ctx) => {
|
|
94
|
+
const args = parseArgs(ctx.message?.text ?? "", "stash");
|
|
95
|
+
await handleStash(ctx, args);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
bot.command("branch", async (ctx) => {
|
|
99
|
+
const args = parseArgs(ctx.message?.text ?? "", "branch");
|
|
100
|
+
await handleBranch(ctx, args);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
bot.command("checkout", async (ctx) => {
|
|
104
|
+
const args = parseArgs(ctx.message?.text ?? "", "checkout");
|
|
105
|
+
await handleCheckout(ctx, args);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
bot.command("diff", async (ctx) => {
|
|
109
|
+
await handleDiff(ctx);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
bot.command("pr", async (ctx) => {
|
|
113
|
+
const args = parseArgs(ctx.message?.text ?? "", "pr");
|
|
114
|
+
await handlePR(ctx, args);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Service Commands ────────────────────────────────────────────────────
|
|
118
|
+
bot.command("service", async (ctx) => {
|
|
119
|
+
const args = parseArgs(ctx.message?.text ?? "", "service");
|
|
120
|
+
await handleService(ctx, args);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Callback Query Handlers ─────────────────────────────────────────────
|
|
124
|
+
registerCallbackHandlers(bot);
|
|
125
|
+
|
|
126
|
+
// ── Fallback ────────────────────────────────────────────────────────────
|
|
127
|
+
bot.on("message:text", async (ctx) => {
|
|
128
|
+
// Treat non-command text as a locus exec prompt
|
|
129
|
+
const text = ctx.message.text;
|
|
130
|
+
if (text.startsWith("/")) return; // unknown command — ignore
|
|
131
|
+
|
|
132
|
+
await handleLocusCommand(ctx, "exec", [text]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return bot;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Callback Query Handlers ────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function registerCallbackHandlers(bot: Bot): void {
|
|
141
|
+
// Plan callbacks
|
|
142
|
+
bot.callbackQuery(CB.APPROVE_PLAN, async (ctx) => {
|
|
143
|
+
await ctx.answerCallbackQuery({ text: "Plan approved!" });
|
|
144
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
145
|
+
await ctx.reply(formatSuccess("Plan approved. Starting execution..."), {
|
|
146
|
+
parse_mode: "HTML",
|
|
147
|
+
});
|
|
148
|
+
// Trigger the run
|
|
149
|
+
await handleLocusCommand(ctx, "run", []);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
bot.callbackQuery(CB.REJECT_PLAN, async (ctx) => {
|
|
153
|
+
await ctx.answerCallbackQuery({ text: "Plan rejected." });
|
|
154
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
155
|
+
await ctx.reply(formatInfo("Plan rejected. No changes will be made."), {
|
|
156
|
+
parse_mode: "HTML",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
bot.callbackQuery(CB.SHOW_PLAN_DETAILS, async (ctx) => {
|
|
161
|
+
await ctx.answerCallbackQuery();
|
|
162
|
+
await handleLocusCommand(ctx, "plan", ["--show"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Run callbacks
|
|
166
|
+
bot.callbackQuery(CB.VIEW_LOGS, async (ctx) => {
|
|
167
|
+
await ctx.answerCallbackQuery();
|
|
168
|
+
await handleLocusCommand(ctx, "logs", ["--lines", "30"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
bot.callbackQuery(/^run:again:(\d+)$/, async (ctx) => {
|
|
172
|
+
const issue = ctx.match[1];
|
|
173
|
+
await ctx.answerCallbackQuery({ text: `Re-running issue #${issue}` });
|
|
174
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
175
|
+
await handleLocusCommand(ctx, "run", [issue]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Review callbacks
|
|
179
|
+
bot.callbackQuery(/^review:approve:(\d+)$/, async (ctx) => {
|
|
180
|
+
const pr = ctx.match[1];
|
|
181
|
+
await ctx.answerCallbackQuery({ text: "Approving PR..." });
|
|
182
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
183
|
+
await ctx.reply(formatSuccess(`Approved PR #${pr}`), {
|
|
184
|
+
parse_mode: "HTML",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
bot.callbackQuery(/^review:changes:(\d+)$/, async (ctx) => {
|
|
189
|
+
const pr = ctx.match[1];
|
|
190
|
+
await ctx.answerCallbackQuery();
|
|
191
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
192
|
+
await ctx.reply(
|
|
193
|
+
formatInfo(
|
|
194
|
+
`Requesting changes on PR #${pr}. Reply with your feedback and I'll pass it along via /iterate ${pr}`
|
|
195
|
+
),
|
|
196
|
+
{ parse_mode: "HTML" }
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
bot.callbackQuery(/^review:diff:(\d+)$/, async (ctx) => {
|
|
201
|
+
const pr = ctx.match[1];
|
|
202
|
+
await ctx.answerCallbackQuery();
|
|
203
|
+
await handleLocusCommand(ctx, "review", [pr, "--diff"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Status callbacks
|
|
207
|
+
bot.callbackQuery(CB.RUN_SPRINT, async (ctx) => {
|
|
208
|
+
await ctx.answerCallbackQuery({ text: "Starting sprint run..." });
|
|
209
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
210
|
+
await handleLocusCommand(ctx, "run", []);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
bot.callbackQuery(CB.VIEW_ISSUES, async (ctx) => {
|
|
214
|
+
await ctx.answerCallbackQuery();
|
|
215
|
+
await handleLocusCommand(ctx, "issues", []);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Stash callbacks
|
|
219
|
+
bot.callbackQuery(CB.STASH_POP, async (ctx) => {
|
|
220
|
+
await ctx.answerCallbackQuery();
|
|
221
|
+
await handleStash(ctx, ["pop"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
bot.callbackQuery(CB.STASH_LIST, async (ctx) => {
|
|
225
|
+
await ctx.answerCallbackQuery();
|
|
226
|
+
await handleStash(ctx, ["list"]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
bot.callbackQuery(CB.STASH_DROP, async (ctx) => {
|
|
230
|
+
await ctx.answerCallbackQuery();
|
|
231
|
+
await handleStash(ctx, ["drop"]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Confirmation callbacks
|
|
235
|
+
bot.callbackQuery(CB.CONFIRM_ACTION, async (ctx) => {
|
|
236
|
+
await ctx.answerCallbackQuery({ text: "Confirmed!" });
|
|
237
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
bot.callbackQuery(CB.CANCEL_ACTION, async (ctx) => {
|
|
241
|
+
await ctx.answerCallbackQuery({ text: "Cancelled." });
|
|
242
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
243
|
+
await ctx.reply(formatInfo("Action cancelled."), { parse_mode: "HTML" });
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** Parse arguments from a Telegram command message. */
|
|
250
|
+
function parseArgs(text: string, command: string): string[] {
|
|
251
|
+
// Remove the /command part (handles @botname suffix too)
|
|
252
|
+
const prefixRegex = new RegExp(`^/${command}(@\\S+)?\\s*`);
|
|
253
|
+
const rest = text.replace(prefixRegex, "").trim();
|
|
254
|
+
if (!rest) return [];
|
|
255
|
+
return rest.split(/\s+/);
|
|
256
|
+
}
|