grok-telegram-bot 2.5.0 → 2.7.0

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.
Files changed (50) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +20 -5
  4. package/docs/GROUP.md +39 -4
  5. package/docs/INSTALL.md +2 -0
  6. package/package.json +4 -4
  7. package/scripts/setup.mjs +20 -3
  8. package/src/app/instance.ts +223 -0
  9. package/src/app/types.ts +34 -1
  10. package/src/bot/ask-user-service.ts +226 -0
  11. package/src/bot/auth.ts +5 -1
  12. package/src/bot/bot.ts +105 -4
  13. package/src/bot/chat-controller.ts +129 -0
  14. package/src/bot/commands.ts +22 -2
  15. package/src/bot/group-memory.ts +192 -12
  16. package/src/bot/handlers/forum.ts +16 -6
  17. package/src/bot/handlers/grok-slash.ts +336 -0
  18. package/src/bot/handlers/message.ts +165 -25
  19. package/src/bot/handlers/photo.ts +4 -1
  20. package/src/bot/handlers/system.ts +63 -1
  21. package/src/bot/image-return.ts +4 -1
  22. package/src/bot/manager-context.ts +208 -0
  23. package/src/bot/manager-jobs.ts +142 -0
  24. package/src/bot/menu/ephemeral.ts +4 -1
  25. package/src/bot/plan-exit-service.ts +169 -0
  26. package/src/bot/prompt-anchor.ts +2 -3
  27. package/src/bot/prompt-content.ts +5 -0
  28. package/src/bot/registry.ts +11 -2
  29. package/src/bot/scope.ts +9 -8
  30. package/src/bot/session-runtime.ts +665 -55
  31. package/src/bot/telegram-actions.ts +728 -38
  32. package/src/bot/telegram-bots.ts +2 -1
  33. package/src/bot/telegram-io.ts +4 -1
  34. package/src/cli.ts +43 -7
  35. package/src/config.ts +35 -25
  36. package/src/forum/manager.ts +2 -1
  37. package/src/forum/thread.ts +33 -0
  38. package/src/grok/client.ts +29 -5
  39. package/src/grok/plan-approval.ts +8 -0
  40. package/src/index.ts +4 -0
  41. package/src/render/manager-directive.ts +137 -0
  42. package/src/render/session-comment.ts +10 -0
  43. package/src/render/telegram-bridge.ts +118 -14
  44. package/src/service/linux.ts +21 -15
  45. package/src/service/macos.ts +20 -15
  46. package/src/service/platform.ts +12 -3
  47. package/src/service/types.ts +6 -0
  48. package/src/service/windows.ts +31 -22
  49. package/src/sessions/history.ts +18 -0
  50. package/src/stream/streamer.ts +46 -10
