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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manage a Telegram forum group: AI Chat topic + optional one-topic-per-project
|
|
3
|
+
* setup, path binding for user-created topics, and best-effort icon pins.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import type { Api } from "grammy";
|
|
8
|
+
import { InputFile } from "grammy";
|
|
9
|
+
import type { AppConfig } from "../config.js";
|
|
10
|
+
import { withTelegramRetry } from "../bot/telegram-io.js";
|
|
11
|
+
import { createLogger } from "../logger.js";
|
|
12
|
+
import type { ProjectEntry, ProjectManager } from "../projects/manager.js";
|
|
13
|
+
import { resolveBindTarget } from "./bind-path.js";
|
|
14
|
+
import { discoverProjectIcon } from "./project-icon.js";
|
|
15
|
+
import { TopicStore } from "./topic-store.js";
|
|
16
|
+
import type { ForumTopicBinding } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const log = createLogger("forum");
|
|
19
|
+
|
|
20
|
+
/** Telegram-allowed topic icon colors (Bot API enum). */
|
|
21
|
+
const TOPIC_ICON_COLORS = [0x6fb9f0, 0xffd67e, 0xcb86db, 0x8eee98, 0xff93b2, 0xfb6f5f] as const;
|
|
22
|
+
type TopicIconColor = (typeof TOPIC_ICON_COLORS)[number];
|
|
23
|
+
const DEFAULT_ICON_COLOR: TopicIconColor = 0x6fb9f0;
|
|
24
|
+
|
|
25
|
+
/** Pace createForumTopic + announce to stay under Telegram flood limits. */
|
|
26
|
+
const INTER_TOPIC_DELAY_MS = 500;
|
|
27
|
+
/** Generous 429 budget for bulk setup of 1000+ projects. */
|
|
28
|
+
const SETUP_RATE_LIMIT_RETRIES = 15;
|
|
29
|
+
const SETUP_NETWORK_RETRIES = 5;
|
|
30
|
+
const PROGRESS_EVERY = 25;
|
|
31
|
+
|
|
32
|
+
/** Why forum topic management is off (null when ready). */
|
|
33
|
+
export type ForumDisabledReason =
|
|
34
|
+
| "access_error"
|
|
35
|
+
| "not_supergroup"
|
|
36
|
+
| "not_admin"
|
|
37
|
+
| "no_manage_topics"
|
|
38
|
+
| "not_forum"
|
|
39
|
+
| "forums_disabled";
|
|
40
|
+
|
|
41
|
+
export class ForumManager {
|
|
42
|
+
readonly store: TopicStore;
|
|
43
|
+
private setupPromise: Promise<void> | undefined;
|
|
44
|
+
/** True only after a successful probe (admin + forum topics usable). */
|
|
45
|
+
private ready = false;
|
|
46
|
+
private disabledReason: ForumDisabledReason | null = "access_error";
|
|
47
|
+
private disabledDetail: string | null = "setup not run yet";
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly api: Api,
|
|
51
|
+
private readonly cfg: AppConfig,
|
|
52
|
+
private readonly projects: ProjectManager,
|
|
53
|
+
) {
|
|
54
|
+
this.store = new TopicStore(cfg.dataDir, cfg.topicGroupId!);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get groupId(): number {
|
|
58
|
+
return this.cfg.topicGroupId!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Topic management is active for this group (admin + forum enabled). */
|
|
62
|
+
get isReady(): boolean {
|
|
63
|
+
return this.ready;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Human-readable readiness for logs / /forum_setup. */
|
|
67
|
+
getStatusText(): string {
|
|
68
|
+
if (this.ready) {
|
|
69
|
+
return `ready (${this.store.all().length} topic(s) mapped)`;
|
|
70
|
+
}
|
|
71
|
+
const code = this.disabledReason ?? "access_error";
|
|
72
|
+
const detail = this.disabledDetail ? `: ${this.disabledDetail}` : "";
|
|
73
|
+
return `disabled (${code})${detail}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when this chat id is the configured group and setup succeeded. */
|
|
77
|
+
isActiveForumChat(chatId: number): boolean {
|
|
78
|
+
return this.ready && chatId === this.groupId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Mark forum features off (not admin / no topics / etc.). */
|
|
82
|
+
markDisabled(reason: ForumDisabledReason, detail: string): void {
|
|
83
|
+
this.ready = false;
|
|
84
|
+
this.disabledReason = reason;
|
|
85
|
+
this.disabledDetail = detail;
|
|
86
|
+
log.warn(`forum management ignored for group ${this.groupId}: [${reason}] ${detail}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Verify access and ensure AI chat + optional project topics. Idempotent. */
|
|
90
|
+
async ensureSetup(): Promise<void> {
|
|
91
|
+
if (this.setupPromise) return this.setupPromise;
|
|
92
|
+
this.setupPromise = this.runSetup().finally(() => {
|
|
93
|
+
this.setupPromise = undefined;
|
|
94
|
+
});
|
|
95
|
+
return this.setupPromise;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async runSetup(): Promise<void> {
|
|
99
|
+
const chatId = this.groupId;
|
|
100
|
+
const probe = await this.probeGroup(chatId);
|
|
101
|
+
if (!probe.ok) {
|
|
102
|
+
this.markDisabled(probe.reason, probe.detail);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Usable as a forum group — handlers may use topics while bulk create runs.
|
|
107
|
+
this.ready = true;
|
|
108
|
+
this.disabledReason = null;
|
|
109
|
+
this.disabledDetail = null;
|
|
110
|
+
log.info(`forum group ${chatId} ready (admin + topics enabled)`);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await this.ensureAiChatTopic();
|
|
114
|
+
if (this.cfg.topicAutoCreateProjects) {
|
|
115
|
+
await this.ensureProjectTopics(this.projects.listAll());
|
|
116
|
+
}
|
|
117
|
+
this.store.setLastSetup();
|
|
118
|
+
log.info(`forum setup complete for group ${chatId} (${this.store.all().length} topics mapped)`);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// Keep ready if probe passed — individual create failures are logged inside.
|
|
121
|
+
const msg = (e as Error).message ?? String(e);
|
|
122
|
+
if (isForumsDisabledError(e)) {
|
|
123
|
+
this.markDisabled("forums_disabled", msg);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
log.warn(`forum setup partially failed for ${chatId}: ${msg}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check admin rights and forum mode. If Topics are off and the bot is admin,
|
|
132
|
+
* try to enable them (Bot API has no official method — best-effort raw calls).
|
|
133
|
+
* On failure, the group is ignored for forum features.
|
|
134
|
+
*/
|
|
135
|
+
private async probeGroup(
|
|
136
|
+
chatId: number,
|
|
137
|
+
): Promise<{ ok: true } | { ok: false; reason: ForumDisabledReason; detail: string }> {
|
|
138
|
+
let chat: Awaited<ReturnType<Api["getChat"]>>;
|
|
139
|
+
try {
|
|
140
|
+
chat = await this.api.getChat(chatId);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
reason: "access_error",
|
|
145
|
+
detail: `cannot getChat: ${(e as Error).message}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const chatType = (chat as { type?: string }).type;
|
|
150
|
+
if (chatType !== "supergroup") {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: "not_supergroup",
|
|
154
|
+
detail: `chat type is "${chatType ?? "unknown"}" (need a supergroup with Topics)`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let me: { id: number };
|
|
159
|
+
let member: { status: string; can_manage_topics?: boolean };
|
|
160
|
+
try {
|
|
161
|
+
me = await this.api.getMe();
|
|
162
|
+
member = (await this.api.getChatMember(chatId, me.id)) as {
|
|
163
|
+
status: string;
|
|
164
|
+
can_manage_topics?: boolean;
|
|
165
|
+
};
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: "access_error",
|
|
170
|
+
detail: `cannot getChatMember: ${(e as Error).message}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const status = member.status;
|
|
175
|
+
if (status !== "administrator" && status !== "creator") {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: "not_admin",
|
|
179
|
+
detail: `bot status is "${status}" — make the bot admin with Manage Topics, then /forum_setup`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Creator always has topic rights; admins need can_manage_topics.
|
|
184
|
+
if (status === "administrator" && member.can_manage_topics === false) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: "no_manage_topics",
|
|
188
|
+
detail: "bot is admin but can_manage_topics=false — enable Manage Topics for the bot",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let isForum = Boolean((chat as { is_forum?: boolean }).is_forum);
|
|
193
|
+
if (!isForum) {
|
|
194
|
+
log.info(`group ${chatId} has Topics off — attempting to enable forum mode…`);
|
|
195
|
+
const enabled = await this.tryEnableForumTopics(chatId);
|
|
196
|
+
if (enabled) {
|
|
197
|
+
isForum = true;
|
|
198
|
+
log.info(`forum topics enabled on group ${chatId}`);
|
|
199
|
+
} else {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
reason: "not_forum",
|
|
203
|
+
detail:
|
|
204
|
+
"Topics are disabled and could not be enabled via Bot API. " +
|
|
205
|
+
"In Telegram: Group settings → Topics → enable, then /forum_setup. " +
|
|
206
|
+
"Ignoring this group for project topics until then.",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { ok: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Best-effort enable Topics on a supergroup.
|
|
216
|
+
* Official Bot API does not document this (MTProto channels.toggleForum is
|
|
217
|
+
* owner-only). We still try known raw method names so a future API works
|
|
218
|
+
* without a bot change; failure is expected today.
|
|
219
|
+
*/
|
|
220
|
+
private async tryEnableForumTopics(chatId: number): Promise<boolean> {
|
|
221
|
+
// grammy api.raw is a proxy: any method name is callable and hits Telegram.
|
|
222
|
+
// Official Bot API has no enable-Topics method; these fail with "method not
|
|
223
|
+
// found" today and succeed only if Telegram adds them later.
|
|
224
|
+
const attempts: Array<{ method: string; args: Record<string, unknown> }> = [
|
|
225
|
+
{ method: "setChatForum", args: { chat_id: chatId, is_forum: true } },
|
|
226
|
+
{ method: "toggleForum", args: { chat_id: chatId, enabled: true } },
|
|
227
|
+
{ method: "setChatIsForum", args: { chat_id: chatId, is_forum: true } },
|
|
228
|
+
];
|
|
229
|
+
const raw = this.api.raw as unknown as Record<
|
|
230
|
+
string,
|
|
231
|
+
(args: Record<string, unknown>) => Promise<unknown>
|
|
232
|
+
>;
|
|
233
|
+
|
|
234
|
+
for (const { method, args } of attempts) {
|
|
235
|
+
try {
|
|
236
|
+
await raw[method]!(args);
|
|
237
|
+
const chat = await this.api.getChat(chatId);
|
|
238
|
+
if (Boolean((chat as { is_forum?: boolean }).is_forum)) {
|
|
239
|
+
log.info(`enabled Topics on ${chatId} via raw ${method}`);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
} catch (e) {
|
|
243
|
+
log.debug(`enable Topics via ${method} failed: ${(e as Error).message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Ensure the default "chat with AI" topic exists (workspace cwd). */
|
|
250
|
+
async ensureAiChatTopic(): Promise<ForumTopicBinding> {
|
|
251
|
+
const existing = this.store.findAiChat();
|
|
252
|
+
if (existing) return existing;
|
|
253
|
+
|
|
254
|
+
const name = this.cfg.topicAiChatName;
|
|
255
|
+
// Reuse a stored topic with the same name if present.
|
|
256
|
+
const byName = this.store.all().find((t) => t.name.toLowerCase() === name.toLowerCase());
|
|
257
|
+
if (byName) {
|
|
258
|
+
const bound = this.store.bindProject(byName.threadId, this.cfg.workspace, name, "ai_chat");
|
|
259
|
+
return bound;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const topic = await this.createTopic(name, DEFAULT_ICON_COLOR);
|
|
263
|
+
const binding = this.store.bindProject(topic.message_thread_id, this.cfg.workspace, name, "ai_chat");
|
|
264
|
+
await this.tgCall(
|
|
265
|
+
() =>
|
|
266
|
+
this.api.sendMessage(
|
|
267
|
+
this.groupId,
|
|
268
|
+
`\u{1F916} **AI Chat** — general conversation (workspace).\nPath: \`${this.cfg.workspace}\``,
|
|
269
|
+
{ message_thread_id: topic.message_thread_id, parse_mode: "Markdown" },
|
|
270
|
+
),
|
|
271
|
+
"ai-chat-announce",
|
|
272
|
+
).catch(() => {});
|
|
273
|
+
return binding;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create missing topics for catalog projects. Resumable: skips paths already
|
|
278
|
+
* mapped. Uses pacing + 429/network retries so 1000+ projects can complete.
|
|
279
|
+
*/
|
|
280
|
+
async ensureProjectTopics(projects: ProjectEntry[]): Promise<void> {
|
|
281
|
+
let created = 0;
|
|
282
|
+
let skipped = 0;
|
|
283
|
+
let failed = 0;
|
|
284
|
+
const total = projects.length;
|
|
285
|
+
log.info(`ensuring project topics: ${total} catalog project(s)`);
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < projects.length; i++) {
|
|
288
|
+
// Demotion / markDisabled mid-run should stop creating topics.
|
|
289
|
+
if (!this.ready) {
|
|
290
|
+
log.warn("aborting remaining project topics — forum no longer ready");
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
const p = projects[i]!;
|
|
294
|
+
if (this.store.findByProjectPath(p.path)) {
|
|
295
|
+
skipped++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await this.createProjectTopic(p);
|
|
300
|
+
created++;
|
|
301
|
+
if (created % PROGRESS_EVERY === 0 || i + 1 === total) {
|
|
302
|
+
log.info(
|
|
303
|
+
`project topics progress: created=${created} skipped=${skipped} failed=${failed} ` +
|
|
304
|
+
`processed=${i + 1}/${total}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
// Pace even after success so we rarely hit flood limits.
|
|
308
|
+
if (i + 1 < projects.length) await sleep(INTER_TOPIC_DELAY_MS);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
failed++;
|
|
311
|
+
log.warn(`create topic for ${p.name} failed: ${(e as Error).message}`);
|
|
312
|
+
if (isForumsDisabledError(e)) {
|
|
313
|
+
this.markDisabled("forums_disabled", (e as Error).message);
|
|
314
|
+
log.warn("aborting remaining project topics — forum mode no longer available");
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
// Still pause after errors (especially non-429) to avoid a tight fail loop.
|
|
318
|
+
if (i + 1 < projects.length) await sleep(INTER_TOPIC_DELAY_MS);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
log.info(
|
|
323
|
+
`project topics done: created=${created} skipped=${skipped} failed=${failed} catalog=${total}`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async createProjectTopic(project: ProjectEntry): Promise<ForumTopicBinding> {
|
|
328
|
+
const existing = this.store.findByProjectPath(project.path);
|
|
329
|
+
if (existing) return existing;
|
|
330
|
+
|
|
331
|
+
const iconPath = discoverProjectIcon(project.path);
|
|
332
|
+
const topic = await this.createTopic(clampTopicName(project.name), DEFAULT_ICON_COLOR);
|
|
333
|
+
const binding: ForumTopicBinding = {
|
|
334
|
+
threadId: topic.message_thread_id,
|
|
335
|
+
name: project.name,
|
|
336
|
+
kind: "project",
|
|
337
|
+
projectPath: project.path,
|
|
338
|
+
iconPath,
|
|
339
|
+
updatedAt: Date.now(),
|
|
340
|
+
};
|
|
341
|
+
this.store.upsert(binding);
|
|
342
|
+
await this.announceProjectTopic(binding);
|
|
343
|
+
return binding;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Agent/user request: create a new forum topic, optionally bind a project.
|
|
348
|
+
* Path rules match manual bind (absolute path or exact catalog name only).
|
|
349
|
+
*/
|
|
350
|
+
async createBoundTopic(
|
|
351
|
+
name: string,
|
|
352
|
+
projectPath?: string,
|
|
353
|
+
): Promise<
|
|
354
|
+
| { ok: true; binding: ForumTopicBinding }
|
|
355
|
+
| { ok: false; error: string }
|
|
356
|
+
> {
|
|
357
|
+
if (!this.ready) {
|
|
358
|
+
return { ok: false, error: `forum not ready (${this.getStatusText()})` };
|
|
359
|
+
}
|
|
360
|
+
const title = clampTopicName(name);
|
|
361
|
+
if (!title.trim()) return { ok: false, error: "topic name is empty" };
|
|
362
|
+
|
|
363
|
+
let resolvedPath: string | undefined;
|
|
364
|
+
let kind: ForumTopicBinding["kind"] = "unbound";
|
|
365
|
+
let pathCreated = false;
|
|
366
|
+
if (projectPath?.trim()) {
|
|
367
|
+
// Agent new-project flow: absolute paths that do not exist yet are created.
|
|
368
|
+
const resolved = resolveBindTarget(projectPath, {
|
|
369
|
+
existsSync,
|
|
370
|
+
isDirectory: (p) => this.projects.isDirectory(p),
|
|
371
|
+
findExactByName: (n) => this.projects.findExactByName(n),
|
|
372
|
+
createIfMissing: true,
|
|
373
|
+
mkdirSync: (p) => mkdirSync(p, { recursive: true }),
|
|
374
|
+
defaultRoot: this.cfg.projectRoots[0],
|
|
375
|
+
});
|
|
376
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
377
|
+
const already = this.store.findByProjectPath(resolved.path);
|
|
378
|
+
if (already) {
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
error: `Project already bound to topic #${already.threadId} (${already.name})`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
resolvedPath = resolved.path;
|
|
385
|
+
pathCreated = !!resolved.created;
|
|
386
|
+
kind = "project";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const topic = await this.createTopic(title, DEFAULT_ICON_COLOR);
|
|
391
|
+
if (resolvedPath) {
|
|
392
|
+
const iconPath = discoverProjectIcon(resolvedPath);
|
|
393
|
+
const binding = this.store.bindProject(
|
|
394
|
+
topic.message_thread_id,
|
|
395
|
+
resolvedPath,
|
|
396
|
+
title,
|
|
397
|
+
kind,
|
|
398
|
+
);
|
|
399
|
+
if (iconPath) {
|
|
400
|
+
binding.iconPath = iconPath;
|
|
401
|
+
this.store.upsert(binding);
|
|
402
|
+
}
|
|
403
|
+
await this.announceProjectTopic(binding, pathCreated);
|
|
404
|
+
return { ok: true, binding };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const binding: ForumTopicBinding = {
|
|
408
|
+
threadId: topic.message_thread_id,
|
|
409
|
+
name: title,
|
|
410
|
+
kind: "unbound",
|
|
411
|
+
projectPath: null,
|
|
412
|
+
updatedAt: Date.now(),
|
|
413
|
+
};
|
|
414
|
+
this.store.upsert(binding);
|
|
415
|
+
this.store.markPending(topic.message_thread_id);
|
|
416
|
+
await this.tgCall(
|
|
417
|
+
() =>
|
|
418
|
+
this.api.sendMessage(
|
|
419
|
+
this.groupId,
|
|
420
|
+
`\u{1F4CC} **${title}**\n\nTopic created by the agent. Send an absolute project path or exact catalog name to bind it.`,
|
|
421
|
+
{ message_thread_id: topic.message_thread_id, parse_mode: "Markdown" },
|
|
422
|
+
),
|
|
423
|
+
`agent-topic-announce:${topic.message_thread_id}`,
|
|
424
|
+
).catch(() => {});
|
|
425
|
+
return { ok: true, binding };
|
|
426
|
+
} catch (e) {
|
|
427
|
+
const msg = (e as Error).message ?? String(e);
|
|
428
|
+
if (isForumsDisabledError(e)) {
|
|
429
|
+
this.markDisabled("forums_disabled", msg);
|
|
430
|
+
}
|
|
431
|
+
return { ok: false, error: msg };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async createTopic(
|
|
436
|
+
name: string,
|
|
437
|
+
iconColor: TopicIconColor = DEFAULT_ICON_COLOR,
|
|
438
|
+
): Promise<{ message_thread_id: number; name: string }> {
|
|
439
|
+
const res = await this.tgCall(
|
|
440
|
+
() =>
|
|
441
|
+
this.api.createForumTopic(this.groupId, name, {
|
|
442
|
+
icon_color: iconColor,
|
|
443
|
+
}),
|
|
444
|
+
`createTopic:${name}`,
|
|
445
|
+
);
|
|
446
|
+
return { message_thread_id: res.message_thread_id, name: res.name };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private async announceProjectTopic(
|
|
450
|
+
binding: ForumTopicBinding,
|
|
451
|
+
pathCreated = false,
|
|
452
|
+
): Promise<void> {
|
|
453
|
+
const chatId = this.groupId;
|
|
454
|
+
const threadId = binding.threadId;
|
|
455
|
+
const createdLine = pathCreated ? `\nFolder created on disk.\n` : `\n`;
|
|
456
|
+
const caption =
|
|
457
|
+
`\u{1F4C1} **${binding.name}**\n` +
|
|
458
|
+
`Project path: \`${binding.projectPath}\`` +
|
|
459
|
+
createdLine +
|
|
460
|
+
`Messages in this topic run Grok sessions in that directory.`;
|
|
461
|
+
try {
|
|
462
|
+
if (binding.iconPath && existsSync(binding.iconPath)) {
|
|
463
|
+
await this.tgCall(
|
|
464
|
+
() =>
|
|
465
|
+
this.api.sendPhoto(chatId, new InputFile(binding.iconPath!), {
|
|
466
|
+
message_thread_id: threadId,
|
|
467
|
+
caption,
|
|
468
|
+
parse_mode: "Markdown",
|
|
469
|
+
}),
|
|
470
|
+
`announce-photo:${threadId}`,
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
await this.tgCall(
|
|
474
|
+
() =>
|
|
475
|
+
this.api.sendMessage(chatId, caption, {
|
|
476
|
+
message_thread_id: threadId,
|
|
477
|
+
parse_mode: "Markdown",
|
|
478
|
+
}),
|
|
479
|
+
`announce-msg:${threadId}`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
} catch (e) {
|
|
483
|
+
log.debug(`announce topic ${threadId} failed: ${(e as Error).message}`);
|
|
484
|
+
await this.tgCall(
|
|
485
|
+
() =>
|
|
486
|
+
this.api.sendMessage(chatId, `Project: ${binding.projectPath}`, {
|
|
487
|
+
message_thread_id: threadId,
|
|
488
|
+
}),
|
|
489
|
+
`announce-fallback:${threadId}`,
|
|
490
|
+
).catch(() => {});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private tgCall<T>(fn: () => Promise<T>, label: string): Promise<T> {
|
|
495
|
+
return withTelegramRetry(fn, {
|
|
496
|
+
maxRateLimitRetries: SETUP_RATE_LIMIT_RETRIES,
|
|
497
|
+
maxNetworkRetries: SETUP_NETWORK_RETRIES,
|
|
498
|
+
label: `forum:${label}`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Register a user-created topic we have not seen. Marks it unbound and asks
|
|
504
|
+
* for a project path on first message (caller sends the ask) unless auto-bound.
|
|
505
|
+
* If the thread was noted earlier without a real title, refresh the name.
|
|
506
|
+
*/
|
|
507
|
+
noteUserTopic(threadId: number, name: string): ForumTopicBinding {
|
|
508
|
+
const prev = this.store.get(threadId);
|
|
509
|
+
const title = (name || "").trim() || `Topic ${threadId}`;
|
|
510
|
+
if (prev) {
|
|
511
|
+
if (!prev.projectPath && title !== prev.name) {
|
|
512
|
+
const next: ForumTopicBinding = { ...prev, name: title, updatedAt: Date.now() };
|
|
513
|
+
this.store.upsert(next);
|
|
514
|
+
this.store.markPending(threadId);
|
|
515
|
+
return next;
|
|
516
|
+
}
|
|
517
|
+
return prev;
|
|
518
|
+
}
|
|
519
|
+
const binding: ForumTopicBinding = {
|
|
520
|
+
threadId,
|
|
521
|
+
name: title,
|
|
522
|
+
kind: "unbound",
|
|
523
|
+
projectPath: null,
|
|
524
|
+
updatedAt: Date.now(),
|
|
525
|
+
};
|
|
526
|
+
this.store.upsert(binding);
|
|
527
|
+
this.store.markPending(threadId);
|
|
528
|
+
return binding;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* If the topic title exactly matches a catalog project name (and that path
|
|
533
|
+
* is not already mapped to another thread), bind and announce.
|
|
534
|
+
*/
|
|
535
|
+
async tryAutoBindByTopicName(threadId: number, topicName: string): Promise<AutoBindResult> {
|
|
536
|
+
const prev = this.store.get(threadId);
|
|
537
|
+
if (prev?.projectPath) return { status: "bound", binding: prev };
|
|
538
|
+
|
|
539
|
+
const hit = this.projects.findExactByName(topicName);
|
|
540
|
+
if (!hit) return { status: "no_match" };
|
|
541
|
+
|
|
542
|
+
const already = this.store.findByProjectPath(hit.path);
|
|
543
|
+
if (already && already.threadId !== threadId) {
|
|
544
|
+
log.info(
|
|
545
|
+
`topic "${topicName}" (#${threadId}) matches project ${hit.path} but path already bound to #${already.threadId}`,
|
|
546
|
+
);
|
|
547
|
+
return {
|
|
548
|
+
status: "already_bound",
|
|
549
|
+
projectPath: hit.path,
|
|
550
|
+
otherThreadId: already.threadId,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const iconPath = discoverProjectIcon(hit.path);
|
|
555
|
+
// Prefer real topic title over a placeholder "Topic <id>" from an earlier note.
|
|
556
|
+
const displayName =
|
|
557
|
+
prev?.name && !/^Topic \d+$/i.test(prev.name) ? prev.name : hit.name;
|
|
558
|
+
const binding = this.store.bindProject(threadId, hit.path, displayName, "project");
|
|
559
|
+
if (iconPath) {
|
|
560
|
+
binding.iconPath = iconPath;
|
|
561
|
+
this.store.upsert(binding);
|
|
562
|
+
}
|
|
563
|
+
log.info(`auto-bound topic "${topicName}" (#${threadId}) → ${hit.path}`);
|
|
564
|
+
await this.announceProjectTopic(binding);
|
|
565
|
+
return { status: "bound", binding };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Bind a topic to a path. When `createIfMissing` is true (agent set_path /
|
|
570
|
+
* create_topic), absolute paths that do not exist yet are created on disk.
|
|
571
|
+
* User-typed bind in an unbound topic stays strict (createIfMissing false).
|
|
572
|
+
*/
|
|
573
|
+
tryBindPath(
|
|
574
|
+
threadId: number,
|
|
575
|
+
text: string,
|
|
576
|
+
opts?: { createIfMissing?: boolean },
|
|
577
|
+
): { ok: true; binding: ForumTopicBinding; created?: boolean } | { ok: false; error: string } {
|
|
578
|
+
const resolved = resolveBindTarget(text, {
|
|
579
|
+
existsSync,
|
|
580
|
+
isDirectory: (p) => this.projects.isDirectory(p),
|
|
581
|
+
findExactByName: (n) => this.projects.findExactByName(n),
|
|
582
|
+
createIfMissing: opts?.createIfMissing === true,
|
|
583
|
+
mkdirSync: (p) => mkdirSync(p, { recursive: true }),
|
|
584
|
+
defaultRoot: opts?.createIfMissing ? this.cfg.projectRoots[0] : undefined,
|
|
585
|
+
});
|
|
586
|
+
if (!resolved.ok) return resolved;
|
|
587
|
+
|
|
588
|
+
const path = resolved.path;
|
|
589
|
+
const already = this.store.findByProjectPath(path);
|
|
590
|
+
if (already && already.threadId !== threadId) {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
error:
|
|
594
|
+
`That project is already bound to topic #${already.threadId} (${already.name}). ` +
|
|
595
|
+
`Send a different absolute path or exact catalog name.`,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const name = this.store.get(threadId)?.name || basename(path);
|
|
600
|
+
const iconPath = discoverProjectIcon(path);
|
|
601
|
+
const binding = this.store.bindProject(threadId, path, name, "project");
|
|
602
|
+
if (iconPath) {
|
|
603
|
+
binding.iconPath = iconPath;
|
|
604
|
+
this.store.upsert(binding);
|
|
605
|
+
}
|
|
606
|
+
if (resolved.created) {
|
|
607
|
+
return { ok: true, binding, created: true };
|
|
608
|
+
}
|
|
609
|
+
return { ok: true, binding };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Resolve cwd for a thread; undefined means not ready (unbound). */
|
|
613
|
+
resolveCwd(threadId: number): { cwd: string; projectName: string; binding: ForumTopicBinding } | undefined {
|
|
614
|
+
const b = this.store.get(threadId);
|
|
615
|
+
if (!b?.projectPath) return undefined;
|
|
616
|
+
return {
|
|
617
|
+
cwd: b.projectPath,
|
|
618
|
+
projectName: b.kind === "ai_chat" ? this.cfg.topicAiChatName : b.name,
|
|
619
|
+
binding: b,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Result of auto-binding a user-created topic by its title. */
|
|
625
|
+
export type AutoBindResult =
|
|
626
|
+
| { status: "bound"; binding: ForumTopicBinding }
|
|
627
|
+
| { status: "no_match" }
|
|
628
|
+
| { status: "already_bound"; projectPath: string; otherThreadId: number };
|
|
629
|
+
|
|
630
|
+
function clampTopicName(name: string): string {
|
|
631
|
+
// Telegram topic names are short; keep readable.
|
|
632
|
+
const t = name.trim() || "Project";
|
|
633
|
+
return t.length > 128 ? t.slice(0, 125) + "…" : t;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function sleep(ms: number): Promise<void> {
|
|
637
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Telegram errors when Topics are off or createForumTopic is impossible. */
|
|
641
|
+
function isForumsDisabledError(err: unknown): boolean {
|
|
642
|
+
const msg = String((err as Error)?.message ?? err).toLowerCase();
|
|
643
|
+
return (
|
|
644
|
+
msg.includes("forums_disabled") ||
|
|
645
|
+
msg.includes("forum is disabled") ||
|
|
646
|
+
msg.includes("topics are disabled") ||
|
|
647
|
+
msg.includes("not a forum") ||
|
|
648
|
+
msg.includes("chat is not a forum") ||
|
|
649
|
+
msg.includes("supergroup required")
|
|
650
|
+
);
|
|
651
|
+
}
|