grok-telegram-bot 2.0.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 (106) hide show
  1. package/.env.example +135 -0
  2. package/CHANGELOG.md +598 -0
  3. package/LICENSE +21 -0
  4. package/README.md +644 -0
  5. package/bin/grok-tg.mjs +21 -0
  6. package/docs/INSTALL.md +153 -0
  7. package/docs/UPGRADE.md +253 -0
  8. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  9. package/package.json +74 -0
  10. package/scripts/setup.mjs +116 -0
  11. package/src/agents/catalog.ts +58 -0
  12. package/src/app/accounts.ts +162 -0
  13. package/src/app/auth-service.ts +136 -0
  14. package/src/app/grok-credentials.ts +103 -0
  15. package/src/app/instance-lock.ts +139 -0
  16. package/src/app/json-store.ts +54 -0
  17. package/src/app/reasoning.ts +30 -0
  18. package/src/app/settings-store.ts +38 -0
  19. package/src/app/stt.ts +53 -0
  20. package/src/app/types.ts +56 -0
  21. package/src/app/updater.ts +234 -0
  22. package/src/app/usage.ts +38 -0
  23. package/src/app/version.ts +41 -0
  24. package/src/bot/account-rotator.ts +52 -0
  25. package/src/bot/auth.ts +38 -0
  26. package/src/bot/bot.ts +225 -0
  27. package/src/bot/chat-controller.ts +317 -0
  28. package/src/bot/commands.ts +52 -0
  29. package/src/bot/deps.ts +67 -0
  30. package/src/bot/file-ingest.ts +190 -0
  31. package/src/bot/handlers/accounts.ts +220 -0
  32. package/src/bot/handlers/auth.ts +64 -0
  33. package/src/bot/handlers/control.ts +103 -0
  34. package/src/bot/handlers/document.ts +112 -0
  35. package/src/bot/handlers/history.ts +63 -0
  36. package/src/bot/handlers/kill.ts +54 -0
  37. package/src/bot/handlers/mcp.ts +206 -0
  38. package/src/bot/handlers/menu.ts +220 -0
  39. package/src/bot/handlers/message.ts +103 -0
  40. package/src/bot/handlers/photo.ts +123 -0
  41. package/src/bot/handlers/projects.ts +183 -0
  42. package/src/bot/handlers/running.ts +181 -0
  43. package/src/bot/handlers/session-card.ts +81 -0
  44. package/src/bot/handlers/session-kill.ts +95 -0
  45. package/src/bot/handlers/sessions.ts +148 -0
  46. package/src/bot/handlers/system.ts +51 -0
  47. package/src/bot/handlers/tasks.ts +224 -0
  48. package/src/bot/handlers/usage.ts +38 -0
  49. package/src/bot/handlers/voice.ts +55 -0
  50. package/src/bot/image-return.ts +69 -0
  51. package/src/bot/menu/ephemeral.ts +117 -0
  52. package/src/bot/menu/keyboard.ts +49 -0
  53. package/src/bot/menu/refresh.ts +13 -0
  54. package/src/bot/menu/status-panel.ts +173 -0
  55. package/src/bot/permission-service.ts +149 -0
  56. package/src/bot/prompt-content.ts +64 -0
  57. package/src/bot/prompt-retry.ts +70 -0
  58. package/src/bot/reauth-controller.ts +297 -0
  59. package/src/bot/registry.ts +186 -0
  60. package/src/bot/reply-context.ts +77 -0
  61. package/src/bot/session-fork.ts +35 -0
  62. package/src/bot/session-runtime.ts +1048 -0
  63. package/src/bot/telegram-io.ts +109 -0
  64. package/src/bot/typing.ts +35 -0
  65. package/src/bot/wizard/task-wizard.ts +214 -0
  66. package/src/cli.ts +126 -0
  67. package/src/config.ts +248 -0
  68. package/src/grok/client.ts +617 -0
  69. package/src/grok/models.ts +50 -0
  70. package/src/grok/session-log.ts +148 -0
  71. package/src/grok/transport.ts +51 -0
  72. package/src/grok/types.ts +136 -0
  73. package/src/index.ts +84 -0
  74. package/src/logger.ts +78 -0
  75. package/src/mcp/config.ts +120 -0
  76. package/src/mcp/probe.ts +218 -0
  77. package/src/mcp/types.ts +68 -0
  78. package/src/projects/manager.ts +99 -0
  79. package/src/render/chunk.ts +57 -0
  80. package/src/render/diff.ts +48 -0
  81. package/src/render/escape.ts +22 -0
  82. package/src/render/file-summary.ts +111 -0
  83. package/src/render/hashtags.ts +34 -0
  84. package/src/render/markdown.ts +130 -0
  85. package/src/render/progress-estimate.ts +63 -0
  86. package/src/render/progress.ts +80 -0
  87. package/src/render/subagent.ts +75 -0
  88. package/src/render/tool-call.ts +196 -0
  89. package/src/service/index.ts +24 -0
  90. package/src/service/linux.ts +85 -0
  91. package/src/service/macos.ts +101 -0
  92. package/src/service/platform.ts +64 -0
  93. package/src/service/types.ts +36 -0
  94. package/src/service/windows.ts +198 -0
  95. package/src/sessions/history.ts +225 -0
  96. package/src/sessions/process.ts +30 -0
  97. package/src/sessions/store.ts +133 -0
  98. package/src/sessions/tail.ts +86 -0
  99. package/src/sessions/types.ts +26 -0
  100. package/src/stream/streamer.ts +261 -0
  101. package/src/tasks/runner.ts +82 -0
  102. package/src/tasks/schedule.ts +142 -0
  103. package/src/tasks/scheduler.ts +53 -0
  104. package/src/tasks/store.ts +80 -0
  105. package/src/tasks/types.ts +33 -0
  106. package/tsconfig.json +19 -0