@@ -0,0 +1,142 @@
1
+ /**
2
+ * In-memory tracking of work dispatched from General (manager) into project topics.
3
+ * Used for status, context inject, and report-back when a child turn finishes.
4
+ */
5
+
6
+ export type ManagerJobStatus = "dispatched" | "running" | "done" | "failed" | "cancelled";
7
+
8
+ export interface ManagerJob {
9
+ id: string;
10
+ originChatId: number;
11
+ originThreadId: number;
12
+ targetThreadId: number;
13
+ targetName: string;
14
+ targetPath: string;
15
+ childSessionId?: string;
16
+ dispatchPrompt: string;
17
+ userAskPreview: string;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ status: ManagerJobStatus;
21
+ resultSummary?: string;
22
+ }
23
+
24
+ export interface ReportBackMeta {
25
+ jobId: string;
26
+ originChatId: number;
27
+ originThreadId: number;
28
+ userAskPreview: string;
29
+ targetName: string;
30
+ targetPath: string;
31
+ dispatchPrompt: string;
32
+ }
33
+
34
+ const MAX_JOBS = 100;
35
+
36
+ const jobsById = new Map<string, ManagerJob>();
37
+ const jobIdBySession = new Map<string, string>();
38
+
39
+ let seq = 0;
40
+
41
+ export function newManagerJobId(): string {
42
+ seq = (seq + 1) % 1_000_000;
43
+ return `mj_${Date.now().toString(36)}_${seq.toString(36)}`;
44
+ }
45
+
46
+ export function registerManagerJob(input: {
47
+ originChatId: number;
48
+ originThreadId: number;
49
+ targetThreadId: number;
50
+ targetName: string;
51
+ targetPath: string;
52
+ dispatchPrompt: string;
53
+ userAskPreview: string;
54
+ childSessionId?: string;
55
+ }): ManagerJob {
56
+ const now = Date.now();
57
+ const job: ManagerJob = {
58
+ id: newManagerJobId(),
59
+ originChatId: input.originChatId,
60
+ originThreadId: input.originThreadId,
61
+ targetThreadId: input.targetThreadId,
62
+ targetName: input.targetName,
63
+ targetPath: input.targetPath,
64
+ childSessionId: input.childSessionId,
65
+ dispatchPrompt: input.dispatchPrompt,
66
+ userAskPreview: input.userAskPreview,
67
+ createdAt: now,
68
+ updatedAt: now,
69
+ status: "dispatched",
70
+ };
71
+ jobsById.set(job.id, job);
72
+ if (job.childSessionId) jobIdBySession.set(job.childSessionId, job.id);
73
+ trimJobs();
74
+ return job;
75
+ }
76
+
77
+ export function bindJobSession(jobId: string, sessionId: string): void {
78
+ const job = jobsById.get(jobId);
79
+ if (!job) return;
80
+ job.childSessionId = sessionId;
81
+ job.updatedAt = Date.now();
82
+ if (job.status === "dispatched") job.status = "running";
83
+ jobIdBySession.set(sessionId, jobId);
84
+ }
85
+
86
+ export function updateManagerJob(
87
+ jobId: string,
88
+ patch: Partial<Pick<ManagerJob, "status" | "resultSummary" | "childSessionId">>,
89
+ ): ManagerJob | undefined {
90
+ const job = jobsById.get(jobId);
91
+ if (!job) return undefined;
92
+ if (patch.status !== undefined) job.status = patch.status;
93
+ if (patch.resultSummary !== undefined) job.resultSummary = patch.resultSummary;
94
+ if (patch.childSessionId !== undefined) {
95
+ job.childSessionId = patch.childSessionId;
96
+ jobIdBySession.set(patch.childSessionId, jobId);
97
+ }
98
+ job.updatedAt = Date.now();
99
+ return job;
100
+ }
101
+
102
+ export function getManagerJob(jobId: string): ManagerJob | undefined {
103
+ return jobsById.get(jobId);
104
+ }
105
+
106
+ export function getJobBySession(sessionId: string): ManagerJob | undefined {
107
+ const id = jobIdBySession.get(sessionId);
108
+ return id ? jobsById.get(id) : undefined;
109
+ }
110
+
111
+ /** Active (not terminal) jobs, newest first. */
112
+ export function listActiveManagerJobs(limit = 12): ManagerJob[] {
113
+ return [...jobsById.values()]
114
+ .filter((j) => j.status === "dispatched" || j.status === "running")
115
+ .sort((a, b) => b.updatedAt - a.updatedAt)
116
+ .slice(0, limit);
117
+ }
118
+
119
+ /** Recent jobs including finished, newest first. */
120
+ export function listRecentManagerJobs(limit = 12): ManagerJob[] {
121
+ return [...jobsById.values()]
122
+ .sort((a, b) => b.updatedAt - a.updatedAt)
123
+ .slice(0, Math.max(1, Math.min(50, limit)));
124
+ }
125
+
126
+ function trimJobs(): void {
127
+ if (jobsById.size <= MAX_JOBS) return;
128
+ const ordered = [...jobsById.values()].sort((a, b) => a.updatedAt - b.updatedAt);
129
+ const drop = ordered.length - MAX_JOBS;
130
+ for (let i = 0; i < drop; i++) {
131
+ const j = ordered[i]!;
132
+ jobsById.delete(j.id);
133
+ if (j.childSessionId) jobIdBySession.delete(j.childSessionId);
134
+ }
135
+ }
136
+
137
+ /** Test helper: wipe job state. */
138
+ export function clearManagerJobsForTests(): void {
139
+ jobsById.clear();
140
+ jobIdBySession.clear();
141
+ seq = 0;
142
+ }
@@ -101,8 +101,11 @@ export class Ephemeral {
101
101
  msg && "message_thread_id" in msg
102
102
  ? (msg as { message_thread_id?: number }).message_thread_id
103
103
  : undefined;
104
+ // Omit General (1) — Bot API rejects message_thread_id=1.
104
105
  const merged =
105
- threadId !== undefined && extra.message_thread_id === undefined
106
+ threadId !== undefined &&
107
+ threadId !== 1 &&
108
+ extra.message_thread_id === undefined
106
109
  ? { ...extra, message_thread_id: threadId }
107
110
  : extra;
108
111
  return this.serialize(chatId, async () => {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Interactive plan-mode approval. Grok's exit_plan_mode reverse request
3
+ * becomes Approve / Request changes / Abandon buttons (same idea as
4
+ * PermissionService). Auto-approve is used when configured, or when the
5
+ * session has no owning chat (scheduled / orphan).
6
+ */
7
+ import type { Api } from "grammy";
8
+ import { InlineKeyboard } from "grammy";
9
+ import type { PlanExitDecision, PlanExitOutcome } from "../grok/plan-approval.js";
10
+ import { outboundThreadExtra } from "../forum/thread.js";
11
+ import { createLogger } from "../logger.js";
12
+ import type { RuntimeRegistry } from "./registry.js";
13
+
14
+ const log = createLogger("plan-exit");
15
+ const TIMEOUT_MS = 30 * 60 * 1000;
16
+ const PREVIEW = 900;
17
+
18
+ interface Pending {
19
+ resolve: (d: PlanExitDecision) => void;
20
+ chatId: number;
21
+ messageId?: number;
22
+ timer: NodeJS.Timeout;
23
+ pinned: boolean;
24
+ waitingFeedback: boolean;
25
+ }
26
+
27
+ export class PlanExitService {
28
+ private readonly pending = new Map<string, Pending>();
29
+ /** chatId → reqId while we wait for revision notes after "Request changes". */
30
+ private readonly feedbackFor = new Map<number, string>();
31
+ private seq = 0;
32
+
33
+ constructor(
34
+ private readonly api: Api,
35
+ private readonly registry: RuntimeRegistry,
36
+ public autoApprove = false,
37
+ private readonly onUnpinned?: (chatId: number) => void | Promise<void>,
38
+ ) {}
39
+
40
+ async handle(params: Record<string, unknown>): Promise<PlanExitDecision> {
41
+ const sessionId = str(params.sessionId) || str(params.session_id) || "";
42
+ const planText = extractPlanText(params);
43
+ if (this.autoApprove) {
44
+ log.info(`auto-approved plan exit for ${sessionId.slice(0, 8) || "?"}`);
45
+ return { outcome: "approved", feedback: "" };
46
+ }
47
+
48
+ const desc = sessionId ? this.registry.describeSession(sessionId) : { chatId: undefined };
49
+ const chatId = desc.chatId;
50
+ if (chatId === undefined) return { outcome: "approved", feedback: "" };
51
+ const threadExtra = outboundThreadExtra(desc.threadId);
52
+
53
+ const reqId = String(++this.seq);
54
+ const preview = planText.replace(/\s+/g, " ").trim().slice(0, PREVIEW);
55
+ const body = [
56
+ "\u{1F4CB} Plan ready \u2014 review before Grok implements.",
57
+ preview ? `\n${preview}${planText.length > PREVIEW ? "\u2026" : ""}` : "\n(No plan text in the request.)",
58
+ "\nApprove to build, request changes (then send notes), or abandon.",
59
+ ].join("\n");
60
+
61
+ const kb = new InlineKeyboard()
62
+ .text("\u2705 Approve", `planx:${reqId}:ok`)
63
+ .text("\u270F\uFE0F Changes", `planx:${reqId}:chg`)
64
+ .row()
65
+ .text("\u26D4 Abandon", `planx:${reqId}:no`);
66
+
67
+ let messageId: number | undefined;
68
+ let pinned = false;
69
+ try {
70
+ const msg = await this.api.sendMessage(chatId, body, {
71
+ reply_markup: kb,
72
+ disable_notification: false,
73
+ ...threadExtra,
74
+ });
75
+ messageId = msg.message_id;
76
+ try {
77
+ await this.api.pinChatMessage(chatId, messageId, { disable_notification: true });
78
+ pinned = true;
79
+ } catch (e) {
80
+ log.warn("pin plan prompt failed:", (e as Error).message);
81
+ }
82
+ } catch (e) {
83
+ log.warn("send plan prompt failed:", (e as Error).message);
84
+ return { outcome: "approved", feedback: "" };
85
+ }
86
+
87
+ return new Promise<PlanExitDecision>((resolve) => {
88
+ const timer = setTimeout(() => {
89
+ const p = this.pending.get(reqId);
90
+ if (!p) return;
91
+ this.pending.delete(reqId);
92
+ this.feedbackFor.delete(p.chatId);
93
+ void this.finish(p, "\u231B Plan approval timed out \u2014 abandoned.");
94
+ resolve({ outcome: "abandoned", feedback: "timed out waiting for review" });
95
+ }, TIMEOUT_MS);
96
+ this.pending.set(reqId, { resolve, chatId, messageId, timer, pinned, waitingFeedback: false });
97
+ });
98
+ }
99
+
100
+ /** Button tap. Returns a short toast; "chg" waits for the next text message. */
101
+ resolveChoice(reqId: string, action: string): string | undefined {
102
+ const p = this.pending.get(reqId);
103
+ if (!p) return undefined;
104
+ if (action === "chg") {
105
+ p.waitingFeedback = true;
106
+ this.feedbackFor.set(p.chatId, reqId);
107
+ void this.api
108
+ .editMessageText(p.chatId, p.messageId ?? 0, "\u270F\uFE0F Send revision notes as your next message.", {
109
+ reply_markup: { inline_keyboard: [] },
110
+ })
111
+ .catch(() => {});
112
+ return "Send change notes";
113
+ }
114
+ clearTimeout(p.timer);
115
+ this.pending.delete(reqId);
116
+ this.feedbackFor.delete(p.chatId);
117
+ const outcome: PlanExitOutcome = action === "ok" ? "approved" : "abandoned";
118
+ const label = outcome === "approved" ? "\u2705 Plan approved \u2014 implementing." : "\u26D4 Plan abandoned.";
119
+ void this.finish(p, label);
120
+ p.resolve({ outcome, feedback: "" });
121
+ return outcome === "approved" ? "Approved" : "Abandoned";
122
+ }
123
+
124
+ /** If this chat is waiting for revision notes, consume the text. */
125
+ takeFeedback(chatId: number, text: string): boolean {
126
+ const reqId = this.feedbackFor.get(chatId);
127
+ if (!reqId) return false;
128
+ const p = this.pending.get(reqId);
129
+ this.feedbackFor.delete(chatId);
130
+ if (!p) return false;
131
+ clearTimeout(p.timer);
132
+ this.pending.delete(reqId);
133
+ const notes = text.trim().slice(0, 4000);
134
+ void this.finish(p, `\u270F\uFE0F Requested changes:\n${notes.slice(0, 400)}`);
135
+ p.resolve({ outcome: "request_changes", feedback: notes || "Please revise the plan." });
136
+ return true;
137
+ }
138
+
139
+ private async finish(p: Pending, text: string): Promise<void> {
140
+ if (p.messageId !== undefined) {
141
+ await this.api
142
+ .editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } })
143
+ .catch(() => {});
144
+ }
145
+ if (p.pinned && p.messageId !== undefined) {
146
+ p.pinned = false;
147
+ await this.api.unpinChatMessage(p.chatId, p.messageId).catch(() => {});
148
+ }
149
+ if (this.onUnpinned) {
150
+ try {
151
+ await this.onUnpinned(p.chatId);
152
+ } catch {
153
+ /* non-fatal */
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ export function extractPlanText(params: Record<string, unknown>): string {
160
+ for (const k of ["plan_content", "planContent", "content", "plan", "text"]) {
161
+ const v = params[k];
162
+ if (typeof v === "string" && v.trim()) return v;
163
+ }
164
+ return "";
165
+ }
166
+
167
+ function str(v: unknown): string {
168
+ return typeof v === "string" ? v : "";
169
+ }
@@ -15,6 +15,7 @@ import type { Api, Context } from "grammy";
15
15
  import { InputMediaBuilder } from "grammy";
16
16
  import { createLogger } from "../logger.js";
17
17
  import { tagSafe } from "../render/hashtags.js";
18
+ import { outboundThreadExtra } from "../forum/thread.js";
18
19
 
19
20
  const log = createLogger("prompt-anchor");
20
21
 
@@ -123,10 +124,8 @@ export async function adoptUserPrompt(
123
124
  });
124
125
  const threadExtra: Record<string, unknown> = {
125
126
  disable_notification: true,
127
+ ...outboundThreadExtra(opts.messageThreadId),
126
128
  };
127
- if (opts.messageThreadId !== undefined) {
128
- threadExtra.message_thread_id = opts.messageThreadId;
129
- }
130
129
 
131
130
  let replyTo: number;
132
131
  try {
@@ -69,6 +69,9 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
69
69
  // Preserve meta flags: auto-suggestion batches / self-recheck must not re-arm
70
70
  // another recheck after merge (dropping this caused infinite recheck loops).
71
71
  const skipSelfRecheck = inputs.some((i) => i.skipSelfRecheck);
72
+ // First reportBack wins (oldest queued manager dispatch in this merge batch).
73
+ const reportBack = inputs.find((i) => i.reportBack)?.reportBack;
74
+ const seedMessageId = inputs.find((i) => i.seedMessageId !== undefined)?.seedMessageId;
72
75
  return {
73
76
  text: inputs
74
77
  .map((i) => i.text)
@@ -82,6 +85,8 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
82
85
  promptId: inputs.find((i) => i.promptId)?.promptId,
83
86
  quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
84
87
  skipSelfRecheck: skipSelfRecheck || undefined,
88
+ reportBack,
89
+ seedMessageId,
85
90
  };
86
91
  }
87
92
 
@@ -24,6 +24,8 @@ import type { SessionRuntime } from "./session-runtime.js";
24
24
  export interface SessionDescription {
25
25
  /** Chat that owns the session (controlled session or subagent parent). */
26
26
  chatId?: number;
27
+ /** Forum topic thread id when the session lives in a project topic. */
28
+ threadId?: number;
27
29
  /** True when this is a session the chat directly controls. */
28
30
  controlled: boolean;
29
31
  /** True when this is a subagent of a controlled turn. */
@@ -202,10 +204,17 @@ export class RuntimeRegistry {
202
204
  describeSession(sessionId: string): SessionDescription {
203
205
  const controlledChat = this.findChatBySession(sessionId);
204
206
  if (controlledChat !== undefined) {
205
- const project = this.controller(controlledChat)
207
+ const forum = this.forumControllerForSession(sessionId);
208
+ const project = (forum ?? this.controller(controlledChat))
206
209
  .list()
207
210
  .find((s) => s.sessionId === sessionId)?.projectName;
208
- return { chatId: controlledChat, controlled: true, subagent: false, projectName: project };
211
+ return {
212
+ chatId: controlledChat,
213
+ threadId: forum?.messageThreadId,
214
+ controlled: true,
215
+ subagent: false,
216
+ projectName: project,
217
+ };
209
218
  }
210
219
  const parent = this.subagentParents.get(sessionId);
211
220
  const info = this.acp.subagentById(sessionId);
package/src/bot/scope.ts CHANGED
@@ -6,8 +6,11 @@ import type { Context } from "grammy";
6
6
  import type { BotDeps } from "./deps.js";
7
7
  import type { ChatController } from "./chat-controller.js";
8
8
  import type { SessionRuntime } from "./session-runtime.js";
9
- import { forumThreadId } from "../forum/thread.js";
10
- import { FORUM_GENERAL_THREAD_ID } from "../forum/thread.js";
9
+ import {
10
+ forumThreadId,
11
+ FORUM_GENERAL_THREAD_ID,
12
+ outboundThreadExtra,
13
+ } from "../forum/thread.js";
11
14
 
12
15
  export interface HandlerScope {
13
16
  chatId: number;
@@ -48,10 +51,6 @@ export function resolveScope(ctx: Context, deps: BotDeps): HandlerScope {
48
51
  const chatId = ctx.chat!.id;
49
52
  const rawThread = threadIdFromContext(ctx);
50
53
  const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
51
- const threadExtra =
52
- isForum || rawThread !== undefined
53
- ? { message_thread_id: isForum ? forumThreadId(rawThread) : rawThread }
54
- : {};
55
54
 
56
55
  if (!isForum || !deps.forum) {
57
56
  const controller = deps.registry.controller(chatId);
@@ -61,7 +60,8 @@ export function resolveScope(ctx: Context, deps: BotDeps): HandlerScope {
61
60
  settingsKey: settingsKeyFor(chatId),
62
61
  controller,
63
62
  rt: controller.foreground(),
64
- threadExtra: rawThread !== undefined ? { message_thread_id: rawThread } : {},
63
+ // Private chats rarely have threads; still omit General-style id 1.
64
+ threadExtra: outboundThreadExtra(rawThread),
65
65
  };
66
66
  }
67
67
 
@@ -87,7 +87,8 @@ export function resolveScope(ctx: Context, deps: BotDeps): HandlerScope {
87
87
  settingsKey: settingsKeyFor(chatId, tid),
88
88
  controller,
89
89
  rt: controller.foreground(),
90
- threadExtra: { message_thread_id: tid },
90
+ // Outbound: never pass message_thread_id=1 (General) — Telegram rejects it.
91
+ threadExtra: outboundThreadExtra(tid),
91
92
  projectPath: cwd,
92
93
  projectName,
93
94
  };