grok-telegram-bot 2.4.0 → 2.5.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.
- package/.env.example +38 -2
- package/CHANGELOG.md +119 -1
- package/README.md +58 -15
- package/docs/GROUP.md +225 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +12 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +122 -15
- package/src/bot/chat-controller.ts +52 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +207 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +101 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +8 -5
- package/src/bot/menu/ephemeral.ts +13 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +3 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-runtime.ts +647 -158
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +440 -0
- package/src/bot/telegram-bots.ts +495 -0
- package/src/bot/telegram-io.ts +94 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +651 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +16 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/session-comment.ts +64 -7
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +50 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +17 -6
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-chat settings persistence (project, agent, model,
|
|
3
|
-
* status message id
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
@@ -74,13 +84,14 @@ export function textPrompt(
|
|
|
74
84
|
text: string,
|
|
75
85
|
replyTo?: number,
|
|
76
86
|
quotedText?: string,
|
|
77
|
-
opts?: { skipSelfRecheck?: boolean },
|
|
87
|
+
opts?: { skipSelfRecheck?: boolean; promptId?: string },
|
|
78
88
|
): PromptInput {
|
|
79
89
|
return {
|
|
80
90
|
text,
|
|
81
91
|
images: [],
|
|
82
92
|
resourceLinks: [],
|
|
83
93
|
replyTo,
|
|
94
|
+
promptId: opts?.promptId,
|
|
84
95
|
quotedText,
|
|
85
96
|
skipSelfRecheck: opts?.skipSelfRecheck,
|
|
86
97
|
};
|
package/src/app/updater.ts
CHANGED
|
@@ -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
|
-
{
|
|
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,105 @@ import { createLogger } from "../logger.js";
|
|
|
8
15
|
const log = createLogger("auth");
|
|
9
16
|
|
|
10
17
|
export function createAuthMiddleware(cfg: AppConfig) {
|
|
11
|
-
|
|
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
|
|
19
|
-
//
|
|
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 (
|
|
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
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
if (
|
|
35
|
-
await
|
|
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
|
+
const extra = threadId !== undefined ? { message_thread_id: threadId as number } : {};
|
|
116
|
+
await ctx
|
|
117
|
+
.reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID to ALLOWED_USERS.", extra)
|
|
118
|
+
.catch(() => {});
|
|
119
|
+
}
|
package/src/bot/bot.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { Scheduler } from "../tasks/scheduler.js";
|
|
|
22
22
|
import { TaskStore } from "../tasks/store.js";
|
|
23
23
|
import { createAuthMiddleware } from "./auth.js";
|
|
24
24
|
import { isStaleCallbackError, safeCallbackMiddleware } from "./callback.js";
|
|
25
|
-
import { COMMANDS } from "./commands.js";
|
|
25
|
+
import { COMMANDS, GROUP_COMMANDS } from "./commands.js";
|
|
26
26
|
import { type BotDeps, MenuCache } from "./deps.js";
|
|
27
27
|
import { registerControl } from "./handlers/control.js";
|
|
28
28
|
import { registerDocuments } from "./handlers/document.js";
|
|
@@ -43,6 +43,7 @@ import { registerSystem } from "./handlers/system.js";
|
|
|
43
43
|
import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
|
|
44
44
|
import { registerUsage } from "./handlers/usage.js";
|
|
45
45
|
import { registerVoice } from "./handlers/voice.js";
|
|
46
|
+
import { registerForum } from "./handlers/forum.js";
|
|
46
47
|
import { StatusPanel } from "./menu/status-panel.js";
|
|
47
48
|
import { sendMarkdownDoc } from "./telegram-io.js";
|
|
48
49
|
import { Ephemeral } from "./menu/ephemeral.js";
|
|
@@ -50,6 +51,8 @@ import { BAR_LABELS } from "./menu/keyboard.js";
|
|
|
50
51
|
import { PermissionService } from "./permission-service.js";
|
|
51
52
|
import { RuntimeRegistry } from "./registry.js";
|
|
52
53
|
import { TaskWizard } from "./wizard/task-wizard.js";
|
|
54
|
+
import { ForumManager } from "../forum/manager.js";
|
|
55
|
+
import { TelegramBotService } from "./telegram-bots.js";
|
|
53
56
|
|
|
54
57
|
const log = createLogger("bot");
|
|
55
58
|
|
|
@@ -99,13 +102,44 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
99
102
|
const statusPanel = new StatusPanel(bot.api, settings, registry);
|
|
100
103
|
registry.setRefresher((chatId) => void statusPanel.refresh(chatId));
|
|
101
104
|
|
|
105
|
+
const projects = new ProjectManager(cfg.projectRoots);
|
|
106
|
+
const forum =
|
|
107
|
+
cfg.topicGroupId !== undefined
|
|
108
|
+
? new ForumManager(bot.api, cfg, projects)
|
|
109
|
+
: undefined;
|
|
110
|
+
|
|
111
|
+
// Sibling bots + memory/topic actions for the agent telegram JSON bridge.
|
|
112
|
+
const telegramBots = new TelegramBotService(bot.api, cfg);
|
|
113
|
+
telegramBots.attachToBot(bot);
|
|
114
|
+
registry.setBridge({
|
|
115
|
+
store,
|
|
116
|
+
forum,
|
|
117
|
+
bots: telegramBots,
|
|
118
|
+
// Cross-topic orchestration: General can create a topic and send_prompt there.
|
|
119
|
+
submitTopicPrompt: async ({ threadId, cwd, projectName, prompt, newSession }) => {
|
|
120
|
+
if (cfg.topicGroupId === undefined) {
|
|
121
|
+
throw new Error("TOPIC_GROUP_ID unset");
|
|
122
|
+
}
|
|
123
|
+
const groupId = cfg.topicGroupId;
|
|
124
|
+
const controller = registry.forumController(groupId, threadId, cwd, projectName);
|
|
125
|
+
if (newSession) {
|
|
126
|
+
const rt = await controller.addNew(cwd, projectName);
|
|
127
|
+
const outcome = await rt.submit(textPrompt(prompt));
|
|
128
|
+
return { outcome, sessionId: rt.sessionId };
|
|
129
|
+
}
|
|
130
|
+
const rt = controller.foreground();
|
|
131
|
+
const outcome = await rt.submit(textPrompt(prompt));
|
|
132
|
+
return { outcome, sessionId: rt.sessionId };
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
102
136
|
const deps: BotDeps = {
|
|
103
137
|
api: bot.api,
|
|
104
138
|
cfg,
|
|
105
139
|
acp,
|
|
106
140
|
registry,
|
|
107
141
|
store,
|
|
108
|
-
projects
|
|
142
|
+
projects,
|
|
109
143
|
menuCache: new MenuCache(),
|
|
110
144
|
settings,
|
|
111
145
|
statusPanel,
|
|
@@ -121,6 +155,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
121
155
|
}),
|
|
122
156
|
usage: new UsageService(cfg.grokCliPath),
|
|
123
157
|
accounts: new AccountManager(cfg.dataDir),
|
|
158
|
+
forum,
|
|
124
159
|
};
|
|
125
160
|
|
|
126
161
|
// Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
|
|
@@ -135,6 +170,11 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
135
170
|
onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
|
|
136
171
|
});
|
|
137
172
|
acp.permissionHandler = (p) => permissions.handle(p);
|
|
173
|
+
// /stop and /cancel must cancel pending interactive permissions for that
|
|
174
|
+
// session only (ACP requires cancelled outcomes) — never kill the agent.
|
|
175
|
+
acp.onSessionCancel = (sessionId) => {
|
|
176
|
+
permissions.cancelForSession(sessionId);
|
|
177
|
+
};
|
|
138
178
|
|
|
139
179
|
// The bot pins/unpins the status panel, and Telegram emits a "pinned a
|
|
140
180
|
// message" service message for each pin. Delete those so the chat stays clean
|
|
@@ -146,14 +186,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
146
186
|
// handler forgets (prevents the loading spinner + unhandled 400 noise).
|
|
147
187
|
bot.use(safeCallbackMiddleware());
|
|
148
188
|
|
|
149
|
-
// Keep history clean:
|
|
150
|
-
//
|
|
189
|
+
// Keep history clean: delete the user's command (/…) and persistent-bar
|
|
190
|
+
// button taps INSTANTLY (before handlers) so slow ACP/CLI work never leaves
|
|
191
|
+
// the raw slash sitting in chat. Handlers post bot status messages instead.
|
|
192
|
+
// Plain prompts are adopted separately (see prompt-anchor.ts).
|
|
151
193
|
bot.on("message:text", async (ctx, next) => {
|
|
152
|
-
await next();
|
|
153
194
|
const text = ctx.message?.text ?? "";
|
|
154
195
|
if (text.startsWith("/") || BAR_LABELS.includes(text)) {
|
|
155
|
-
|
|
196
|
+
void ctx.deleteMessage().catch(() => {});
|
|
156
197
|
}
|
|
198
|
+
await next();
|
|
157
199
|
});
|
|
158
200
|
|
|
159
201
|
bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
|
|
@@ -187,7 +229,10 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
187
229
|
bot.callbackQuery(/^sug:(\d+):(\d+)$/, async (ctx) => {
|
|
188
230
|
const batchId = Number(ctx.match![1]);
|
|
189
231
|
const index = Number(ctx.match![2]);
|
|
190
|
-
const
|
|
232
|
+
const { resolveScope } = await import("./scope.js");
|
|
233
|
+
const { adoptUserPrompt } = await import("./prompt-anchor.js");
|
|
234
|
+
const scope = resolveScope(ctx, deps);
|
|
235
|
+
const rt = scope.rt;
|
|
191
236
|
const text = rt.takeSuggestion(batchId, index);
|
|
192
237
|
if (!text) {
|
|
193
238
|
await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
|
|
@@ -197,17 +242,45 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
197
242
|
// Dim the keyboard so double-taps don't re-fire.
|
|
198
243
|
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
199
244
|
try {
|
|
200
|
-
const
|
|
245
|
+
const chatId = ctx.chat?.id;
|
|
246
|
+
const anchor =
|
|
247
|
+
chatId !== undefined
|
|
248
|
+
? await adoptUserPrompt(deps.api, {
|
|
249
|
+
chatId,
|
|
250
|
+
text,
|
|
251
|
+
userMessageIds: [],
|
|
252
|
+
messageThreadId: scope.threadExtra.message_thread_id,
|
|
253
|
+
projectName: rt.projectName,
|
|
254
|
+
prefix: "\u{1F4A1} Suggestion",
|
|
255
|
+
})
|
|
256
|
+
: undefined;
|
|
257
|
+
const outcome = await rt.submit(
|
|
258
|
+
textPrompt(text, anchor?.replyTo ?? ctx.callbackQuery.message?.message_id, undefined, {
|
|
259
|
+
promptId: anchor?.promptId,
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
201
262
|
if (outcome === "queued") {
|
|
202
|
-
|
|
263
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
264
|
+
if (anchor?.replyTo !== undefined) {
|
|
265
|
+
extra.reply_parameters = {
|
|
266
|
+
message_id: anchor.replyTo,
|
|
267
|
+
allow_sending_without_reply: true,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
await ctx
|
|
271
|
+
.reply(`\u{1F4E5} Queued suggestion (position ${rt.queueLength}).`, extra)
|
|
272
|
+
.catch(() => {});
|
|
203
273
|
}
|
|
204
274
|
} catch (e) {
|
|
205
|
-
await ctx
|
|
275
|
+
await ctx
|
|
276
|
+
.reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`, scope.threadExtra)
|
|
277
|
+
.catch(() => {});
|
|
206
278
|
}
|
|
207
279
|
});
|
|
208
280
|
|
|
209
281
|
registerMenu(bot, deps); // persistent-keyboard buttons (hears)
|
|
210
282
|
registerWizardInput(bot, deps); // wizard text input (before commands)
|
|
283
|
+
if (forum) registerForum(bot, deps, forum);
|
|
211
284
|
registerControl(bot, deps);
|
|
212
285
|
registerProjects(bot, deps);
|
|
213
286
|
registerSessions(bot, deps);
|
|
@@ -234,13 +307,40 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
234
307
|
log.debug("stale callback query:", err.error instanceof Error ? err.error.message : err.error);
|
|
235
308
|
return;
|
|
236
309
|
}
|
|
237
|
-
|
|
310
|
+
const e = err.error;
|
|
311
|
+
if (e instanceof Error) {
|
|
312
|
+
// Include Grammy error_code when present (429 / 403 / 409, etc.) for diagnosis.
|
|
313
|
+
const codeNum = (e as unknown as { error_code?: number }).error_code;
|
|
314
|
+
const code = typeof codeNum === "number" ? ` (code ${codeNum})` : "";
|
|
315
|
+
log.error(`unhandled bot error${code}:`, e.stack || e.message);
|
|
316
|
+
} else {
|
|
317
|
+
log.error("unhandled bot error:", e);
|
|
318
|
+
}
|
|
319
|
+
// Never rethrow — a middleware failure must not take down long polling.
|
|
238
320
|
});
|
|
239
321
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
322
|
+
// Scoped command menus: private = full sorted list; groups = short list with
|
|
323
|
+
// cancel/menu first (reply keyboard is unreliable in forum topics).
|
|
324
|
+
const registerCommands = async (
|
|
325
|
+
commands: typeof COMMANDS,
|
|
326
|
+
scope?: { type: string; chat_id?: number },
|
|
327
|
+
label = "default",
|
|
328
|
+
): Promise<void> => {
|
|
329
|
+
try {
|
|
330
|
+
await bot.api.setMyCommands(commands, scope ? { scope: scope as never } : undefined);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
log.warn(`setMyCommands (${label}) failed:`, (e as Error).message);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
await registerCommands(COMMANDS, undefined, "default");
|
|
336
|
+
await registerCommands(COMMANDS, { type: "all_private_chats" }, "private");
|
|
337
|
+
await registerCommands(GROUP_COMMANDS, { type: "all_group_chats" }, "groups");
|
|
338
|
+
if (cfg.topicGroupId !== undefined) {
|
|
339
|
+
await registerCommands(
|
|
340
|
+
GROUP_COMMANDS,
|
|
341
|
+
{ type: "chat", chat_id: cfg.topicGroupId },
|
|
342
|
+
`chat:${cfg.topicGroupId}`,
|
|
343
|
+
);
|
|
244
344
|
}
|
|
245
345
|
|
|
246
346
|
const updater = new Updater({
|
|
@@ -278,5 +378,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
278
378
|
// Remove any navigation surface left over from before a restart.
|
|
279
379
|
void deps.ephemeral.cleanupAll().catch(() => {});
|
|
280
380
|
|
|
381
|
+
// Forum project topics: ensure AI Chat + optional catalog topics (best-effort).
|
|
382
|
+
if (forum) {
|
|
383
|
+
void forum.ensureSetup().catch((e) => {
|
|
384
|
+
log.warn(`forum setup failed: ${(e as Error).message}`);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
281
388
|
return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
|
|
282
389
|
}
|