@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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Git command handlers — stage, commit, stash, branch, checkout, diff, PR.
3
+ *
4
+ * Uses direct git subprocess calls for fast, local operations.
5
+ * PR creation uses `gh` CLI (same as the locus CLI does).
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+ import type { Context } from "grammy";
10
+ import {
11
+ bold,
12
+ codeBlock,
13
+ escapeHtml,
14
+ formatError,
15
+ formatSuccess,
16
+ } from "../ui/format.js";
17
+ import { stashKeyboard } from "../ui/keyboards.js";
18
+ import {
19
+ gitBranchCreatedMessage,
20
+ gitCheckoutMessage,
21
+ gitCommitMessage,
22
+ gitStashMessage,
23
+ prCreatedMessage,
24
+ } from "../ui/messages.js";
25
+
26
+ // ─── Git Helper ─────────────────────────────────────────────────────────────
27
+
28
+ function git(args: string): string {
29
+ return execSync(`git ${args}`, {
30
+ encoding: "utf-8",
31
+ stdio: ["pipe", "pipe", "pipe"],
32
+ cwd: process.cwd(),
33
+ });
34
+ }
35
+
36
+ function gitSafe(args: string): string | null {
37
+ try {
38
+ return git(args);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ // ─── Command Handlers ───────────────────────────────────────────────────────
45
+
46
+ /** /gitstatus — show git status */
47
+ export async function handleGitStatus(ctx: Context): Promise<void> {
48
+ try {
49
+ const status = git("status --short");
50
+ if (!status.trim()) {
51
+ await ctx.reply(formatSuccess("Working tree is clean."), {
52
+ parse_mode: "HTML",
53
+ });
54
+ return;
55
+ }
56
+
57
+ const branch = git("branch --show-current").trim();
58
+ await ctx.reply(
59
+ `${bold("Branch:")} ${escapeHtml(branch)}\n\n${codeBlock(status)}`,
60
+ { parse_mode: "HTML" }
61
+ );
62
+ } catch (error: unknown) {
63
+ await ctx.reply(formatError("Failed to get git status", String(error)), {
64
+ parse_mode: "HTML",
65
+ });
66
+ }
67
+ }
68
+
69
+ /** /stage [files|.] — stage files for commit */
70
+ export async function handleStage(ctx: Context, args: string[]): Promise<void> {
71
+ const target = args.length > 0 ? args.join(" ") : ".";
72
+
73
+ try {
74
+ git(`add ${target}`);
75
+ const status = git("status --short");
76
+ await ctx.reply(
77
+ `${formatSuccess(`Staged: ${target}`)}\n\n${codeBlock(status)}`,
78
+ { parse_mode: "HTML" }
79
+ );
80
+ } catch (error: unknown) {
81
+ await ctx.reply(formatError("Failed to stage files", String(error)), {
82
+ parse_mode: "HTML",
83
+ });
84
+ }
85
+ }
86
+
87
+ /** /commit <message> — commit staged changes */
88
+ export async function handleCommit(
89
+ ctx: Context,
90
+ args: string[]
91
+ ): Promise<void> {
92
+ if (args.length === 0) {
93
+ await ctx.reply(formatError("Usage: /commit <message>"), {
94
+ parse_mode: "HTML",
95
+ });
96
+ return;
97
+ }
98
+
99
+ const message = args.join(" ");
100
+
101
+ try {
102
+ const result = git(`commit -m ${JSON.stringify(message)}`);
103
+ // Extract short hash from commit output
104
+ const hashMatch = result.match(/\[[\w/.-]+ ([a-f0-9]+)\]/);
105
+ const hash = hashMatch?.[1] ?? "unknown";
106
+
107
+ await ctx.reply(gitCommitMessage(message, hash), {
108
+ parse_mode: "HTML",
109
+ });
110
+ } catch (error: unknown) {
111
+ const errStr = String(error);
112
+ if (errStr.includes("nothing to commit")) {
113
+ await ctx.reply(
114
+ formatError("Nothing to commit. Stage changes first with /stage"),
115
+ {
116
+ parse_mode: "HTML",
117
+ }
118
+ );
119
+ } else {
120
+ await ctx.reply(formatError("Failed to commit", errStr), {
121
+ parse_mode: "HTML",
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ /** /stash [pop|list|drop|save] — stash operations */
128
+ export async function handleStash(ctx: Context, args: string[]): Promise<void> {
129
+ const subcommand = args[0] ?? "push";
130
+
131
+ try {
132
+ switch (subcommand) {
133
+ case "push":
134
+ case "save": {
135
+ const message = args.slice(1).join(" ");
136
+ const stashArgs = message
137
+ ? `stash push -m ${JSON.stringify(message)}`
138
+ : "stash push";
139
+ git(stashArgs);
140
+ await ctx.reply(gitStashMessage("Changes stashed"), {
141
+ parse_mode: "HTML",
142
+ reply_markup: stashKeyboard(),
143
+ });
144
+ break;
145
+ }
146
+ case "pop": {
147
+ git("stash pop");
148
+ await ctx.reply(gitStashMessage("Stash popped"), {
149
+ parse_mode: "HTML",
150
+ });
151
+ break;
152
+ }
153
+ case "list": {
154
+ const list = gitSafe("stash list") ?? "";
155
+ if (!list.trim()) {
156
+ await ctx.reply(formatSuccess("No stashes."), {
157
+ parse_mode: "HTML",
158
+ });
159
+ } else {
160
+ await ctx.reply(codeBlock(list), { parse_mode: "HTML" });
161
+ }
162
+ break;
163
+ }
164
+ case "drop": {
165
+ const stashRef = args[1] ?? "stash@{0}";
166
+ git(`stash drop ${stashRef}`);
167
+ await ctx.reply(gitStashMessage(`Dropped ${stashRef}`), {
168
+ parse_mode: "HTML",
169
+ });
170
+ break;
171
+ }
172
+ default: {
173
+ // Default: just stash
174
+ git("stash push");
175
+ await ctx.reply(gitStashMessage("Changes stashed"), {
176
+ parse_mode: "HTML",
177
+ reply_markup: stashKeyboard(),
178
+ });
179
+ }
180
+ }
181
+ } catch (error: unknown) {
182
+ await ctx.reply(formatError("Stash operation failed", String(error)), {
183
+ parse_mode: "HTML",
184
+ });
185
+ }
186
+ }
187
+
188
+ /** /branch [name] — list branches or create a new one */
189
+ export async function handleBranch(
190
+ ctx: Context,
191
+ args: string[]
192
+ ): Promise<void> {
193
+ try {
194
+ if (args.length === 0) {
195
+ // List branches
196
+ const branches = git("branch -a --format='%(refname:short)'");
197
+ const current = git("branch --show-current").trim();
198
+ await ctx.reply(
199
+ `${bold("Current:")} ${escapeHtml(current)}\n\n${codeBlock(branches)}`,
200
+ { parse_mode: "HTML" }
201
+ );
202
+ } else {
203
+ // Create new branch
204
+ const branchName = args[0];
205
+ git(`branch ${branchName}`);
206
+ await ctx.reply(gitBranchCreatedMessage(branchName), {
207
+ parse_mode: "HTML",
208
+ });
209
+ }
210
+ } catch (error: unknown) {
211
+ await ctx.reply(formatError("Branch operation failed", String(error)), {
212
+ parse_mode: "HTML",
213
+ });
214
+ }
215
+ }
216
+
217
+ /** /checkout <branch> — switch to a branch */
218
+ export async function handleCheckout(
219
+ ctx: Context,
220
+ args: string[]
221
+ ): Promise<void> {
222
+ if (args.length === 0) {
223
+ await ctx.reply(formatError("Usage: /checkout <branch>"), {
224
+ parse_mode: "HTML",
225
+ });
226
+ return;
227
+ }
228
+
229
+ const branch = args[0];
230
+
231
+ try {
232
+ git(`checkout ${branch}`);
233
+ await ctx.reply(gitCheckoutMessage(branch), { parse_mode: "HTML" });
234
+ } catch (error: unknown) {
235
+ await ctx.reply(formatError("Checkout failed", String(error)), {
236
+ parse_mode: "HTML",
237
+ });
238
+ }
239
+ }
240
+
241
+ /** /diff — show git diff summary */
242
+ export async function handleDiff(ctx: Context): Promise<void> {
243
+ try {
244
+ const diff = git("diff --stat");
245
+ if (!diff.trim()) {
246
+ const staged = git("diff --cached --stat");
247
+ if (!staged.trim()) {
248
+ await ctx.reply(formatSuccess("No changes."), {
249
+ parse_mode: "HTML",
250
+ });
251
+ } else {
252
+ await ctx.reply(`${bold("Staged changes:")}\n\n${codeBlock(staged)}`, {
253
+ parse_mode: "HTML",
254
+ });
255
+ }
256
+ } else {
257
+ await ctx.reply(`${bold("Unstaged changes:")}\n\n${codeBlock(diff)}`, {
258
+ parse_mode: "HTML",
259
+ });
260
+ }
261
+ } catch (error: unknown) {
262
+ await ctx.reply(formatError("Diff failed", String(error)), {
263
+ parse_mode: "HTML",
264
+ });
265
+ }
266
+ }
267
+
268
+ /** /pr <title> — create a pull request */
269
+ export async function handlePR(ctx: Context, args: string[]): Promise<void> {
270
+ if (args.length === 0) {
271
+ await ctx.reply(formatError("Usage: /pr <title>"), {
272
+ parse_mode: "HTML",
273
+ });
274
+ return;
275
+ }
276
+
277
+ const title = args.join(" ");
278
+
279
+ try {
280
+ // Push current branch first
281
+ const branch = git("branch --show-current").trim();
282
+ try {
283
+ git(`push -u origin ${branch}`);
284
+ } catch {
285
+ // May already be pushed — continue
286
+ }
287
+
288
+ // Create PR using gh CLI
289
+ const result = execSync(
290
+ `gh pr create --title ${JSON.stringify(title)} --body "Created via Locus Telegram Bot" --head ${branch}`,
291
+ {
292
+ encoding: "utf-8",
293
+ stdio: ["pipe", "pipe", "pipe"],
294
+ cwd: process.cwd(),
295
+ }
296
+ );
297
+
298
+ const prMatch = result.match(/\/pull\/(\d+)/);
299
+ const prNumber = prMatch ? Number(prMatch[1]) : 0;
300
+
301
+ await ctx.reply(prCreatedMessage(prNumber, result.trim()), {
302
+ parse_mode: "HTML",
303
+ });
304
+ } catch (error: unknown) {
305
+ await ctx.reply(formatError("Failed to create PR", String(error)), {
306
+ parse_mode: "HTML",
307
+ });
308
+ }
309
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Locus CLI command passthrough — maps every Telegram command to
3
+ * the corresponding `locus` CLI invocation via `@locusai/sdk`.
4
+ *
5
+ * Long-running commands stream output by editing the Telegram message
6
+ * in place at regular intervals.
7
+ */
8
+
9
+ import { invokeLocusStream } from "@locusai/sdk";
10
+ import type { Context } from "grammy";
11
+ import { formatCommandResult, formatStreamingMessage } from "../ui/format.js";
12
+ import {
13
+ planKeyboard,
14
+ reviewKeyboard,
15
+ runCompleteKeyboard,
16
+ statusKeyboard,
17
+ } from "../ui/keyboards.js";
18
+ import { reviewStartedMessage, runStartedMessage } from "../ui/messages.js";
19
+
20
+ // ─── Command Map ────────────────────────────────────────────────────────────
21
+
22
+ /** Maps Telegram command names to locus CLI arguments. */
23
+ const COMMAND_MAP: Record<string, string[]> = {
24
+ run: ["run"],
25
+ status: ["status"],
26
+ issues: ["issue", "list"],
27
+ issue: ["issue", "show"],
28
+ sprint: ["sprint"],
29
+ plan: ["plan"],
30
+ review: ["review"],
31
+ iterate: ["iterate"],
32
+ discuss: ["discuss"],
33
+ exec: ["exec"],
34
+ logs: ["logs"],
35
+ config: ["config"],
36
+ artifacts: ["artifacts"],
37
+ };
38
+
39
+ /** Commands that produce long-running streaming output. */
40
+ const STREAMING_COMMANDS = new Set([
41
+ "run",
42
+ "plan",
43
+ "review",
44
+ "iterate",
45
+ "discuss",
46
+ "exec",
47
+ ]);
48
+
49
+ /** Minimum interval between message edits (ms) to avoid Telegram rate limits. */
50
+ const EDIT_INTERVAL = 2000;
51
+
52
+ // ─── Handlers ───────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Execute a locus CLI command, streaming output to the Telegram chat.
56
+ */
57
+ export async function handleLocusCommand(
58
+ ctx: Context,
59
+ command: string,
60
+ args: string[]
61
+ ): Promise<void> {
62
+ const cliArgs = COMMAND_MAP[command];
63
+ if (!cliArgs) {
64
+ await ctx.reply(`Unknown command: /${command}`);
65
+ return;
66
+ }
67
+
68
+ const fullArgs = [...cliArgs, ...args];
69
+ const displayCmd = `locus ${fullArgs.join(" ")}`;
70
+ const isStreaming = STREAMING_COMMANDS.has(command);
71
+
72
+ // Send pre-messages for specific commands
73
+ if (command === "run" && args.length > 0) {
74
+ await ctx.reply(runStartedMessage(args.join(" ")), { parse_mode: "HTML" });
75
+ } else if (command === "review" && args.length > 0) {
76
+ await ctx.reply(reviewStartedMessage(Number(args[0])), {
77
+ parse_mode: "HTML",
78
+ });
79
+ }
80
+
81
+ if (isStreaming) {
82
+ await handleStreamingCommand(ctx, displayCmd, fullArgs, command, args);
83
+ } else {
84
+ await handleBufferedCommand(ctx, displayCmd, fullArgs, command);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Handle a streaming command — edit the message in place as output arrives.
90
+ */
91
+ async function handleStreamingCommand(
92
+ ctx: Context,
93
+ displayCmd: string,
94
+ fullArgs: string[],
95
+ command: string,
96
+ args: string[]
97
+ ): Promise<void> {
98
+ const child = invokeLocusStream(fullArgs);
99
+
100
+ let output = "";
101
+ let lastEditTime = 0;
102
+ let editTimer: ReturnType<typeof setTimeout> | null = null;
103
+
104
+ // Send initial "running" message
105
+ const msg = await ctx.reply(formatStreamingMessage(displayCmd, "", false), {
106
+ parse_mode: "HTML",
107
+ });
108
+
109
+ const editMessage = async () => {
110
+ const now = Date.now();
111
+ if (now - lastEditTime < EDIT_INTERVAL) return;
112
+ lastEditTime = now;
113
+
114
+ try {
115
+ await ctx.api.editMessageText(
116
+ msg.chat.id,
117
+ msg.message_id,
118
+ formatStreamingMessage(displayCmd, output, false),
119
+ { parse_mode: "HTML" }
120
+ );
121
+ } catch {
122
+ // Edit can fail if content hasn't changed — ignore
123
+ }
124
+ };
125
+
126
+ child.stdout?.on("data", (chunk: Buffer) => {
127
+ output += chunk.toString();
128
+
129
+ // Debounce edits
130
+ if (editTimer) clearTimeout(editTimer);
131
+ editTimer = setTimeout(editMessage, EDIT_INTERVAL);
132
+ });
133
+
134
+ child.stderr?.on("data", (chunk: Buffer) => {
135
+ output += chunk.toString();
136
+ });
137
+
138
+ await new Promise<void>((resolve) => {
139
+ child.on("close", async (exitCode) => {
140
+ if (editTimer) clearTimeout(editTimer);
141
+
142
+ // Final edit with complete output
143
+ try {
144
+ await ctx.api.editMessageText(
145
+ msg.chat.id,
146
+ msg.message_id,
147
+ formatStreamingMessage(displayCmd, output, true),
148
+ { parse_mode: "HTML" }
149
+ );
150
+ } catch {
151
+ // ignore edit failures
152
+ }
153
+
154
+ // Send keyboard for post-command actions
155
+ const keyboard = getPostCommandKeyboard(command, args, exitCode ?? 0);
156
+ if (keyboard) {
157
+ await ctx.reply(
158
+ exitCode === 0
159
+ ? "What would you like to do next?"
160
+ : "Command failed.",
161
+ { reply_markup: keyboard }
162
+ );
163
+ }
164
+
165
+ resolve();
166
+ });
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Handle a buffered command — collect all output, then send once.
172
+ */
173
+ async function handleBufferedCommand(
174
+ ctx: Context,
175
+ displayCmd: string,
176
+ fullArgs: string[],
177
+ command: string
178
+ ): Promise<void> {
179
+ const child = invokeLocusStream(fullArgs);
180
+ let output = "";
181
+
182
+ child.stdout?.on("data", (chunk: Buffer) => {
183
+ output += chunk.toString();
184
+ });
185
+
186
+ child.stderr?.on("data", (chunk: Buffer) => {
187
+ output += chunk.toString();
188
+ });
189
+
190
+ await new Promise<void>((resolve) => {
191
+ child.on("close", async (exitCode) => {
192
+ const result = formatCommandResult(displayCmd, output, exitCode ?? 0);
193
+
194
+ const keyboard = getPostCommandKeyboard(command, [], exitCode ?? 0);
195
+ await ctx.reply(result, {
196
+ parse_mode: "HTML",
197
+ reply_markup: keyboard ?? undefined,
198
+ });
199
+
200
+ resolve();
201
+ });
202
+ });
203
+ }
204
+
205
+ // ─── Post-Command Keyboards ────────────────────────────────────────────────
206
+
207
+ function getPostCommandKeyboard(
208
+ command: string,
209
+ args: string[],
210
+ exitCode: number
211
+ ) {
212
+ if (exitCode !== 0) return null;
213
+
214
+ switch (command) {
215
+ case "plan":
216
+ return planKeyboard();
217
+ case "review":
218
+ if (args.length > 0) {
219
+ return reviewKeyboard(Number(args[0]));
220
+ }
221
+ return null;
222
+ case "run":
223
+ if (args.length > 0) {
224
+ return runCompleteKeyboard(Number(args[0]));
225
+ }
226
+ return runCompleteKeyboard();
227
+ case "status":
228
+ return statusKeyboard();
229
+ default:
230
+ return null;
231
+ }
232
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Service management commands — PM2 lifecycle control via Telegram.
3
+ */
4
+
5
+ import type { Context } from "grammy";
6
+ import {
7
+ pm2Delete,
8
+ pm2Logs,
9
+ pm2Restart,
10
+ pm2Start,
11
+ pm2Status,
12
+ pm2Stop,
13
+ } from "../pm2.js";
14
+ import { codeBlock, formatError, formatSuccess } from "../ui/format.js";
15
+ import {
16
+ serviceNotRunningMessage,
17
+ serviceStatusMessage,
18
+ } from "../ui/messages.js";
19
+
20
+ /** /service <subcommand> — manage the bot process */
21
+ export async function handleService(
22
+ ctx: Context,
23
+ args: string[]
24
+ ): Promise<void> {
25
+ const subcommand = args[0] ?? "status";
26
+
27
+ try {
28
+ switch (subcommand) {
29
+ case "start": {
30
+ const result = pm2Start();
31
+ await ctx.reply(formatSuccess(result), { parse_mode: "HTML" });
32
+ break;
33
+ }
34
+ case "stop": {
35
+ const result = pm2Stop();
36
+ await ctx.reply(formatSuccess(result), { parse_mode: "HTML" });
37
+ break;
38
+ }
39
+ case "restart": {
40
+ const result = pm2Restart();
41
+ await ctx.reply(formatSuccess(result), { parse_mode: "HTML" });
42
+ break;
43
+ }
44
+ case "delete": {
45
+ const result = pm2Delete();
46
+ await ctx.reply(formatSuccess(result), { parse_mode: "HTML" });
47
+ break;
48
+ }
49
+ case "status": {
50
+ const status = pm2Status();
51
+ if (!status) {
52
+ await ctx.reply(serviceNotRunningMessage(), {
53
+ parse_mode: "HTML",
54
+ });
55
+ } else {
56
+ await ctx.reply(
57
+ serviceStatusMessage(
58
+ status.status,
59
+ status.pid,
60
+ status.uptime,
61
+ status.memory,
62
+ status.restarts
63
+ ),
64
+ { parse_mode: "HTML" }
65
+ );
66
+ }
67
+ break;
68
+ }
69
+ case "logs": {
70
+ const lines = args[1] ? Number(args[1]) : 50;
71
+ const logs = pm2Logs(lines);
72
+ await ctx.reply(codeBlock(logs), { parse_mode: "HTML" });
73
+ break;
74
+ }
75
+ default: {
76
+ await ctx.reply(
77
+ formatError(
78
+ "Unknown service command. Use: start, stop, restart, delete, status, logs"
79
+ ),
80
+ { parse_mode: "HTML" }
81
+ );
82
+ }
83
+ }
84
+ } catch (error: unknown) {
85
+ await ctx.reply(formatError("Service command failed", String(error)), {
86
+ parse_mode: "HTML",
87
+ });
88
+ }
89
+ }
package/src/config.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Configuration for the Telegram bot, read from the Locus config system.
3
+ *
4
+ * Users configure via:
5
+ * locus config packages.telegram.botToken "123456:ABC..."
6
+ * locus config packages.telegram.chatIds "12345678,87654321"
7
+ */
8
+
9
+ import { readLocusConfig } from "@locusai/sdk";
10
+
11
+ export interface TelegramConfig {
12
+ botToken: string;
13
+ allowedChatIds: number[];
14
+ }
15
+
16
+ export function loadTelegramConfig(): TelegramConfig {
17
+ const locusConfig = readLocusConfig();
18
+ const pkg = locusConfig.packages?.telegram;
19
+
20
+ const botToken = pkg?.botToken;
21
+ if (!botToken || typeof botToken !== "string") {
22
+ throw new Error(
23
+ 'Telegram bot token not configured. Run:\n locus config packages.telegram.botToken "<your-token>"\n\nGet a token from @BotFather on Telegram.'
24
+ );
25
+ }
26
+
27
+ const chatIdsRaw = pkg?.chatIds;
28
+ if (!chatIdsRaw) {
29
+ throw new Error(
30
+ 'Telegram chat IDs not configured. Run:\n locus config packages.telegram.chatIds "12345678"\n\nSend /start to your bot, then use the chat ID from the Telegram API.'
31
+ );
32
+ }
33
+
34
+ const allowedChatIds = parseChatIds(chatIdsRaw);
35
+
36
+ if (allowedChatIds.length === 0) {
37
+ throw new Error(
38
+ "packages.telegram.chatIds must contain at least one chat ID."
39
+ );
40
+ }
41
+
42
+ return { botToken, allowedChatIds };
43
+ }
44
+
45
+ function parseChatIds(raw: unknown): number[] {
46
+ // Support both array of numbers and comma-separated string
47
+ if (Array.isArray(raw)) {
48
+ return raw.map((id) => {
49
+ const parsed = Number(id);
50
+ if (Number.isNaN(parsed)) {
51
+ throw new Error(`Invalid chat ID: "${id}". Must be a number.`);
52
+ }
53
+ return parsed;
54
+ });
55
+ }
56
+
57
+ if (typeof raw === "string") {
58
+ return raw
59
+ .split(",")
60
+ .map((id) => id.trim())
61
+ .filter((id) => id.length > 0)
62
+ .map((id) => {
63
+ const parsed = Number.parseInt(id, 10);
64
+ if (Number.isNaN(parsed)) {
65
+ throw new Error(`Invalid chat ID: "${id}". Must be a number.`);
66
+ }
67
+ return parsed;
68
+ });
69
+ }
70
+
71
+ if (typeof raw === "number") {
72
+ return [raw];
73
+ }
74
+
75
+ throw new Error(
76
+ "Invalid chatIds format. Expected a number, array of numbers, or comma-separated string."
77
+ );
78
+ }