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
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
56
|
-
const
|
|
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
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
.
|
|
110
|
-
|
|
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
|
|
130
|
-
|
|
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 —
|
|
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
|
|
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 &
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
"\
|
|
29
|
-
|
|
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 (
|
|
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
|
-
|
|
40
|
-
const
|
|
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
|
|
43
|
-
|
|
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(
|
|
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
|
|
package/src/bot/image-return.ts
CHANGED
|
@@ -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
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
...
|
|
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
|
|
101
|
-
this.remember(chatId,
|
|
102
|
-
return
|
|
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
|
}
|
package/src/bot/menu/keyboard.ts
CHANGED
|
@@ -1,33 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Menu surfaces:
|
|
3
|
-
* -
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* so the
|
|
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()
|
|
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: {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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
|
}
|
package/src/bot/menu/refresh.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|