grok-telegram-bot 2.4.0 → 2.6.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 (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
package/docs/GROUP.md ADDED
@@ -0,0 +1,260 @@
1
+ # Forum project group
2
+
3
+ Use a **Telegram forum supergroup** so each project gets its own topic. The bot
4
+ runs Grok sessions **in the bound project path** for that topic, while the
5
+ default **AI Chat** topic stays on your workspace (`GROK_WORKSPACE`).
6
+
7
+ This is optional. Private DMs with the bot still work exactly as before.
8
+
9
+ ---
10
+
11
+ ## Prerequisites
12
+
13
+ 1. A **supergroup** with **Topics** enabled (Telegram group settings → Topics).
14
+ 2. The bot is an **administrator** with **Manage Topics** (and enough rights to
15
+ post / pin when you want icons or setup announcements).
16
+ 3. Your bot token and allowlist configured as usual:
17
+ - `TELEGRAM_BOT_TOKEN`
18
+ - **`ALLOWED_USERS`** — required for groups. Unauthorized members are
19
+ **ignored silently** (no ⛔ spam). Empty allowlist with a forum group is
20
+ unsafe (any member could drive the host).
21
+ 4. `PROJECT_ROOTS` / catalog so project names resolve (same roots as `/projects`).
22
+
23
+ ---
24
+
25
+ ## Configure
26
+
27
+ In `.env` (or `~/.grok/tg/.env` for a global npm install):
28
+
29
+ ```ini
30
+ # Negative Telegram chat id of the forum supergroup
31
+ TOPIC_GROUP_ID=-100xxxxxxxxxx
32
+
33
+ # Default true: create one topic per catalog project (paced + 429-retried)
34
+ TOPIC_AUTO_CREATE=true
35
+
36
+ # Display name for the workspace topic (default AI Chat)
37
+ TOPIC_AI_CHAT_NAME=AI Chat
38
+
39
+ # Workspace used in General / AI Chat
40
+ GROK_WORKSPACE=C:\path\to\workspace
41
+
42
+ # Where /projects and exact-name topic binds look for folders
43
+ PROJECT_ROOTS=C:\path\to\Domains,H:\Lucru\Domains
44
+ ```
45
+
46
+ Restart the bot after changing these. On startup the bot **probes** the group:
47
+
48
+ | Probe result | Behavior |
49
+ |---|---|
50
+ | Not admin / no Manage Topics | Group **ignored** for topic features |
51
+ | Topics off | Best-effort try to enable; if Telegram has no API for it, group ignored until you enable Topics manually |
52
+ | Admin + Topics on | **Ready** — creates AI Chat (and optional project topics) |
53
+
54
+ Re-run setup any time from inside the group:
55
+
56
+ ```
57
+ /forum_setup
58
+ ```
59
+
60
+ You get a clear **ready** vs **disabled (reason)** status and the mapped topic count.
61
+
62
+ When the bot is later promoted to admin, it re-probes automatically (`my_chat_member`).
63
+
64
+ ---
65
+
66
+ ## Topic model
67
+
68
+ | Topic | Working directory | Typical use |
69
+ |---|---|---|
70
+ | **General** | `GROK_WORKSPACE` | **Manager chat** (OpenClaw-style): routes work, memory-first, no coding spam |
71
+ | **AI Chat** | `GROK_WORKSPACE` | Normal coding/conversation in the workspace |
72
+ | **Project topic** | Bound project path | All coding work for that folder |
73
+ | **User-created topic** | Bound after name/path match | Ad-hoc projects or new folders |
74
+
75
+ ### General = manager
76
+
77
+ Messages in **General** drive a chat-like orchestrator, not a coding agent:
78
+
79
+ 1. User asks in General (e.g. “fix login in MyApp”).
80
+ 2. Bot uses **memory + topic catalog** (auto-injected) and may call `search_memory` / `list_topics`.
81
+ 3. It replies briefly (“OK — I’ll start … in **MyApp**”) with **no progress bars / tool dumps**.
82
+ 4. It **dispatches** via `create_topic` / `set_path` / `send_prompt` into the right project topic.
83
+ 5. When that child turn finishes, the bridge **wakes General** with a `MANAGER WORK REPORT` so the manager summarizes success/fail for you.
84
+
85
+ Real implementation stays in **project topics**. General should not edit app code.
86
+
87
+ Messages you send **inside a project topic** are prompts for a session whose `cwd` is
88
+ that topic’s path. Menus, model/reasoning picks, `/sessions`, `/running`, and
89
+ Stop are **topic-scoped** so one project does not steal another’s session.
90
+
91
+ In topics the persistent reply keyboard is unreliable, so use the **topic
92
+ inline menu** (Stop + Running + …) or slash commands (`/new`, `/stop`, …).
93
+
94
+ ---
95
+
96
+ ## Binding a topic to a project
97
+
98
+ ### Auto-bind (exact name only)
99
+
100
+ If you create a topic whose title **exactly** matches a catalog project name
101
+ (case-insensitive), the bot binds it immediately and confirms the path.
102
+
103
+ Fuzzy / partial matching is **not** used (it used to pick the wrong folder).
104
+
105
+ ### Manual bind
106
+
107
+ If there is no exact catalog match, the bot asks you to send:
108
+
109
+ - an **absolute directory path**, or
110
+ - an **exact** catalog project name
111
+
112
+ Examples:
113
+
114
+ ```text
115
+ H:\Lucru\Domains\MyApp
116
+ MyApp
117
+ ```
118
+
119
+ ### Agent bind (Telegram bridge)
120
+
121
+ From **AI Chat / General**, the agent can create topics and bind paths via a
122
+ fenced JSON block (see [Agent bridge](#agent-bridge-cross-topic-actions)).
123
+ Absolute paths that **do not exist yet are created on disk**, then bound
124
+ (new-project flow).
125
+
126
+ ### One path, one topic
127
+
128
+ A catalog path is only bound to **one** topic. If the same exact name/path is
129
+ already used, the bot says so and asks for another path or name.
130
+
131
+ ---
132
+
133
+ ## Day-to-day usage
134
+
135
+ 1. Open the **project topic** (or create one and bind it).
136
+ 2. Chat normally — each message is a Grok prompt in that project.
137
+ 3. **`/new`** or the menu **New** button starts a fresh session in that topic.
138
+ 4. **Stop** / `/stop` / `/cancel` only cancels **this topic’s** in-flight turn —
139
+ never the shared `grok agent` process (other topics keep running).
140
+ 5. Attach photos, documents, and (with STT configured) voice the same as in DM.
141
+ 6. User prompts are re-posted as **prompt anchors** with a searchable
142
+ `#prompt_…` tag; replies and Done messages thread to that anchor.
143
+
144
+ ### Multi-project orchestration
145
+
146
+ In **AI Chat**, ask the agent to open a project elsewhere, e.g. “Create a topic
147
+ for MyApp at `H:\Projects\MyApp` and scaffold a README there.” The agent emits
148
+ bridge actions; the bot creates the topic, binds the path, and can
149
+ `send_prompt` into that topic without you switching threads manually.
150
+
151
+ ---
152
+
153
+ ## Agent bridge (cross-topic actions)
154
+
155
+ On the **first prompt of a session** the bot teaches the agent a small protocol:
156
+ emit a fenced `json` block with a `"telegram"` array (up to **9** actions per
157
+ turn; up to **5** `send_prompt`). The bridge strips the fence from the chat and
158
+ may feed results back as a quiet system turn (not a second Done).
159
+
160
+ | Action | Purpose |
161
+ |---|---|
162
+ | `create_topic` | New forum topic; optional `path` binds immediately |
163
+ | `set_path` | Bind/rebind topic by title or `#threadId` |
164
+ | `send_prompt` | Inject a prompt into another topic (`ran` / `queued`; optional `new_session`, **`session_id`** to resume a specific session) |
165
+ | `notify` | Optional extra ping. In **General** the user already sees short chat prose; `notify` is not required. |
166
+ | `search_memory` | Search topic + session indexes (memory-first) |
167
+ | `list_topics` | List mapped forum topics (name, `#id`, path) |
168
+ | `list_jobs` | List recent General → project dispatches |
169
+ | `list_bots` | List allowlisted sibling bots |
170
+ | `bot_command` | Call `/command@bot` and wait for that bot to settle |
171
+
172
+ Example (from General / AI Chat):
173
+
174
+ ```json
175
+ {
176
+ "telegram": [
177
+ {
178
+ "action": "create_topic",
179
+ "name": "MyApp",
180
+ "path": "H:\\Projects\\MyApp"
181
+ },
182
+ {
183
+ "action": "send_prompt",
184
+ "topic": "MyApp",
185
+ "prompt": "1) scaffold\n2) tests\n3) README"
186
+ }
187
+ ]
188
+ }
189
+ ```
190
+
191
+ `topic` may be the exact title, `#threadId`, `general`, or `ai chat`.
192
+
193
+ To **resume a related session** found via memory (not the topic's currently open session):
194
+
195
+ ```json
196
+ {
197
+ "telegram": [
198
+ {
199
+ "action": "send_prompt",
200
+ "topic": "MyApp",
201
+ "session_id": "019fc9ec",
202
+ "prompt": "Continue: apply the follow-up fix."
203
+ }
204
+ ]
205
+ }
206
+ ```
207
+
208
+ `session_id` may be a full UUID or a short prefix from memory hits. Without it, the bridge uses the topic's foreground session.
209
+
210
+ `topic` must be an **exact** mapped title or `#threadId` (never placeholders like `…`). If only the session is known, omit `topic` — the bridge maps `session.cwd` → forum topic.
211
+
212
+ ---
213
+
214
+ ## Sibling bots (optional)
215
+
216
+ To let the agent call other Telegram bots you control:
217
+
218
+ ```ini
219
+ ALLOWED_TELEGRAM_BOTS=helperbot,other_bot
220
+ # Optional catalogs for list_bots / teaching:
221
+ # TELEGRAM_BOT_COMMANDS=helperbot:status,help;otherbot:start|Start,info
222
+ # TELEGRAM_BOT_REPLY_TIMEOUT_MS=45000
223
+ # TELEGRAM_BOT_SETTLE_MS=2000
224
+ ```
225
+
226
+ Only usernames on the allowlist can be invoked. Timeouts return `ok=false` and
227
+ do **not** count as Done.
228
+
229
+ ---
230
+
231
+ ## Access control
232
+
233
+ | Setting | Effect |
234
+ |---|---|
235
+ | `ALLOWED_USERS` set | Only listed user IDs can prompt (DM **and** group topics) |
236
+ | Not allowlisted | Updates ignored **silently** in the group |
237
+ | Empty allowlist | Anyone can use the bot — **do not combine with `TOPIC_GROUP_ID`** |
238
+
239
+ ---
240
+
241
+ ## Troubleshooting
242
+
243
+ | Symptom | What to check |
244
+ |---|---|
245
+ | Topics never created | Bot admin + Manage Topics; Topics enabled; `TOPIC_GROUP_ID` exact (negative id); `/forum_setup` |
246
+ | “Group ignored” | Status text from `/forum_setup` (`not_admin`, `not_forum`, …) |
247
+ | Wrong folder bound | Title must **exactly** match catalog name; re-bind with absolute path |
248
+ | Agent can’t create topics | Forum must be **ready**; work from AI Chat / General for orchestration |
249
+ | Unauthorized spam / silent ignore | Confirm your id in `ALLOWED_USERS`; others are silent by design |
250
+ | Stop kills everything | Should not — upgrade if an old build killed the shared agent; Stop is session-scoped |
251
+ | Large catalog slow | Bulk create is paced and 429-retried; re-run `/forum_setup` to continue |
252
+
253
+ ---
254
+
255
+ ## Related
256
+
257
+ - [README](../README.md) — full feature list and config table
258
+ - [INSTALL.md](./INSTALL.md) — first-time setup
259
+ - [UPGRADE.md](./UPGRADE.md) — updating an existing install
260
+ - `.env.example` — all forum / bridge env vars with comments
package/docs/INSTALL.md CHANGED
@@ -41,6 +41,9 @@ grok-tg run # run in the foreground (Ctrl-C to stop)
41
41
 
42
42
  > ⚠️ **Set `ALLOWED_USERS`** in `.env` to your Telegram user ID(s). Empty means
43
43
  > *anyone* who finds the bot can run commands on your machine.
44
+ >
45
+ > Optional: drive one project per **forum topic** with `TOPIC_GROUP_ID` — see
46
+ > **[GROUP.md](./GROUP.md)** after the bot is running.
44
47
 
45
48
  ### Startup options (`grok-tg <command>`)
46
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -60,15 +60,15 @@
60
60
  "dependencies": {
61
61
  "diff": "^7.0.0",
62
62
  "dotenv": "^16.4.7",
63
- "grammy": "^1.30.0",
64
- "tsx": "^4.19.2"
63
+ "grammy": "^1.46.0",
64
+ "tsx": "^4.23.13"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@homebridge/node-pty-prebuilt-multiarch": "0.13.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@types/diff": "^7.0.0",
71
- "@types/node": "^22.10.0",
71
+ "@types/node": "^22.20.1",
72
72
  "typescript": "^5.7.2"
73
73
  }
74
74
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Cross-module intentional-exit flag so beforeExit keep-alive and the polling
3
+ * loop do not fight updater re-exec / fatal exits / SIGINT.
4
+ */
5
+ let intentional = false;
6
+ let reason = "";
7
+
8
+ /** Mark that the process is exiting on purpose (do not keep-alive). */
9
+ export function markIntentionalShutdown(why: string): void {
10
+ intentional = true;
11
+ reason = why;
12
+ }
13
+
14
+ export function isIntentionalShutdown(): boolean {
15
+ return intentional;
16
+ }
17
+
18
+ export function intentionalShutdownReason(): string {
19
+ return reason;
20
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Per-chat settings persistence (project, agent, model, reasoning, pinned
3
- * status message id). Backed by a single JSON file so state survives restarts.
2
+ * Per-chat / per-forum-topic settings persistence (project, agent, model,
3
+ * reasoning, pinned status message id, controlled sessions).
4
+ * Keys: `"12345"` for private chats, `"12345:t7"` for forum topic thread 7.
5
+ * Backed by a single JSON file so state survives restarts.
4
6
  */
5
7
  import { join } from "node:path";
6
8
  import { JsonStore } from "./json-store.js";
@@ -15,24 +17,61 @@ export class SettingsStore {
15
17
  this.store = new JsonStore<SettingsMap>(join(dataDir, "settings.json"), {});
16
18
  }
17
19
 
20
+ /** Settings for a private chat (or legacy callers). */
18
21
  get(chatId: number): ChatSettings {
19
- const existing = this.store.get()[String(chatId)];
22
+ return this.getKey(String(chatId));
23
+ }
24
+
25
+ /** Settings by storage key (`chatId` or `chatId:t{threadId}`). */
26
+ getKey(key: string): ChatSettings {
27
+ const existing = this.store.get()[key];
20
28
  return existing ?? defaultSettings();
21
29
  }
22
30
 
23
31
  update(chatId: number, patch: Partial<ChatSettings>): ChatSettings {
24
- const key = String(chatId);
25
- const next = { ...this.get(chatId), ...patch };
32
+ return this.updateKey(String(chatId), patch);
33
+ }
34
+
35
+ updateKey(key: string, patch: Partial<ChatSettings>): ChatSettings {
36
+ const next = { ...this.getKey(key), ...patch };
26
37
  this.store.update((m) => {
27
38
  m[key] = next;
28
39
  });
29
40
  return next;
30
41
  }
31
42
 
43
+ /**
44
+ * All settings entries whose projectPath matches (for bidirectional
45
+ * bot ↔ forum session discovery).
46
+ */
47
+ entriesForProject(projectPath: string): Array<{ key: string; settings: ChatSettings }> {
48
+ const want = normPath(projectPath);
49
+ const out: Array<{ key: string; settings: ChatSettings }> = [];
50
+ for (const [key, s] of Object.entries(this.store.get())) {
51
+ if (s.projectPath && normPath(s.projectPath) === want) {
52
+ out.push({ key, settings: s });
53
+ }
54
+ for (const cs of s.controlledSessions ?? []) {
55
+ if (cs.projectPath && normPath(cs.projectPath) === want) {
56
+ out.push({ key, settings: s });
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
32
64
  /** All chat ids that have interacted (for broadcast announcements). */
33
65
  chatIds(): number[] {
34
- return Object.keys(this.store.get())
35
- .map(Number)
36
- .filter((n) => Number.isFinite(n));
66
+ const ids = new Set<number>();
67
+ for (const key of Object.keys(this.store.get())) {
68
+ const n = Number(key.split(":")[0]);
69
+ if (Number.isFinite(n)) ids.add(n);
70
+ }
71
+ return [...ids];
37
72
  }
38
73
  }
74
+
75
+ function normPath(p: string): string {
76
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
77
+ }
package/src/app/types.ts CHANGED
@@ -13,6 +13,11 @@ export interface ChatSettings {
13
13
  agent?: string;
14
14
  model?: string;
15
15
  reasoning: ReasoningEffort;
16
+ /**
17
+ * Preferred saved Grok account login id for this chat/topic (optional).
18
+ * Applied when starting turns if different from the process-active account.
19
+ */
20
+ preferredAccountId?: string;
16
21
  /** Telegram message id of the pinned status panel, if any. */
17
22
  statusMessageId?: number;
18
23
  /** Sessions this chat controls (for multi-session switching). */
@@ -57,6 +62,11 @@ export interface PromptInput {
57
62
  resourceLinks?: PromptResourceLink[];
58
63
  /** Telegram message id of the prompt, so the reply threads to it. */
59
64
  replyTo?: number;
65
+ /**
66
+ * Short id for the bot-owned prompt anchor (`#prompt_<id>`). All AI messages
67
+ * for this turn carry the same tag so the user can search related replies.
68
+ */
69
+ promptId?: string;
60
70
  /**
61
71
  * Content of the message the user was replying to (or the portion they
62
72
  * quoted). Injected as context so the agent sees what the user is responding
@@ -68,20 +78,47 @@ export interface PromptInput {
68
78
  * trigger another self-recheck — only real user prompts do (once each).
69
79
  */
70
80
  skipSelfRecheck?: boolean;
81
+ /**
82
+ * Manager dispatch metadata for this prompt only (General → project).
83
+ * Carried through the queue so concurrent send_prompt jobs do not steal
84
+ * each other's report-back. Shape matches bot/manager-jobs ReportBackMeta.
85
+ */
86
+ reportBack?: {
87
+ jobId: string;
88
+ originChatId: number;
89
+ originThreadId: number;
90
+ userAskPreview: string;
91
+ targetName: string;
92
+ targetPath: string;
93
+ dispatchPrompt: string;
94
+ };
95
+ /**
96
+ * Pre-posted status bubble (General: "Starting…") that the turn edits to
97
+ * "Thinking…" then streams the agent reply into.
98
+ */
99
+ seedMessageId?: number;
71
100
  }
72
101
 
73
102
  export function textPrompt(
74
103
  text: string,
75
104
  replyTo?: number,
76
105
  quotedText?: string,
77
- opts?: { skipSelfRecheck?: boolean },
106
+ opts?: {
107
+ skipSelfRecheck?: boolean;
108
+ promptId?: string;
109
+ reportBack?: PromptInput["reportBack"];
110
+ seedMessageId?: number;
111
+ },
78
112
  ): PromptInput {
79
113
  return {
80
114
  text,
81
115
  images: [],
82
116
  resourceLinks: [],
83
117
  replyTo,
118
+ promptId: opts?.promptId,
84
119
  quotedText,
85
120
  skipSelfRecheck: opts?.skipSelfRecheck,
121
+ reportBack: opts?.reportBack,
122
+ seedMessageId: opts?.seedMessageId,
86
123
  };
87
124
  }
@@ -17,6 +17,7 @@ import { get } from "node:https";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { JsonStore } from "./json-store.js";
20
+ import { markIntentionalShutdown } from "./lifetime-flag.js";
20
21
  import { createLogger } from "../logger.js";
21
22
  import { extractChangelog, isNewer, isSafeVersion } from "./version.js";
22
23
 
@@ -145,27 +146,47 @@ export class Updater {
145
146
  }
146
147
 
147
148
  private async restart(): Promise<void> {
149
+ // Signal main/beforeExit that this exit is intentional (avoid keep-alive race).
150
+ markIntentionalShutdown("updater-reexec");
148
151
  await this.opts.shutdown().catch(() => {});
149
152
  // Under systemd/launchd, a clean exit triggers a managed relaunch (no double
150
153
  // instance). On Windows / foreground there is no supervisor, so re-exec.
151
154
  if (process.env.GROK_TG_SUPERVISED === "1") {
152
155
  log.info("exiting for supervisor to relaunch the updated bot");
156
+ try {
157
+ process.stderr.write("[updater] supervised exit for relaunch\n");
158
+ } catch {
159
+ /* ignore */
160
+ }
153
161
  setTimeout(() => process.exit(0), 250);
154
162
  return;
155
163
  }
156
164
  log.info("re-executing the updated bot");
157
165
  try {
166
+ // TTY: inherit stdio so `npm start` doesn't look like a silent death.
167
+ // Non-TTY (service): detach and ignore stdio.
168
+ const inherit = Boolean(process.stdout.isTTY);
158
169
  const child = spawn(
159
170
  process.execPath,
160
171
  ["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir],
161
- { detached: true, stdio: "ignore", cwd: this.opts.projectRoot, env: process.env },
172
+ {
173
+ detached: !inherit,
174
+ stdio: inherit ? "inherit" : "ignore",
175
+ cwd: this.opts.projectRoot,
176
+ env: process.env,
177
+ },
162
178
  );
163
- child.unref();
179
+ if (!inherit) child.unref();
164
180
  if (!child.pid) {
165
181
  log.error("re-exec spawn produced no pid — staying alive");
166
182
  return;
167
183
  }
168
- log.info(`re-exec child pid ${child.pid}`);
184
+ log.info(`re-exec child pid ${child.pid} (stdio=${inherit ? "inherit" : "ignore"})`);
185
+ try {
186
+ process.stderr.write(`[updater] re-exec child pid ${child.pid}; parent exiting\n`);
187
+ } catch {
188
+ /* ignore */
189
+ }
169
190
  } catch (e) {
170
191
  // Never exit if replacement failed — silent death is worse than stale code.
171
192
  log.error(`re-exec failed: ${(e as Error).message} — staying alive`);
package/src/bot/auth.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /**
2
2
  * Authorization middleware: restricts the bot to ALLOWED_USERS when configured.
3
+ *
4
+ * Applies to **private chats and groups/forum topics alike**. User IDs are
5
+ * comma-separated in env (`ALLOWED_USERS=111,222,333`). Empty set = allow all
6
+ * (unsafe — especially with TOPIC_GROUP_ID).
7
+ *
8
+ * Unauthorized users in groups are ignored silently (no ⛔ spam). Private chats
9
+ * get one clear denial. Callback taps get a toast.
3
10
  */
4
11
  import type { Context, NextFunction } from "grammy";
5
12
  import type { AppConfig } from "../config.js";
@@ -8,31 +15,109 @@ import { createLogger } from "../logger.js";
8
15
  const log = createLogger("auth");
9
16
 
10
17
  export function createAuthMiddleware(cfg: AppConfig) {
11
- const allowAll = cfg.allowedUsers.size === 0;
12
- if (allowAll) {
18
+ if (cfg.allowAllUsers) {
13
19
  log.warn("ALLOWED_USERS is empty — the bot will respond to ANY Telegram user.");
20
+ if (cfg.topicGroupId !== undefined) {
21
+ log.warn(
22
+ "TOPIC_GROUP_ID is set with empty ALLOWED_USERS — any group member can drive sessions.",
23
+ );
24
+ }
25
+ } else {
26
+ log.info(`ALLOWED_USERS: ${cfg.allowedUsers.size} id(s) (private + groups)`);
27
+ if (cfg.allowedUsers.size === 0) {
28
+ log.warn(
29
+ "ALLOWED_USERS was set but no valid numeric ids remain — denying everyone (fail closed).",
30
+ );
31
+ }
14
32
  }
15
33
 
16
34
  return async (ctx: Context, next: NextFunction): Promise<void> => {
35
+ // Bot membership changes (promote/demote) must always reach handlers so
36
+ // forum readiness can re-probe — not gated on ALLOWED_USERS or from.is_bot.
37
+ if (ctx.myChatMember) {
38
+ await next();
39
+ return;
40
+ }
41
+
17
42
  const from = ctx.from;
18
- // Only a genuine USER action is subject to (and worth replying to) the auth
19
- // gate. Ignore everything else silently most importantly the bot's OWN
20
- // updates: the status panel being pinned/unpinned emits a service message
21
- // whose `from` is THIS bot (is_bot), and replying "⛔ Not authorized" to
22
- // that (or to any service/no-`from` update) spammed the chat with false
23
- // rejections. Real unauthorized users still get one clear reply below.
43
+ // Only a genuine USER action is subject to the auth gate. Ignore bot-authored
44
+ // updates and missing `from` (service noise) so we never ⛔-spam ourselves.
24
45
  if (!from || from.is_bot) return;
25
46
  const m = ctx.message ?? ctx.editedMessage;
26
- if (m && (m.pinned_message || m.new_chat_members || m.left_chat_member)) return;
47
+ if (
48
+ m &&
49
+ (m.pinned_message ||
50
+ m.new_chat_members ||
51
+ m.left_chat_member ||
52
+ m.forum_topic_closed ||
53
+ m.forum_topic_reopened ||
54
+ m.forum_topic_edited ||
55
+ m.general_forum_topic_hidden ||
56
+ m.general_forum_topic_unhidden)
57
+ ) {
58
+ return;
59
+ }
27
60
 
28
- const userId = String(from.id);
29
- if (allowAll || cfg.allowedUsers.has(userId)) {
30
- await next();
61
+ // forum_topic_created: only allowed users get the path-bind prompt.
62
+ if (m?.forum_topic_created) {
63
+ if (isAllowed(cfg, from.id)) {
64
+ await next();
65
+ return;
66
+ }
67
+ log.debug(`blocked unauthorized forum_topic_created from ${from.id}`);
31
68
  return;
32
69
  }
33
- log.warn(`blocked unauthorized user ${userId}`);
34
- if (ctx.chat) {
35
- await ctx.reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID.");
70
+
71
+ if (isAllowed(cfg, from.id)) {
72
+ await next();
73
+ return;
36
74
  }
75
+
76
+ log.warn(
77
+ `blocked unauthorized user ${from.id}` +
78
+ (ctx.chat ? ` in chat ${ctx.chat.id} (${ctx.chat.type})` : ""),
79
+ );
80
+ await denyUnauthorized(ctx, m);
37
81
  };
38
82
  }
83
+
84
+ /**
85
+ * True when ALLOWED_USERS was blank (open) or `userId` is listed.
86
+ * When allowAllUsers is false and the set is empty, nobody is allowed.
87
+ */
88
+ export function isAllowed(cfg: AppConfig, userId: number | string): boolean {
89
+ if (cfg.allowAllUsers) return true;
90
+ return cfg.allowedUsers.has(String(userId));
91
+ }
92
+
93
+ /** Groups, supergroups, and channels: never ⛔-reply (silent deny). */
94
+ function isGroupChat(ctx: Context): boolean {
95
+ const t = ctx.chat?.type;
96
+ return t === "group" || t === "supergroup" || t === "channel";
97
+ }
98
+
99
+ /** Private: one ⛔ reply. Group: silent (or callback toast). Never spam topics. */
100
+ async function denyUnauthorized(
101
+ ctx: Context,
102
+ m: { message_thread_id?: number } | undefined,
103
+ ): Promise<void> {
104
+ if (ctx.callbackQuery) {
105
+ await ctx
106
+ .answerCallbackQuery({
107
+ text: "\u26D4 Not authorized",
108
+ show_alert: true,
109
+ })
110
+ .catch(() => {});
111
+ return;
112
+ }
113
+ if (!ctx.chat || isGroupChat(ctx)) return; // groups: ignore quietly
114
+ const threadId = m && "message_thread_id" in m ? m.message_thread_id : undefined;
115
+ // Omit General (1) — Bot API rejects message_thread_id=1.
116
+ const extra =
117
+ threadId !== undefined && threadId !== 1
118
+ ? { message_thread_id: threadId as number }
119
+ : {};
120
+ await ctx
121
+ .reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID to ALLOWED_USERS.", extra)
122
+ .catch(() => {});
123
+ }