grok-telegram-bot 2.3.1 → 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 +64 -2
- package/CHANGELOG.md +156 -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/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +30 -2
- package/src/app/updater.ts +38 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +10 -0
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +154 -11
- package/src/bot/chat-controller.ts +82 -13
- package/src/bot/commands.ts +69 -27
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +22 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +58 -1
- 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/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +102 -61
- package/src/bot/handlers/message.ts +102 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +172 -16
- package/src/bot/handlers/session-card.ts +20 -0
- package/src/bot/handlers/sessions.ts +76 -15
- package/src/bot/handlers/usage.ts +118 -16
- 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 +54 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +25 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +7 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +1254 -83
- package/src/bot/suggestions.ts +489 -0
- 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 +242 -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 +214 -37
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +315 -30
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/hashtags.ts +5 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +318 -0
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +444 -162
- package/src/render/truncate.ts +85 -0
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +30 -6
- package/src/sessions/history.ts +98 -0
- package/src/sessions/process.ts +7 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +90 -15
- package/src/tasks/runner.ts +4 -3
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
|
@@ -11,6 +11,7 @@ import { SettingsStore } from "../app/settings-store.js";
|
|
|
11
11
|
import { SttService } from "../app/stt.js";
|
|
12
12
|
import { Updater } from "../app/updater.js";
|
|
13
13
|
import { UsageService } from "../app/usage.js";
|
|
14
|
+
import { textPrompt } from "../app/types.js";
|
|
14
15
|
import type { AppConfig } from "../config.js";
|
|
15
16
|
import { INSTANCE_DIR } from "../config.js";
|
|
16
17
|
import { createLogger } from "../logger.js";
|
|
@@ -21,11 +22,12 @@ import { Scheduler } from "../tasks/scheduler.js";
|
|
|
21
22
|
import { TaskStore } from "../tasks/store.js";
|
|
22
23
|
import { createAuthMiddleware } from "./auth.js";
|
|
23
24
|
import { isStaleCallbackError, safeCallbackMiddleware } from "./callback.js";
|
|
24
|
-
import { COMMANDS } from "./commands.js";
|
|
25
|
+
import { COMMANDS, GROUP_COMMANDS } from "./commands.js";
|
|
25
26
|
import { type BotDeps, MenuCache } from "./deps.js";
|
|
26
27
|
import { registerControl } from "./handlers/control.js";
|
|
27
28
|
import { registerDocuments } from "./handlers/document.js";
|
|
28
29
|
import { registerHistory } from "./handlers/history.js";
|
|
30
|
+
import { registerImportSession } from "./handlers/import-session.js";
|
|
29
31
|
import { registerKill } from "./handlers/kill.js";
|
|
30
32
|
import { registerMcp } from "./handlers/mcp.js";
|
|
31
33
|
import { registerMenu } from "./handlers/menu.js";
|
|
@@ -41,6 +43,7 @@ import { registerSystem } from "./handlers/system.js";
|
|
|
41
43
|
import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
|
|
42
44
|
import { registerUsage } from "./handlers/usage.js";
|
|
43
45
|
import { registerVoice } from "./handlers/voice.js";
|
|
46
|
+
import { registerForum } from "./handlers/forum.js";
|
|
44
47
|
import { StatusPanel } from "./menu/status-panel.js";
|
|
45
48
|
import { sendMarkdownDoc } from "./telegram-io.js";
|
|
46
49
|
import { Ephemeral } from "./menu/ephemeral.js";
|
|
@@ -48,6 +51,8 @@ import { BAR_LABELS } from "./menu/keyboard.js";
|
|
|
48
51
|
import { PermissionService } from "./permission-service.js";
|
|
49
52
|
import { RuntimeRegistry } from "./registry.js";
|
|
50
53
|
import { TaskWizard } from "./wizard/task-wizard.js";
|
|
54
|
+
import { ForumManager } from "../forum/manager.js";
|
|
55
|
+
import { TelegramBotService } from "./telegram-bots.js";
|
|
51
56
|
|
|
52
57
|
const log = createLogger("bot");
|
|
53
58
|
|
|
@@ -97,13 +102,44 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
97
102
|
const statusPanel = new StatusPanel(bot.api, settings, registry);
|
|
98
103
|
registry.setRefresher((chatId) => void statusPanel.refresh(chatId));
|
|
99
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
|
+
|
|
100
136
|
const deps: BotDeps = {
|
|
101
137
|
api: bot.api,
|
|
102
138
|
cfg,
|
|
103
139
|
acp,
|
|
104
140
|
registry,
|
|
105
141
|
store,
|
|
106
|
-
projects
|
|
142
|
+
projects,
|
|
107
143
|
menuCache: new MenuCache(),
|
|
108
144
|
settings,
|
|
109
145
|
statusPanel,
|
|
@@ -119,6 +155,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
119
155
|
}),
|
|
120
156
|
usage: new UsageService(cfg.grokCliPath),
|
|
121
157
|
accounts: new AccountManager(cfg.dataDir),
|
|
158
|
+
forum,
|
|
122
159
|
};
|
|
123
160
|
|
|
124
161
|
// Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
|
|
@@ -133,6 +170,11 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
133
170
|
onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
|
|
134
171
|
});
|
|
135
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
|
+
};
|
|
136
178
|
|
|
137
179
|
// The bot pins/unpins the status panel, and Telegram emits a "pinned a
|
|
138
180
|
// message" service message for each pin. Delete those so the chat stays clean
|
|
@@ -144,14 +186,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
144
186
|
// handler forgets (prevents the loading spinner + unhandled 400 noise).
|
|
145
187
|
bot.use(safeCallbackMiddleware());
|
|
146
188
|
|
|
147
|
-
// Keep history clean:
|
|
148
|
-
//
|
|
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).
|
|
149
193
|
bot.on("message:text", async (ctx, next) => {
|
|
150
|
-
await next();
|
|
151
194
|
const text = ctx.message?.text ?? "";
|
|
152
195
|
if (text.startsWith("/") || BAR_LABELS.includes(text)) {
|
|
153
|
-
|
|
196
|
+
void ctx.deleteMessage().catch(() => {});
|
|
154
197
|
}
|
|
198
|
+
await next();
|
|
155
199
|
});
|
|
156
200
|
|
|
157
201
|
bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
|
|
@@ -171,11 +215,76 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
171
215
|
if (sid) await switchAndShow(ctx, deps, sid);
|
|
172
216
|
});
|
|
173
217
|
|
|
218
|
+
// Legacy complexity buttons (removed — agent decides; auto-plan if complex).
|
|
219
|
+
bot.callbackQuery(/^cplx:(simple|complex)$/, async (ctx) => {
|
|
220
|
+
await ctx.answerCallbackQuery({ text: "Complexity is automatic now" });
|
|
221
|
+
await ctx
|
|
222
|
+
.editMessageText("\u2705 Complexity is decided by the agent automatically \u2014 just send your task.", {
|
|
223
|
+
reply_markup: { inline_keyboard: [] },
|
|
224
|
+
})
|
|
225
|
+
.catch(() => {});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Post-turn suggestion buttons on the Done message.
|
|
229
|
+
bot.callbackQuery(/^sug:(\d+):(\d+)$/, async (ctx) => {
|
|
230
|
+
const batchId = Number(ctx.match![1]);
|
|
231
|
+
const index = Number(ctx.match![2]);
|
|
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;
|
|
236
|
+
const text = rt.takeSuggestion(batchId, index);
|
|
237
|
+
if (!text) {
|
|
238
|
+
await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await ctx.answerCallbackQuery({ text: "Sending\u2026" });
|
|
242
|
+
// Dim the keyboard so double-taps don't re-fire.
|
|
243
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
244
|
+
try {
|
|
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
|
+
);
|
|
262
|
+
if (outcome === "queued") {
|
|
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(() => {});
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
await ctx
|
|
276
|
+
.reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`, scope.threadExtra)
|
|
277
|
+
.catch(() => {});
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
174
281
|
registerMenu(bot, deps); // persistent-keyboard buttons (hears)
|
|
175
282
|
registerWizardInput(bot, deps); // wizard text input (before commands)
|
|
283
|
+
if (forum) registerForum(bot, deps, forum);
|
|
176
284
|
registerControl(bot, deps);
|
|
177
285
|
registerProjects(bot, deps);
|
|
178
286
|
registerSessions(bot, deps);
|
|
287
|
+
registerImportSession(bot, deps);
|
|
179
288
|
registerSessionKill(bot, deps);
|
|
180
289
|
registerRunning(bot, deps);
|
|
181
290
|
registerHistory(bot, deps);
|
|
@@ -198,13 +307,40 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
198
307
|
log.debug("stale callback query:", err.error instanceof Error ? err.error.message : err.error);
|
|
199
308
|
return;
|
|
200
309
|
}
|
|
201
|
-
|
|
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.
|
|
202
320
|
});
|
|
203
321
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
);
|
|
208
344
|
}
|
|
209
345
|
|
|
210
346
|
const updater = new Updater({
|
|
@@ -242,5 +378,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
242
378
|
// Remove any navigation surface left over from before a restart.
|
|
243
379
|
void deps.ephemeral.cleanupAll().catch(() => {});
|
|
244
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
|
+
|
|
245
388
|
return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
|
|
246
389
|
}
|
|
@@ -23,6 +23,8 @@ export interface RunningSession {
|
|
|
23
23
|
unread: number;
|
|
24
24
|
/** Latest task-completion % (0–100) for this session, if known. */
|
|
25
25
|
progress?: number;
|
|
26
|
+
/** Card comment: last user prompt (+ last AI thinking while busy). */
|
|
27
|
+
comment?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface SwitchResult {
|
|
@@ -35,11 +37,31 @@ export interface SwitchResult {
|
|
|
35
37
|
alreadyForeground: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
export interface ChatControllerOpts {
|
|
41
|
+
/** Forum topic thread — all runtimes post into this topic. */
|
|
42
|
+
messageThreadId?: number;
|
|
43
|
+
/** Settings key (`chatId` or `chatId:t{thread}`). Defaults to String(chatId). */
|
|
44
|
+
settingsKey?: string;
|
|
45
|
+
/** Fixed project path (forum topics): never switch away via project picker. */
|
|
46
|
+
fixedCwd?: string;
|
|
47
|
+
fixedProjectName?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Optional Telegram bridge services attached to every runtime in this chat. */
|
|
51
|
+
export type ChatBridgeServices = SessionRuntime["bridge"];
|
|
52
|
+
|
|
38
53
|
export class ChatController {
|
|
39
54
|
private readonly runtimes: SessionRuntime[] = [];
|
|
40
55
|
private fg: SessionRuntime | undefined;
|
|
41
56
|
private readonly lastRead = new Map<string, number>();
|
|
42
57
|
private restored = false;
|
|
58
|
+
readonly settingsKey: string;
|
|
59
|
+
readonly messageThreadId: number | undefined;
|
|
60
|
+
readonly fixedCwd: string | undefined;
|
|
61
|
+
readonly fixedProjectName: string | undefined;
|
|
62
|
+
|
|
63
|
+
/** Telegram bridge (forum / memory / sibling bots); set by the registry. */
|
|
64
|
+
bridge?: ChatBridgeServices;
|
|
43
65
|
|
|
44
66
|
constructor(
|
|
45
67
|
private readonly api: Api,
|
|
@@ -51,14 +73,22 @@ export class ChatController {
|
|
|
51
73
|
private readonly refresh: (chatId: number) => void,
|
|
52
74
|
private readonly notifyActivity: (busy: boolean) => void,
|
|
53
75
|
private readonly getRotator?: () => AccountRotator | undefined,
|
|
54
|
-
|
|
76
|
+
opts?: ChatControllerOpts,
|
|
77
|
+
) {
|
|
78
|
+
this.messageThreadId = opts?.messageThreadId;
|
|
79
|
+
this.settingsKey = opts?.settingsKey ?? String(chatId);
|
|
80
|
+
this.fixedCwd = opts?.fixedCwd;
|
|
81
|
+
this.fixedProjectName = opts?.fixedProjectName;
|
|
82
|
+
}
|
|
55
83
|
|
|
56
84
|
/** The current foreground runtime (created/restored lazily). */
|
|
57
85
|
foreground(): SessionRuntime {
|
|
58
86
|
this.ensureRestored();
|
|
59
87
|
if (!this.fg) {
|
|
60
|
-
const s = this.settings.
|
|
61
|
-
const
|
|
88
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
89
|
+
const cwd = this.fixedCwd ?? s.projectPath ?? this.cfg.workspace;
|
|
90
|
+
const name = this.fixedProjectName ?? s.projectName;
|
|
91
|
+
const rt = this.create({ cwd, projectName: name, sessionId: s.sessionId });
|
|
62
92
|
this.runtimes.push(rt);
|
|
63
93
|
this.fg = rt;
|
|
64
94
|
}
|
|
@@ -76,6 +106,7 @@ export class ChatController {
|
|
|
76
106
|
foreground: rt.isForeground,
|
|
77
107
|
unread: this.unreadCount(rt),
|
|
78
108
|
progress: rt.taskProgress,
|
|
109
|
+
comment: rt.cardComment,
|
|
79
110
|
}));
|
|
80
111
|
}
|
|
81
112
|
|
|
@@ -142,6 +173,28 @@ export class ChatController {
|
|
|
142
173
|
return rt;
|
|
143
174
|
}
|
|
144
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Import a foreign session (Kiro / OpenCode / Claude / Codex) as a new
|
|
178
|
+
* controlled Grok session in the same project, primed with the full transcript.
|
|
179
|
+
* The new session becomes the foreground /running entry.
|
|
180
|
+
*/
|
|
181
|
+
async addImport(
|
|
182
|
+
cwd: string,
|
|
183
|
+
projectName: string | undefined,
|
|
184
|
+
priming: string,
|
|
185
|
+
): Promise<SessionRuntime> {
|
|
186
|
+
this.ensureRestored();
|
|
187
|
+
const prevFg = this.fg;
|
|
188
|
+
const rt = this.create({ cwd, projectName });
|
|
189
|
+
this.runtimes.push(rt);
|
|
190
|
+
this.fg = rt;
|
|
191
|
+
void this.background(prevFg);
|
|
192
|
+
await rt.startImportedSession(cwd, projectName, priming);
|
|
193
|
+
this.markSeen(rt);
|
|
194
|
+
this.persist();
|
|
195
|
+
return rt;
|
|
196
|
+
}
|
|
197
|
+
|
|
145
198
|
/**
|
|
146
199
|
* Connect to a session with resume-or-fork semantics (used by /sessions),
|
|
147
200
|
* adding it as a controlled session and bringing it to the foreground.
|
|
@@ -251,6 +304,13 @@ export class ChatController {
|
|
|
251
304
|
return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
|
|
252
305
|
}
|
|
253
306
|
|
|
307
|
+
/** Last user prompt (+ thinking when busy) for a controlled session id. */
|
|
308
|
+
commentFor(sessionId?: string): string | undefined {
|
|
309
|
+
if (!sessionId) return undefined;
|
|
310
|
+
this.ensureRestored();
|
|
311
|
+
return this.runtimes.find((r) => r.sessionId === sessionId)?.cardComment;
|
|
312
|
+
}
|
|
313
|
+
|
|
254
314
|
findBySession(sessionId: string): boolean {
|
|
255
315
|
return this.runtimes.some((r) => r.sessionId === sessionId);
|
|
256
316
|
}
|
|
@@ -266,27 +326,31 @@ export class ChatController {
|
|
|
266
326
|
private ensureRestored(): void {
|
|
267
327
|
if (this.restored) return;
|
|
268
328
|
this.restored = true;
|
|
269
|
-
const s = this.settings.
|
|
329
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
270
330
|
const seen = new Set<string>();
|
|
271
331
|
for (const cs of s.controlledSessions ?? []) {
|
|
272
332
|
if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
|
|
273
333
|
seen.add(cs.sessionId);
|
|
334
|
+
// Forum topics only restore sessions for the fixed project path.
|
|
335
|
+
if (this.fixedCwd && normPath(cs.projectPath) !== normPath(this.fixedCwd)) continue;
|
|
274
336
|
this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
|
|
275
337
|
}
|
|
276
338
|
// Lazy project switches persist projectPath without a sessionId. If the
|
|
277
339
|
// saved project is not among controlled sessions, recreate an unbound FG
|
|
278
340
|
// so a restart lands on the project the user last chose.
|
|
279
|
-
|
|
280
|
-
|
|
341
|
+
const homePath = this.fixedCwd ?? s.projectPath;
|
|
342
|
+
const homeName = this.fixedProjectName ?? s.projectName;
|
|
343
|
+
if (homePath) {
|
|
344
|
+
const key = normPath(homePath);
|
|
281
345
|
const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key);
|
|
282
346
|
if (!hasProject) {
|
|
283
|
-
this.runtimes.push(this.create({ cwd:
|
|
347
|
+
this.runtimes.push(this.create({ cwd: homePath, projectName: homeName, sessionId: s.sessionId }));
|
|
284
348
|
}
|
|
285
349
|
}
|
|
286
350
|
if (this.runtimes.length > 0) {
|
|
287
351
|
let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId);
|
|
288
|
-
if (!fg &&
|
|
289
|
-
const key = normPath(
|
|
352
|
+
if (!fg && homePath) {
|
|
353
|
+
const key = normPath(homePath);
|
|
290
354
|
fg = this.runtimes.find((r) => normPath(r.cwd) === key);
|
|
291
355
|
}
|
|
292
356
|
fg = fg ?? this.runtimes[0]!;
|
|
@@ -330,10 +394,15 @@ export class ChatController {
|
|
|
330
394
|
}
|
|
331
395
|
|
|
332
396
|
private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
|
|
333
|
-
const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings,
|
|
397
|
+
const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, {
|
|
398
|
+
...init,
|
|
399
|
+
messageThreadId: this.messageThreadId,
|
|
400
|
+
settingsKey: this.settingsKey,
|
|
401
|
+
});
|
|
334
402
|
rt.onStateChange = () => this.refresh(this.chatId);
|
|
335
403
|
rt.onActivity = (busy) => this.notifyActivity(busy);
|
|
336
404
|
rt.accountRotator = this.getRotator?.();
|
|
405
|
+
rt.bridge = this.bridge;
|
|
337
406
|
// A logical fork (auto-fork-on-error / lost-session recovery) swaps the
|
|
338
407
|
// runtime's session id in place — re-persist the controlled list with the
|
|
339
408
|
// new id and treat the fresh session as already-seen.
|
|
@@ -380,15 +449,15 @@ export class ChatController {
|
|
|
380
449
|
seen.add(r.sessionId);
|
|
381
450
|
controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
|
|
382
451
|
}
|
|
383
|
-
this.settings.
|
|
452
|
+
this.settings.updateKey(this.settingsKey, {
|
|
384
453
|
controlledSessions: controlled,
|
|
385
454
|
foregroundSessionId: this.fg?.sessionId,
|
|
386
455
|
// Keep the single-session restore fields aligned with the foreground so
|
|
387
456
|
// the pinned status panel and a fresh restore never show a project that
|
|
388
457
|
// belongs to a different (previously-foreground) session.
|
|
389
458
|
sessionId: this.fg?.sessionId,
|
|
390
|
-
projectPath: this.fg?.cwd,
|
|
391
|
-
projectName: this.fg?.projectName,
|
|
459
|
+
projectPath: this.fixedCwd ?? this.fg?.cwd,
|
|
460
|
+
projectName: this.fixedProjectName ?? this.fg?.projectName,
|
|
392
461
|
});
|
|
393
462
|
}
|
|
394
463
|
}
|