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.
Files changed (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -10,6 +10,7 @@ import { InlineKeyboard } from "grammy";
10
10
  import { basename } from "node:path";
11
11
  import { progressBar } from "../../render/progress.js";
12
12
  import type { SessionMeta } from "../../sessions/types.js";
13
+ // Note: callers may pass `comment` from runtime or history (last-turn outcome).
13
14
 
14
15
  export interface SessionCardExtras {
15
16
  /** Context-usage %, when the session is loaded in the current ACP process. */
@@ -22,6 +23,11 @@ export interface SessionCardExtras {
22
23
  selfPid?: number;
23
24
  /** Latest task-completion % (0–100) for this session, if this chat runs it. */
24
25
  progress?: number;
26
+ /**
27
+ * Last user prompt (and, when busy, last AI thinking on a second line).
28
+ * Overrides `m.comment` when provided by the controlling chat runtime.
29
+ */
30
+ comment?: string;
25
31
  }
26
32
 
27
33
  export interface SessionCard {
@@ -29,14 +35,28 @@ export interface SessionCard {
29
35
  keyboard: InlineKeyboard;
30
36
  }
31
37
 
38
+ const COMMENT_LINE_MAX = 250;
39
+
32
40
  /** Build the card body + buttons for one session. */
33
41
  export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}): SessionCard {
34
42
  const dot = m.active ? "\u{1F7E2}" : "\u26AA";
35
43
  const state = m.active ? `running${m.lockPid ? ` \u00B7 pid ${m.lockPid}` : ""}` : "idle";
36
44
  const proj = m.cwd ? basename(m.cwd) : "(no project)";
45
+ const comment = (extra.comment || m.comment || "").trim();
37
46
 
38
47
  const lines = [`${dot} ${m.title}`, `\u{1F4C1} ${proj}`];
39
48
  if (m.cwd) lines.push(` ${m.cwd}`);
49
+ // Last user prompt always; second line = thinking while running.
50
+ if (comment) {
51
+ const busy = m.active || typeof extra.progress === "number";
52
+ const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
53
+ parts.forEach((part, i) => {
54
+ const clipped = part.length > COMMENT_LINE_MAX ? part.slice(0, COMMENT_LINE_MAX - 1) + "\u2026" : part;
55
+ // First line: user prompt; later lines (thinking): hourglass when busy.
56
+ const icon = i === 0 ? (busy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
57
+ lines.push(`${icon} ${clipped}`);
58
+ });
59
+ }
40
60
  lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
41
61
  const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
42
62
  lines.push(`\u{1F4CA} ${state} \u00B7 \u{1F4DC} history ${humanSize(m.historyBytes)}${ctx}`);
@@ -10,7 +10,7 @@
10
10
  import { type Bot, type Context, InlineKeyboard } from "grammy";
11
11
  import { basename } from "node:path";
12
12
  import type { BotDeps } from "../deps.js";
13
- import { readHistory } from "../../sessions/history.js";
13
+ import { readHistory, readLastUserPrompt } from "../../sessions/history.js";
14
14
  import type { SessionMeta } from "../../sessions/types.js";
15
15
  import { refreshMenu } from "../menu/refresh.js";
16
16
  import { showHistory } from "./history.js";
@@ -22,16 +22,35 @@ const UUID = "([0-9a-fA-F-]{36})";
22
22
 
23
23
  export async function showSessions(ctx: Context, deps: BotDeps, query?: string): Promise<void> {
24
24
  const q = (query ?? "").trim().toLowerCase();
25
+ const { resolveScope } = await import("../scope.js");
26
+ const scope = resolveScope(ctx, deps);
25
27
  let metas = deps.store.list(q ? 400 : 200);
28
+ // In a forum topic, only show sessions for this project path (includes bot DMs).
29
+ if (scope.isForum && scope.projectPath) {
30
+ const want = scope.projectPath.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
31
+ metas = metas.filter((m) => (m.cwd || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === want);
32
+ }
26
33
  if (q) {
27
34
  metas = metas.filter((m) => `${m.title} ${m.cwd} ${m.sessionId}`.toLowerCase().includes(q));
28
35
  }
29
36
  if (metas.length === 0) {
30
37
  await deps.ephemeral.open(ctx);
31
- await deps.ephemeral.reply(ctx, q ? `No sessions match "${q}".` : "No saved sessions found in ~/.grok/sessions/cli.");
38
+ await deps.ephemeral.reply(
39
+ ctx,
40
+ q
41
+ ? `No sessions match "${q}".`
42
+ : scope.isForum
43
+ ? `No saved sessions for project **${scope.projectName}** yet.`
44
+ : "No saved sessions found in ~/.grok/sessions/cli.",
45
+ );
32
46
  return;
33
47
  }
34
- deps.menuCache.setSessions(ctx.chat!.id, metas, q ? `Sessions matching "${q}"` : "Recent sessions");
48
+ const heading = scope.isForum
49
+ ? `Sessions \u00B7 ${scope.projectName ?? "topic"}`
50
+ : q
51
+ ? `Sessions matching "${q}"`
52
+ : "Recent sessions";
53
+ deps.menuCache.setSessions(ctx.chat!.id, metas, heading);
35
54
  await renderSessionPage(ctx, deps, 0);
36
55
  }
37
56
 
@@ -50,10 +69,26 @@ async function renderSessionPage(ctx: Context, deps: BotDeps, page: number): Pro
50
69
  const pageStr = totalPages > 1 ? ` \u00B7 page ${p + 1}/${totalPages}` : "";
51
70
  await deps.ephemeral.reply(ctx, `\u{1F5C2} ${heading} \u2014 ${metas.length} total${liveStr}${pageStr}`);
52
71
 
72
+ const { resolveScope } = await import("../scope.js");
73
+ const scope = resolveScope(ctx, deps);
53
74
  for (const m of slice) {
54
75
  const contextPct = deps.acp.metadataFor(m.sessionId)?.contextUsagePercentage;
55
- const progress = deps.registry.controller(ctx.chat!.id).progressFor(m.sessionId);
56
- const { text, keyboard } = buildSessionCard(m, { contextPct, selfPid: deps.acp.pid, progress });
76
+ const ctrl = scope.controller;
77
+ const progress =
78
+ ctrl.progressFor(m.sessionId) ??
79
+ deps.registry.forumControllerForSession(m.sessionId)?.progressFor(m.sessionId);
80
+ // Live runtime (user + thinking) → last user from history → disk comment.
81
+ const comment =
82
+ ctrl.commentFor(m.sessionId) ||
83
+ deps.registry.forumControllerForSession(m.sessionId)?.commentFor(m.sessionId) ||
84
+ readLastUserPrompt(deps.store.jsonlPath(m.sessionId)) ||
85
+ m.comment;
86
+ const { text, keyboard } = buildSessionCard(m, {
87
+ contextPct,
88
+ selfPid: deps.acp.pid,
89
+ progress,
90
+ comment,
91
+ });
57
92
  await deps.ephemeral.reply(ctx, text, { reply_markup: keyboard });
58
93
  }
59
94
 
@@ -86,8 +121,12 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
86
121
  });
87
122
 
88
123
  bot.command("unwatch", async (ctx) => {
89
- const rt = deps.registry.get(ctx.chat.id);
90
- await ctx.reply(rt.stopWatch() ? "\u{1F6D1} Stopped watching." : "Not watching anything.");
124
+ const { resolveScope } = await import("../scope.js");
125
+ const scope = resolveScope(ctx, deps);
126
+ await ctx.reply(
127
+ scope.rt.stopWatch() ? "\u{1F6D1} Stopped watching." : "Not watching anything.",
128
+ scope.threadExtra,
129
+ );
91
130
  });
92
131
 
93
132
  bot.callbackQuery(new RegExp(`^sess:${UUID}$`), async (ctx) => {
@@ -99,19 +138,39 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
99
138
  }
100
139
  await ctx.answerCallbackQuery();
101
140
  await deps.ephemeral.clear(ctx.chat!.id); // remove the session cards
102
- const fgCwd = deps.registry.get(ctx.chat!.id).cwd;
141
+ const { resolveScope } = await import("../scope.js");
142
+ const scope = resolveScope(ctx, deps);
143
+ const fgCwd = scope.rt.cwd;
103
144
  const cwd = meta.cwd || fgCwd;
104
145
  const projectName = basename(meta.cwd || fgCwd) || "session";
146
+ if (scope.controller.fixedCwd) {
147
+ const a = scope.controller.fixedCwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
148
+ const b = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
149
+ if (a !== b) {
150
+ await ctx.reply("That session is for a different project than this topic.", scope.threadExtra);
151
+ return;
152
+ }
153
+ }
105
154
  const prior = readHistory(deps.store.jsonlPath(id), 24);
106
155
  try {
107
- const { result, alreadyControlled } = await deps.registry
108
- .controller(ctx.chat!.id)
109
- .addAttach(id, cwd, projectName, prior);
110
- await ctx.reply(alreadyControlled ? `\u{1F500} Switched to ${meta.title}` : connectMessage(result, meta));
156
+ // Avoid dual ownership across DM topic controllers.
157
+ for (const c of deps.registry.allForumControllers()) {
158
+ if (c !== scope.controller && c.findBySession(id)) await c.close(id);
159
+ }
160
+ const { result, alreadyControlled } = await scope.controller.addAttach(
161
+ id,
162
+ cwd,
163
+ projectName,
164
+ prior,
165
+ );
166
+ await ctx.reply(
167
+ alreadyControlled ? `\u{1F500} Switched to ${meta.title}` : connectMessage(result, meta),
168
+ scope.threadExtra,
169
+ );
111
170
  await refreshMenu(ctx, deps, `\u{1F4C2} ${meta.title}`);
112
171
  await showHistory(deps, ctx.chat!.id, id, meta);
113
172
  } catch (err) {
114
- await ctx.reply(`\u274C Could not connect: ${(err as Error).message}`);
173
+ await ctx.reply(`\u274C Could not connect: ${(err as Error).message}`, scope.threadExtra);
115
174
  }
116
175
  });
117
176
 
@@ -126,10 +185,12 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
126
185
  const id = ctx.match![1]!;
127
186
  await ctx.answerCallbackQuery();
128
187
  const meta = deps.store.get(id);
129
- const rt = deps.registry.get(ctx.chat!.id);
130
- rt.startWatch(deps.store.jsonlPath(id));
188
+ const { resolveScope } = await import("../scope.js");
189
+ const scope = resolveScope(ctx, deps);
190
+ scope.rt.startWatch(deps.store.jsonlPath(id));
131
191
  await ctx.reply(
132
192
  `\u{1F4E1} Watching live: ${meta?.title ?? id.slice(0, 8)}\nNew activity streams here. Send /unwatch to stop.`,
193
+ scope.threadExtra,
133
194
  );
134
195
  });