package/src/app/stt.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Speech-to-text via any OpenAI/Whisper-compatible endpoint.
3
+ *
4
+ * Language handling: when STT_LANGUAGE is unset, Whisper auto-detects the
5
+ * spoken language (covers English, Russian, Romanian/Moldovan, and ~100 more),
6
+ * so multilingual voice notes work out of the box.
7
+ */
8
+ import { createLogger } from "../logger.js";
9
+
10
+ const log = createLogger("stt");
11
+
12
+ export interface SttConfig {
13
+ apiUrl?: string;
14
+ apiKey?: string;
15
+ model: string;
16
+ language?: string;
17
+ }
18
+
19
+ export class SttService {
20
+ constructor(private readonly cfg: SttConfig) {}
21
+
22
+ get enabled(): boolean {
23
+ return Boolean(this.cfg.apiUrl);
24
+ }
25
+
26
+ /** Transcribe audio bytes; returns the recognized text (may be empty). */
27
+ async transcribe(bytes: Buffer, mimeType: string, filename: string): Promise<string> {
28
+ if (!this.cfg.apiUrl) throw new Error("STT is not configured (set STT_API_URL).");
29
+ const url = endpoint(this.cfg.apiUrl);
30
+
31
+ const form = new FormData();
32
+ form.append("file", new Blob([new Uint8Array(bytes)], { type: mimeType }), filename);
33
+ form.append("model", this.cfg.model);
34
+ if (this.cfg.language) form.append("language", this.cfg.language);
35
+
36
+ const headers: Record<string, string> = {};
37
+ if (this.cfg.apiKey) headers.Authorization = `Bearer ${this.cfg.apiKey}`;
38
+
39
+ const res = await fetch(url, { method: "POST", headers, body: form });
40
+ if (!res.ok) {
41
+ const detail = await res.text().catch(() => "");
42
+ throw new Error(`STT HTTP ${res.status}: ${detail.slice(0, 200)}`);
43
+ }
44
+ const data = (await res.json()) as { text?: string };
45
+ log.debug("transcribed", (data.text ?? "").length, "chars");
46
+ return (data.text ?? "").trim();
47
+ }
48
+ }
49
+
50
+ function endpoint(base: string): string {
51
+ const b = base.replace(/\/$/, "");
52
+ return b.endsWith("/audio/transcriptions") ? b : `${b}/audio/transcriptions`;
53
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared application types: per-chat settings, reasoning levels, and the
3
+ * prompt input model (text plus optional images) used across the bot.
4
+ */
5
+
6
+ export const REASONING_LEVELS = ["minimal", "low", "medium", "high", "max"] as const;
7
+ export type ReasoningEffort = (typeof REASONING_LEVELS)[number];
8
+
9
+ export interface ChatSettings {
10
+ projectPath?: string;
11
+ projectName?: string;
12
+ sessionId?: string;
13
+ agent?: string;
14
+ model?: string;
15
+ reasoning: ReasoningEffort;
16
+ /** Telegram message id of the pinned status panel, if any. */
17
+ statusMessageId?: number;
18
+ /** Sessions this chat controls (for multi-session switching). */
19
+ controlledSessions?: ControlledSession[];
20
+ /** Which controlled session is currently in the foreground. */
21
+ foregroundSessionId?: string;
22
+ }
23
+
24
+ export interface ControlledSession {
25
+ sessionId?: string;
26
+ projectPath: string;
27
+ projectName?: string;
28
+ }
29
+
30
+ export function defaultSettings(): ChatSettings {
31
+ return { reasoning: "medium" };
32
+ }
33
+
34
+ /** A decoded image to attach to a prompt as an ACP image content block. */
35
+ export interface PromptImage {
36
+ data: string; // base64-encoded bytes
37
+ mimeType: string;
38
+ }
39
+
40
+ /** A unit of work submitted to the agent: text plus optional images. */
41
+ export interface PromptInput {
42
+ text: string;
43
+ images: PromptImage[];
44
+ /** Telegram message id of the prompt, so the reply threads to it. */
45
+ replyTo?: number;
46
+ /**
47
+ * Content of the message the user was replying to (or the portion they
48
+ * quoted). Injected as context so the agent sees what the user is responding
49
+ * to. See {@link ../bot/reply-context.ts}.
50
+ */
51
+ quotedText?: string;
52
+ }
53
+
54
+ export function textPrompt(text: string, replyTo?: number, quotedText?: string): PromptInput {
55
+ return { text, images: [], replyTo, quotedText };
56
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Auto-updater — once an hour, asks npm for the latest published version with a
3
+ * single lightweight request. When a newer version exists AND the bot is fully
4
+ * idle (no in-flight prompt, no other active Grok session on the PC), it
5
+ * announces in chat, runs `npm install -g grok-telegram-bot@<latest>`, and
6
+ * restarts to apply. After the restart it posts the new version's CHANGELOG
7
+ * (tagged #update) so every release is easy to find in the conversation.
8
+ *
9
+ * Safety:
10
+ * • only ever updates when idle — never interrupts a running turn/task;
11
+ * • only for a global npm install (a cloned/source checkout is left alone);
12
+ * • restart is supervisor-aware: under systemd/launchd it exits cleanly and
13
+ * lets the supervisor relaunch; on Windows / foreground it re-execs itself.
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import { get } from "node:https";
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { JsonStore } from "./json-store.js";
20
+ import { createLogger } from "../logger.js";
21
+ import { extractChangelog, isNewer, isSafeVersion } from "./version.js";
22
+
23
+ const log = createLogger("updater");
24
+ const PKG = "grok-telegram-bot";
25
+
26
+ interface PendingUpdate {
27
+ from: string;
28
+ to: string;
29
+ chats: number[];
30
+ }
31
+
32
+ export interface UpdaterOptions {
33
+ enabled: boolean;
34
+ intervalMs: number;
35
+ projectRoot: string;
36
+ instanceDir: string;
37
+ dataDir: string;
38
+ /** True while the agent is busy (a chat turn or scheduled task). */
39
+ isPromptInFlight: () => boolean;
40
+ /** Active Grok sessions on this PC NOT owned by the bot's own agent. */
41
+ otherActiveSessions: () => number;
42
+ /** Send a plain or markdown message to every chat. */
43
+ announce: (text: string, markdown: boolean) => Promise<void>;
44
+ /** Stop polling + the agent before the process exits/re-execs. */
45
+ shutdown: () => Promise<void>;
46
+ }
47
+
48
+ export class Updater {
49
+ private timer: NodeJS.Timeout | undefined;
50
+ private readonly state: JsonStore<PendingUpdate | null>;
51
+ private readonly current: string;
52
+ private attempting = false;
53
+ private readonly tried = new Set<string>();
54
+
55
+ constructor(private readonly opts: UpdaterOptions) {
56
+ this.state = new JsonStore<PendingUpdate | null>(join(opts.dataDir, "update-state.json"), null);
57
+ this.current = readVersion(opts.projectRoot);
58
+ }
59
+
60
+ /** Announce a just-applied update (if any), then begin hourly checks. */
61
+ async start(): Promise<void> {
62
+ await this.announcePending();
63
+ if (!this.opts.enabled) {
64
+ log.info("auto-update disabled (AUTO_UPDATE=false)");
65
+ return;
66
+ }
67
+ if (!this.isNpmInstall()) {
68
+ log.info("running from source — auto-update is a no-op (use git to update)");
69
+ return;
70
+ }
71
+ // First check shortly after boot, then on the configured interval.
72
+ this.timer = setTimeout(() => void this.tick(), 60_000);
73
+ }
74
+
75
+ stop(): void {
76
+ if (this.timer) clearTimeout(this.timer);
77
+ this.timer = undefined;
78
+ }
79
+
80
+ private schedule(): void {
81
+ this.timer = setTimeout(() => void this.tick(), this.opts.intervalMs);
82
+ }
83
+
84
+ private async tick(): Promise<void> {
85
+ try {
86
+ await this.checkAndUpdate();
87
+ } catch (e) {
88
+ log.debug("update check failed:", (e as Error).message);
89
+ } finally {
90
+ this.schedule();
91
+ }
92
+ }
93
+
94
+ private async checkAndUpdate(): Promise<void> {
95
+ if (this.attempting) return;
96
+ const latest = await fetchLatestVersion();
97
+ if (!latest || !isSafeVersion(latest)) return;
98
+ if (!isNewer(latest, this.current)) return;
99
+ if (this.tried.has(latest)) return; // don't loop on a version we already tried
100
+
101
+ if (this.opts.isPromptInFlight() || this.opts.otherActiveSessions() > 0) {
102
+ log.info(`update ${this.current} -> ${latest} available; waiting for idle`);
103
+ return; // re-evaluated next interval
104
+ }
105
+ await this.applyUpdate(latest);
106
+ }
107
+
108
+ private async applyUpdate(latest: string): Promise<void> {
109
+ this.attempting = true;
110
+ this.tried.add(latest);
111
+ log.info(`updating ${this.current} -> ${latest}`);
112
+ await this.opts.announce(
113
+ `\u{1F504} #update Updating ${PKG} v${this.current} \u2192 v${latest}\u2026\nThe bot is idle, so it's safe \u2014 it will restart and report what changed.`,
114
+ false,
115
+ );
116
+
117
+ const ok = await npmInstall(latest);
118
+ if (!ok) {
119
+ this.attempting = false;
120
+ await this.opts.announce(
121
+ `\u26A0\uFE0F #update Update to v${latest} failed (\`npm install -g\`). I'll try again after the next restart.`,
122
+ false,
123
+ );
124
+ return;
125
+ }
126
+
127
+ this.state.set({ from: this.current, to: latest, chats: this.announceChats() });
128
+ await this.restart();
129
+ }
130
+
131
+ /** After a restart, post the new version's changelog (once), tagged #update. */
132
+ private async announcePending(): Promise<void> {
133
+ const pending = this.state.get();
134
+ if (!pending) return;
135
+ this.state.set(null); // consume regardless, so we never re-announce
136
+ if (pending.to !== this.current) {
137
+ log.warn(`pending update to ${pending.to} but running ${this.current}; skipping announce`);
138
+ return;
139
+ }
140
+ const notes = this.changelogFor(pending.to);
141
+ const body = notes
142
+ ? `\u{1F680} #update Updated v${pending.from} \u2192 **v${pending.to}**\n\n${notes}`
143
+ : `\u{1F680} #update Updated to **v${pending.to}**.`;
144
+ await this.opts.announce(body, true);
145
+ }
146
+
147
+ private async restart(): Promise<void> {
148
+ await this.opts.shutdown().catch(() => {});
149
+ // Under systemd/launchd, a clean exit triggers a managed relaunch (no double
150
+ // instance). On Windows / foreground there is no supervisor, so re-exec.
151
+ if (process.env.GROK_TG_SUPERVISED === "1") {
152
+ log.info("exiting for supervisor to relaunch the updated bot");
153
+ setTimeout(() => process.exit(0), 250);
154
+ return;
155
+ }
156
+ log.info("re-executing the updated bot");
157
+ const child = spawn(
158
+ process.execPath,
159
+ ["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir],
160
+ { detached: true, stdio: "ignore", cwd: this.opts.projectRoot, env: process.env },
161
+ );
162
+ child.unref();
163
+ setTimeout(() => process.exit(0), 500);
164
+ }
165
+
166
+ private isNpmInstall(): boolean {
167
+ return this.opts.projectRoot.replace(/\\/g, "/").includes("/node_modules/");
168
+ }
169
+
170
+ private announceChats(): number[] {
171
+ const pending = this.state.get();
172
+ return pending?.chats ?? [];
173
+ }
174
+
175
+ private changelogFor(version: string): string {
176
+ try {
177
+ const md = readFileSync(join(this.opts.projectRoot, "CHANGELOG.md"), "utf-8");
178
+ return extractChangelog(md, version);
179
+ } catch {
180
+ return "";
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Read the installed version from package.json (falls back to "0.0.0"). */
186
+ function readVersion(projectRoot: string): string {
187
+ try {
188
+ return (JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8")) as { version?: string }).version ?? "0.0.0";
189
+ } catch {
190
+ return "0.0.0";
191
+ }
192
+ }
193
+
194
+ /** One small HTTPS GET to the npm registry's dist-tag manifest for `latest`. */
195
+ function fetchLatestVersion(): Promise<string | undefined> {
196
+ return new Promise((resolve) => {
197
+ const req = get(
198
+ `https://registry.npmjs.org/${PKG}/latest`,
199
+ { timeout: 10_000, headers: { Accept: "application/json" } },
200
+ (res) => {
201
+ if (res.statusCode !== 200) {
202
+ res.resume();
203
+ resolve(undefined);
204
+ return;
205
+ }
206
+ let body = "";
207
+ res.setEncoding("utf-8");
208
+ res.on("data", (c) => (body += c));
209
+ res.on("end", () => {
210
+ try {
211
+ resolve((JSON.parse(body) as { version?: string }).version);
212
+ } catch {
213
+ resolve(undefined);
214
+ }
215
+ });
216
+ },
217
+ );
218
+ req.on("error", () => resolve(undefined));
219
+ req.on("timeout", () => {
220
+ req.destroy();
221
+ resolve(undefined);
222
+ });
223
+ });
224
+ }
225
+
226
+ /** Run `npm install -g grok-telegram-bot@<version>`; resolves true on success. */
227
+ function npmInstall(version: string): Promise<boolean> {
228
+ return new Promise((resolve) => {
229
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
230
+ const child = spawn(npm, ["install", "-g", `${PKG}@${version}`], { stdio: "ignore" });
231
+ child.on("error", () => resolve(false));
232
+ child.on("exit", (code) => resolve(code === 0));
233
+ });
234
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Account info for Grok Build. Grok signs in with your xAI account
3
+ * (`grok login`), so /usage surfaces the signed-in identity (from the token in
4
+ * ~/.grok/auth.json) plus the live per-session context usage the ACP agent
5
+ * reports.
6
+ */
7
+ import { hasLogin, identityFromAuth, loginLabel } from "./grok-credentials.js";
8
+
9
+ export interface AccountInfo {
10
+ /** Signed-in identity (email when the token carries one, else a label). */
11
+ email?: string;
12
+ /** Subscription/plan, when known. */
13
+ accountType?: string;
14
+ region?: string;
15
+ /** Stable identifier for matching saved accounts. */
16
+ startUrl?: string;
17
+ }
18
+
19
+ export class UsageService {
20
+ // Kept for signature compatibility; Grok state lives in ~/.grok/auth.json.
21
+ constructor(private readonly grokCliPath: string) {}
22
+
23
+ async account(): Promise<AccountInfo | undefined> {
24
+ if (!hasLogin()) {
25
+ // XAI_API_KEY with no browser login still counts as usable.
26
+ if (process.env.XAI_API_KEY?.trim()) return { email: "XAI_API_KEY", accountType: "api key" };
27
+ return undefined;
28
+ }
29
+ const id = identityFromAuth();
30
+ const label = loginLabel();
31
+ return { email: id.email || label, accountType: undefined, startUrl: label };
32
+ }
33
+
34
+ /** Whether Grok has a usable sign-in (browser token or XAI_API_KEY). */
35
+ async isLoggedIn(): Promise<boolean> {
36
+ return hasLogin();
37
+ }
38
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tiny semver helpers + CHANGELOG section extraction for the auto-updater.
3
+ * Pure and dependency-free so they're easy to test.
4
+ */
5
+
6
+ /** Parse the leading "X.Y.Z" of a version string (ignores pre-release tags). */
7
+ export function parseSemver(v: string): [number, number, number] {
8
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
9
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
10
+ }
11
+
12
+ /** True when `latest` is strictly greater than `current` (by major/minor/patch). */
13
+ export function isNewer(latest: string, current: string): boolean {
14
+ const a = parseSemver(latest);
15
+ const b = parseSemver(current);
16
+ for (let i = 0; i < 3; i++) {
17
+ if (a[i] !== b[i]) return a[i]! > b[i]!;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ /** A version string is a plain semver we'd trust to pass to `npm install`. */
23
+ export function isSafeVersion(v: string): boolean {
24
+ return /^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(v);
25
+ }
26
+
27
+ /** Extract the body of a CHANGELOG `## [version] …` section (markdown). */
28
+ export function extractChangelog(md: string, version: string): string {
29
+ const head = new RegExp(`^##\\s*\\[${version.replace(/\./g, "\\.")}\\]`);
30
+ const out: string[] = [];
31
+ let capturing = false;
32
+ for (const line of md.split("\n")) {
33
+ if (capturing && /^##\s*\[/.test(line)) break;
34
+ if (capturing) {
35
+ out.push(line);
36
+ continue;
37
+ }
38
+ if (head.test(line)) capturing = true;
39
+ }
40
+ return out.join("\n").trim();
41
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Auto-rotate-on-give-up. When a turn exhausts its retries (and auto-fork can't
3
+ * recover it), the runtime can cycle through the OTHER saved Grok accounts,
4
+ * retrying the same prompt on each — useful when the active account is
5
+ * throttled, out of quota, or its backend keeps returning "dispatch failure".
6
+ *
7
+ * The rotation is bounded to a SINGLE pass over the saved accounts (no infinite
8
+ * loop): each account is tried once; the first that succeeds wins and stays
9
+ * active, otherwise the runtime reports the error gathered from every account.
10
+ *
11
+ * Account switching is process-global (one machine → one active Grok login), so
12
+ * a rotation restarts the shared agent and affects every chat — intended, since
13
+ * the whole point is to move everyone onto a working login.
14
+ */
15
+ import type { GrokClient } from "../grok/client.js";
16
+ import type { AccountManager } from "../app/accounts.js";
17
+
18
+ export interface RotationTarget {
19
+ id: string;
20
+ label: string;
21
+ }
22
+
23
+ export interface AccountRotator {
24
+ /** Whether auto-rotate is switched on. */
25
+ enabled(): boolean;
26
+ /** Saved accounts to try, EXCLUDING the one that's currently active. */
27
+ targets(): Promise<RotationTarget[]>;
28
+ /** Make a saved account active (swap the sign-in + re-bind). Throws on error. */
29
+ activate(id: string): Promise<void>;
30
+ }
31
+
32
+ export class AccountRotatorImpl implements AccountRotator {
33
+ constructor(
34
+ private readonly accounts: AccountManager,
35
+ private readonly acp: GrokClient,
36
+ ) {}
37
+
38
+ enabled(): boolean {
39
+ return this.accounts.autoRotateEnabled();
40
+ }
41
+
42
+ async targets(): Promise<RotationTarget[]> {
43
+ const list = this.accounts.list();
44
+ const activeId = this.accounts.activeAccountId();
45
+ return list.filter((a) => a.id !== activeId).map((a) => ({ id: a.id, label: a.label }));
46
+ }
47
+
48
+ async activate(id: string): Promise<void> {
49
+ await this.accounts.switchTo(id);
50
+ await this.acp.restart();
51
+ }
52
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Authorization middleware: restricts the bot to ALLOWED_USERS when configured.
3
+ */
4
+ import type { Context, NextFunction } from "grammy";
5
+ import type { AppConfig } from "../config.js";
6
+ import { createLogger } from "../logger.js";
7
+
8
+ const log = createLogger("auth");
9
+
10
+ export function createAuthMiddleware(cfg: AppConfig) {
11
+ const allowAll = cfg.allowedUsers.size === 0;
12
+ if (allowAll) {
13
+ log.warn("ALLOWED_USERS is empty — the bot will respond to ANY Telegram user.");
14
+ }
15
+
16
+ return async (ctx: Context, next: NextFunction): Promise<void> => {
17
+ 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.
24
+ if (!from || from.is_bot) return;
25
+ const m = ctx.message ?? ctx.editedMessage;
26
+ if (m && (m.pinned_message || m.new_chat_members || m.left_chat_member)) return;
27
+
28
+ const userId = String(from.id);
29
+ if (allowAll || cfg.allowedUsers.has(userId)) {
30
+ await next();
31
+ return;
32
+ }
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.");
36
+ }
37
+ };
38
+ }