135
196
  }
@@ -1,7 +1,10 @@
1
1
  /**
2
- * /usage — show account info and the current session's context usage.
2
+ * /usage — Grok CLI live monthly quota + session context + bot-tracked
3
+ * per-account turn stats.
3
4
  */
4
5
  import type { Bot, Context } from "grammy";
6
+ import type { StoredAccount } from "../../app/accounts.js";
7
+ import { formatCliBillingLines } from "../../app/usage.js";
5
8
  import type { BotDeps } from "../deps.js";
6
9
 
7
10
  export async function showUsage(ctx: Context, deps: BotDeps): Promise<void> {
@@ -10,29 +13,128 @@ export async function showUsage(ctx: Context, deps: BotDeps): Promise<void> {
10
13
  const acct = await deps.usage.account();
11
14
  const meta = rt.contextInfo();
12
15
  const ctx100 = meta?.contextUsagePercentage;
13
- const savedCount = deps.accounts.list().length;
16
+ const list = deps.accounts.list();
17
+ const activeId = deps.accounts.activeAccountId();
18
+ const activeMeta = activeId ? deps.accounts.get(activeId) : undefined;
19
+ const { billing, error: billingError } = await deps.usage.cliBilling();
14
20
 
15
- const lines = [
16
- "\u{1F4CA} Usage & account",
17
- acct?.email ? `\u{1F464} ${acct.email}` : "",
18
- acct?.accountType ? `\u{1F511} ${acct.accountType}${acct.region ? ` \u00B7 ${acct.region}` : ""}` : "",
21
+ const lines: string[] = [
22
+ "\u{1F4CA} Usage & accounts",
19
23
  "",
20
- `\u{1F9F5} Session: ${rt.sessionId ? rt.sessionId.slice(0, 8) : "none"}`,
21
- `\u{1F9E9} Model: ${rt.model || "default"}`,
22
- `\u{1F4CA} Context used: ${ctx100 !== undefined ? `${ctx100.toFixed(0)}%` : "\u2014"}`,
23
- `\u{1F501} Turns this session: ${rt.turns}`,
24
- meta?.credits !== undefined ? `\u{1FA99} Credits used: ${meta.credits.toLocaleString("en-US")}` : "",
25
- meta?.effort ? `\u{1F9E0} Effort: ${meta.effort}` : "",
26
- savedCount > 0 ? `\u{1F465} Saved accounts: ${savedCount} \u00B7 /accounts to switch` : "",
24
+ "\u{1F464} Current login",
25
+ acct?.email ? ` ${acct.email}` : " (identity unknown)",
26
+ ];
27
+ if (acct?.accountType) {
28
+ lines.push(` \u{1F511} ${acct.accountType}${acct.region ? ` \u00B7 ${acct.region}` : ""}`);
29
+ }
30
+ if (acct?.teamId) lines.push(` Team: ${acct.teamId.slice(0, 8)}\u2026`);
31
+ if (activeMeta) {
32
+ const uLine = deps.accounts.formatUsageLine(activeMeta);
33
+ lines.push(` Saved as: ${activeMeta.label}`);
34
+ if (uLine) lines.push(` Bot-tracked: ${uLine}`);
35
+ }
36
+
37
+ lines.push("");
38
+ if (billing) {
39
+ lines.push(...formatCliBillingLines(billing));
40
+ } else {
41
+ lines.push(
42
+ "\u{1F4B3} Grok CLI monthly quota",
43
+ ` \u26A0\uFE0F ${billingError || "unavailable"}`,
44
+ " (Requires `grok login` OIDC token — not XAI_API_KEY alone.)",
45
+ );
46
+ }
47
+
48
+ lines.push(
27
49
  "",
28
- "\u2139\uFE0F Full billing/quota lives in the Grok app; grok doesn't expose limits headlessly.",
29
- ].filter(Boolean);
50
+ "\u{1F9F5} This session (ACP)",
51
+ ` Id: ${rt.sessionId ? rt.sessionId.slice(0, 8) : "none"}`,
52
+ ` Model: ${rt.model || "default"}`,
53
+ ` Context used: ${ctx100 !== undefined ? `${ctx100.toFixed(0)}%` : "\u2014"}`,
54
+ ` Turns this session: ${rt.turns}`,
55
+ );
56
+ if (meta?.credits !== undefined) {
57
+ lines.push(` Credits (session report): ${fmtNum(meta.credits)}`);
58
+ }
59
+ if (meta?.effort) lines.push(` Effort: ${meta.effort}`);
60
+ if (meta?.totalTokens !== undefined) {
61
+ lines.push(` Tokens (session report): ${meta.totalTokens.toLocaleString("en-US")}`);
62
+ }
63
+
64
+ if (list.length > 0) {
65
+ lines.push("", "\u{1F465} Saved accounts (bot-tracked turns on this machine)");
66
+ for (const a of list) {
67
+ lines.push(accountUsageBlock(deps, a, a.id === activeId));
68
+ }
69
+ const totals = aggregate(list);
70
+ lines.push(
71
+ "",
72
+ `\u{1F4CA} Bot totals: ${totals.turns} turn${totals.turns === 1 ? "" : "s"}` +
73
+ (totals.credits > 0 ? ` \u00B7 ${fmtNum(totals.credits)} session credits` : "") +
74
+ ` \u00B7 ${totals.withUsage}/${list.length} accounts used`,
75
+ );
76
+ } else {
77
+ lines.push("", "\u{1F465} No saved accounts yet \u2014 /accounts to save & switch.");
78
+ }
79
+
80
+ lines.push(
81
+ "",
82
+ "\u2139\uFE0F Monthly quota is live from Grok CLI (`cli-chat-proxy` billing). Bot-tracked turns are local to this bot.",
83
+ );
84
+
85
+ if (!acct) lines.splice(3, 0, " (account info unavailable \u2014 is grok logged in?)");
30
86
 
31
- if (!acct) lines.splice(1, 0, "(account info unavailable \u2014 is grok logged in?)");
32
87
  await deps.ephemeral.open(ctx);
33
88
  await deps.ephemeral.reply(ctx, lines.join("\n"));
34
89
  }
35
90
 
91
+ function accountUsageBlock(deps: BotDeps, a: StoredAccount, active: boolean): string {
92
+ const mark = a.warning ? "\u26A0\uFE0F" : active ? "\u2705" : "\u{1F464}";
93
+ const u = a.usage;
94
+ const head = `${mark} ${a.label}${active ? " (active)" : ""}`;
95
+ if (!u || (u.turns <= 0 && u.credits <= 0 && !u.lastUsedAt)) {
96
+ return `${head}\n no bot-tracked usage yet`;
97
+ }
98
+ const bits: string[] = [];
99
+ bits.push(`${u.turns || 0} turn${(u.turns || 0) === 1 ? "" : "s"}`);
100
+ if (u.credits > 0) bits.push(`${fmtNum(u.credits)} session credits`);
101
+ if (u.lastTurnCredits !== undefined) bits.push(`last turn ${fmtNum(u.lastTurnCredits)}`);
102
+ if (u.lastContextPct !== undefined) bits.push(`last ctx ${u.lastContextPct.toFixed(0)}%`);
103
+ if (u.lastUsedAt) bits.push(`last ${shortWhen(u.lastUsedAt)}`);
104
+ return `${head}\n ${bits.join(" \u00B7 ")}`;
105
+ }
106
+
107
+ function aggregate(list: StoredAccount[]): { turns: number; credits: number; withUsage: number } {
108
+ let turns = 0;
109
+ let credits = 0;
110
+ let withUsage = 0;
111
+ for (const a of list) {
112
+ const u = a.usage;
113
+ if (!u) continue;
114
+ turns += u.turns || 0;
115
+ credits += u.credits || 0;
116
+ if (u.turns > 0 || u.credits > 0 || u.lastUsedAt) withUsage++;
117
+ }
118
+ return { turns, credits, withUsage };
119
+ }
120
+
121
+ function fmtNum(n: number): string {
122
+ if (!Number.isFinite(n)) return String(n);
123
+ if (Number.isInteger(n)) return n.toLocaleString("en-US");
124
+ return n.toFixed(2);
125
+ }
126
+
127
+ function shortWhen(iso: string): string {
128
+ const t = Date.parse(iso);
129
+ if (!Number.isFinite(t)) return iso.slice(0, 10);
130
+ const sec = Math.max(0, Math.round((Date.now() - t) / 1000));
131
+ if (sec < 60) return "just now";
132
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
133
+ if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
134
+ if (sec < 86_400 * 14) return `${Math.floor(sec / 86_400)}d ago`;
135
+ return new Date(t).toISOString().slice(0, 10);
136
+ }
137
+
36
138
  export function registerUsage(bot: Bot, deps: BotDeps): void {
37
139
  bot.command("usage", (ctx) => showUsage(ctx, deps));
38
140
  }
@@ -14,8 +14,16 @@ import { extractReplyContext } from "../reply-context.js";
14
14
 
15
15
  const log = createLogger("voice");
16
16
 
17
+ type VoiceMediaKind = "voice" | "audio" | "video_note";
18
+
17
19
  export function registerVoice(bot: Bot, deps: BotDeps): void {
18
- const handle = async (ctx: Context, fileId: string, mime: string, name: string): Promise<void> => {
20
+ const handle = async (
21
+ ctx: Context,
22
+ fileId: string,
23
+ mime: string,
24
+ name: string,
25
+ mediaKind: VoiceMediaKind,
26
+ ): Promise<void> => {
19
27
  const chatId = ctx.chat!.id;
20
28
  if (deps.wizard.isActive(chatId)) {
21
29
  await ctx.reply("Finish or /cancel the task wizard before sending voice.");
@@ -36,11 +44,41 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
36
44
  await ctx.reply("\u{1F399} I couldn't make out any speech.");
37
45
  return;
38
46
  }
39
- await ctx.reply(`\u{1F399} \u201C${text}\u201D`);
40
- const rt = deps.registry.get(chatId);
47
+ const { resolveScope } = await import("../scope.js");
48
+ const { adoptUserPrompt } = await import("../prompt-anchor.js");
49
+ const scope = resolveScope(ctx, deps);
41
50
  const quoted = extractReplyContext(ctx);
42
- const outcome = await rt.submit(textPrompt(text, ctx.message?.message_id, quoted));
43
- if (outcome === "queued") await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.");
51
+ const userMsgId = ctx.message?.message_id;
52
+ const media =
53
+ mediaKind === "video_note"
54
+ ? [{ type: "video_note" as const, fileId }]
55
+ : mediaKind === "voice"
56
+ ? [{ type: "voice" as const, fileId }]
57
+ : [{ type: "audio" as const, fileId, fileName: name }];
58
+ const anchor = await adoptUserPrompt(deps.api, {
59
+ chatId,
60
+ text: `\u201C${text}\u201D`,
61
+ userMessageIds: userMsgId !== undefined ? [userMsgId] : [],
62
+ messageThreadId: scope.threadExtra.message_thread_id,
63
+ projectName: scope.rt.projectName,
64
+ prefix: "\u{1F399} Voice",
65
+ media,
66
+ });
67
+ const outcome = await scope.rt.submit(
68
+ textPrompt(text, anchor?.replyTo ?? userMsgId, quoted, {
69
+ promptId: anchor?.promptId,
70
+ }),
71
+ );
72
+ if (outcome === "queued") {
73
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
74
+ if (anchor?.replyTo !== undefined) {
75
+ extra.reply_parameters = {
76
+ message_id: anchor.replyTo,
77
+ allow_sending_without_reply: true,
78
+ };
79
+ }
80
+ await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.", extra);
81
+ }
44
82
  } catch (e) {
45
83
  log.warn("voice failed:", (e as Error).message);
46
84
  await ctx.reply(`\u274C Voice transcription failed: ${(e as Error).message}`);
@@ -48,7 +86,13 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
48
86
  };
49
87
 
50
88
  bot.on("message:voice", (ctx) =>
51
- handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"),
89
+ handle(
90
+ ctx,
91
+ ctx.message.voice.file_id,
92
+ ctx.message.voice.mime_type || "audio/ogg",
93
+ "voice.ogg",
94
+ "voice",
95
+ ),
52
96
  );
53
97
  bot.on("message:audio", (ctx) =>
54
98
  handle(
@@ -56,10 +100,11 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
56
100
  ctx.message.audio.file_id,
57
101
  ctx.message.audio.mime_type || "audio/mpeg",
58
102
  ctx.message.audio.file_name || "audio.mp3",
103
+ "audio",
59
104
  ),
60
105
  );
61
106
  bot.on("message:video_note", (ctx) =>
62
- handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
107
+ handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4", "video_note"),
63
108
  );
64
109
  }
65
110
 
@@ -129,6 +129,8 @@ export interface SendImagesOptions {
129
129
  max: number;
130
130
  /** Optional Telegram message id to thread replies under. */
131
131
  replyTo?: number;
132
+ /** Forum topic — required so agent images land in the right topic. */
133
+ messageThreadId?: number;
132
134
  }
133
135
 
134
136
  /** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
@@ -139,10 +141,11 @@ export async function sendImages(
139
141
  opts: SendImagesOptions,
140
142
  ): Promise<number> {
141
143
  let sent = 0;
142
- const replyExtra =
143
- opts.replyTo !== undefined
144
- ? { reply_parameters: { message_id: opts.replyTo, allow_sending_without_reply: true } }
145
- : {};
144
+ const extra: Record<string, unknown> = {};
145
+ if (opts.replyTo !== undefined) {
146
+ extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
147
+ }
148
+ if (opts.messageThreadId !== undefined) extra.message_thread_id = opts.messageThreadId;
146
149
  for (const path of paths) {
147
150
  if (sent >= opts.max) break;
148
151
  if (opts.already.has(path)) continue;
@@ -161,7 +164,7 @@ export async function sendImages(
161
164
  const file = new InputFile(path, basename(path));
162
165
  await api.sendDocument(chatId, file, {
163
166
  caption: basename(path),
164
- ...replyExtra,
167
+ ...extra,
165
168
  });
166
169
  sent++;
167
170
  log.debug(`sent document ${path}`);
@@ -95,11 +95,21 @@ export class Ephemeral {
95
95
  async reply(ctx: Context, text: string, extra: Record<string, unknown> = {}): Promise<number | undefined> {
96
96
  const chatId = ctx.chat?.id;
97
97
  if (chatId === undefined) return undefined;
98
+ // Stay in the forum topic when the trigger message was in a thread.
99
+ const msg = ctx.message ?? ctx.callbackQuery?.message;
100
+ const threadId =
101
+ msg && "message_thread_id" in msg
102
+ ? (msg as { message_thread_id?: number }).message_thread_id
103
+ : undefined;
104
+ const merged =
105
+ threadId !== undefined && extra.message_thread_id === undefined
106
+ ? { ...extra, message_thread_id: threadId }
107
+ : extra;
98
108
  return this.serialize(chatId, async () => {
99
109
  try {
100
- const msg = await ctx.reply(text, extra);
101
- this.remember(chatId, msg.message_id);
102
- return msg.message_id;
110
+ const m = await ctx.reply(text, merged);
111
+ this.remember(chatId, m.message_id);
112
+ return m.message_id;
103
113
  } catch {
104
114
  return undefined;
105
115
  }
@@ -1,33 +1,73 @@
1
1
  /**
2
2
  * Menu surfaces:
3
- * - a tiny PERSISTENT bar (☰ Menu · 🧭 Running · ⏹ Stop) — minimal footprint;
4
- * - a full, organized INLINE menu opened on demand (and hideable).
5
- * Live state (project/agent/model/reasoning/context) lives in the pinned panel,
6
- * so the bar stays clean.
3
+ * - PERSISTENT bar (reply keyboard): ☰ Menu · 🆕 New session / 🧭 Running · ⏹ Stop
4
+ * primary place for New session in private chats (not the inline Menu message).
5
+ * - INLINE menu message (opened via ☰ Menu or /menu) settings & navigation.
6
+ * Forum topics: reply keyboards are unreliable, so New stays on the topic inline menu.
7
+ * Live state lives in the pinned status panel so the bar stays clean.
7
8
  */
8
9
  import { InlineKeyboard, Keyboard } from "grammy";
9
10
 
10
11
  export const MENU_BTN = "\u2630 Menu"; // ☰
12
+ /** Persistent bar + forum topic control — brand-new session (same as /new). */
13
+ export const NEW_BTN = "\u{1F195} New session";
11
14
  export const RUNNING_BTN = "\u{1F9ED} Running";
12
15
  export const STOP_BTN = "\u23F9 Stop";
13
- export const BAR_LABELS = [MENU_BTN, RUNNING_BTN, STOP_BTN];
16
+ export const BAR_LABELS = [MENU_BTN, NEW_BTN, RUNNING_BTN, STOP_BTN];
14
17
 
15
- /** The always-visible compact bar. */
18
+ /** The always-visible compact bar (private chats; best-effort in groups). */
16
19
  export function compactKeyboard(): Keyboard {
17
- return new Keyboard().text(MENU_BTN).text(RUNNING_BTN).text(STOP_BTN).resized().persistent();
20
+ return new Keyboard()
21
+ .text(MENU_BTN)
22
+ .text(NEW_BTN)
23
+ .row()
24
+ .text(RUNNING_BTN)
25
+ .text(STOP_BTN)
26
+ .resized()
27
+ .persistent();
18
28
  }
19
29
 
20
30
  /** The full, grouped inline menu (opened via ☰ Menu or /menu). */
21
- export function mainMenuInline(state: { agent: string; model: string; reasoning: string }): InlineKeyboard {
31
+ export function mainMenuInline(state: {
32
+ model: string;
33
+ reasoning: string;
34
+ /** Forum topic scope — hide project switch; label topic sessions. */
35
+ forumTopic?: { name: string; account?: string };
36
+ }): InlineKeyboard {
22
37
  const t = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "\u2026" : s);
23
- return new InlineKeyboard()
24
- .text("\u{1F4C1} Project", "m:project")
25
- .text("\u{1F195} New", "m:new")
38
+ const kb = new InlineKeyboard();
39
+
40
+ if (state.forumTopic) {
41
+ // Groups/topics: control first (no reliable reply-keyboard bar). New session here.
42
+ kb.text("\u23F9 Stop", "m:stop")
43
+ .text("\u{1F9ED} Running", "m:running")
44
+ .row()
45
+ .text(NEW_BTN, "m:new")
46
+ .text("\u{1F5C2} Sessions", "m:sessions")
47
+ .row()
48
+ .text(`\u{1F4C1} ${t(state.forumTopic.name, 28)}`, "m:topicinfo")
49
+ .row()
50
+ .text("\u{1F4E5} Import session", "m:import")
51
+ .row()
52
+ .text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
53
+ .row()
54
+ .text(`\u{1F9E0} Reasoning \u00B7 ${t(state.reasoning, 24)}`, "m:reasoning")
55
+ .row();
56
+ if (state.forumTopic.account) {
57
+ kb.text(`\u{1F465} Account \u00B7 ${t(state.forumTopic.account, 20)}`, "m:accounts").row();
58
+ }
59
+ kb.text("\u{1F4CA} Status", "m:status").text("\u2716 Close", "m:close");
60
+ return kb;
61
+ }
62
+
63
+ // Private chat: New session is on the persistent bar + /new — not on this message.
64
+ kb.text("\u{1F4C1} Project", "m:project")
65
+ .text("\u{1F5C2} Sessions", "m:sessions")
26
66
  .row()
67
+ .text("\u23F9 Stop", "m:stop")
27
68
  .text("\u{1F9ED} Running", "m:running")
28
- .text("\u{1F5C2} Sessions", "m:sessions")
29
69
  .row()
30
- .text(`\u{1F916} Agent \u00B7 ${t(state.agent, 24)}`, "m:agent")
70
+ .text("\u{1F4E5} Import session", "m:import")
31
71
  .row()
32
72
  .text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
33
73
  .row()
@@ -40,10 +80,10 @@ export function mainMenuInline(state: { agent: string; model: string; reasoning:
40
80
  .text("\u{1F465} Accounts", "m:accounts")
41
81
  .row()
42
82
  .text("\u{1F9E9} MCP", "m:mcp")
43
- .text("\u23F9 Stop", "m:stop")
44
83
  .text("\u{1F6D1} Kill all", "m:killall")
45
84
  .row()
46
85
  .text("\u2328\uFE0F Show bar", "m:showbar")
47
86
  .text("\u{1F648} Hide bar", "m:hidebar")
48
87
  .text("\u2716 Close", "m:close");
88
+ return kb;
49
89
  }
@@ -4,10 +4,12 @@
4
4
  */
5
5
  import type { Context } from "grammy";
6
6
  import type { BotDeps } from "../deps.js";
7
+ import { resolveScope } from "../scope.js";
7
8
  import { compactKeyboard } from "./keyboard.js";
8
9
 
9
10
  export async function refreshMenu(ctx: Context, deps: BotDeps, text: string): Promise<void> {
10
11
  const chatId = ctx.chat!.id;
11
- await ctx.reply(text, { reply_markup: compactKeyboard() });
12
+ const scope = resolveScope(ctx, deps);
13
+ await ctx.reply(text, { reply_markup: compactKeyboard(), ...scope.threadExtra });
12
14
  await deps.statusPanel.refresh(chatId);
13
15
  }