grok-telegram-bot 2.0.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 +135 -0
- package/CHANGELOG.md +598 -0
- package/LICENSE +21 -0
- package/README.md +644 -0
- package/bin/grok-tg.mjs +21 -0
- package/docs/INSTALL.md +153 -0
- package/docs/UPGRADE.md +253 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +74 -0
- package/scripts/setup.mjs +116 -0
- package/src/agents/catalog.ts +58 -0
- package/src/app/accounts.ts +162 -0
- package/src/app/auth-service.ts +136 -0
- package/src/app/grok-credentials.ts +103 -0
- package/src/app/instance-lock.ts +139 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +38 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +56 -0
- package/src/app/updater.ts +234 -0
- package/src/app/usage.ts +38 -0
- package/src/app/version.ts +41 -0
- package/src/bot/account-rotator.ts +52 -0
- package/src/bot/auth.ts +38 -0
- package/src/bot/bot.ts +225 -0
- package/src/bot/chat-controller.ts +317 -0
- package/src/bot/commands.ts +52 -0
- package/src/bot/deps.ts +67 -0
- package/src/bot/file-ingest.ts +190 -0
- package/src/bot/handlers/accounts.ts +220 -0
- package/src/bot/handlers/auth.ts +64 -0
- package/src/bot/handlers/control.ts +103 -0
- package/src/bot/handlers/document.ts +112 -0
- package/src/bot/handlers/history.ts +63 -0
- package/src/bot/handlers/kill.ts +54 -0
- package/src/bot/handlers/mcp.ts +206 -0
- package/src/bot/handlers/menu.ts +220 -0
- package/src/bot/handlers/message.ts +103 -0
- package/src/bot/handlers/photo.ts +123 -0
- package/src/bot/handlers/projects.ts +183 -0
- package/src/bot/handlers/running.ts +181 -0
- package/src/bot/handlers/session-card.ts +81 -0
- package/src/bot/handlers/session-kill.ts +95 -0
- package/src/bot/handlers/sessions.ts +148 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +224 -0
- package/src/bot/handlers/usage.ts +38 -0
- package/src/bot/handlers/voice.ts +55 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/ephemeral.ts +117 -0
- package/src/bot/menu/keyboard.ts +49 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +173 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +64 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/reauth-controller.ts +297 -0
- package/src/bot/registry.ts +186 -0
- package/src/bot/reply-context.ts +77 -0
- package/src/bot/session-fork.ts +35 -0
- package/src/bot/session-runtime.ts +1048 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +126 -0
- package/src/config.ts +248 -0
- package/src/grok/client.ts +617 -0
- package/src/grok/models.ts +50 -0
- package/src/grok/session-log.ts +148 -0
- package/src/grok/transport.ts +51 -0
- package/src/grok/types.ts +136 -0
- package/src/index.ts +84 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +99 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/file-summary.ts +111 -0
- package/src/render/hashtags.ts +34 -0
- package/src/render/markdown.ts +130 -0
- package/src/render/progress-estimate.ts +63 -0
- package/src/render/progress.ts +80 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +196 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +85 -0
- package/src/service/macos.ts +101 -0
- package/src/service/platform.ts +64 -0
- package/src/service/types.ts +36 -0
- package/src/service/windows.ts +198 -0
- package/src/sessions/history.ts +225 -0
- package/src/sessions/process.ts +30 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +261 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session kill — terminate the OS process holding a live session's `.lock`
|
|
3
|
+
* straight from its card in /sessions or /active.
|
|
4
|
+
*
|
|
5
|
+
* Flow (callback data, UUID-keyed so it survives bot restarts):
|
|
6
|
+
* killsess:<id> a tap on the card's 🛑 Kill button → ask to confirm
|
|
7
|
+
* killsess:do:<id> confirmed → kill the lockPid, report the outcome
|
|
8
|
+
* killsess:cancel:<id> abort → restore the card's normal buttons
|
|
9
|
+
*
|
|
10
|
+
* Guards: the bot's own agent (acp.pid) is never killable here (its card never
|
|
11
|
+
* shows the button, and the handlers re-check), and state is re-read from disk
|
|
12
|
+
* at every step so a session that already stopped can't be "killed" twice.
|
|
13
|
+
*/
|
|
14
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
15
|
+
import { killPid } from "../../sessions/process.js";
|
|
16
|
+
import type { SessionMeta } from "../../sessions/types.js";
|
|
17
|
+
import type { BotDeps } from "../deps.js";
|
|
18
|
+
import { buildSessionCard } from "./session-card.js";
|
|
19
|
+
|
|
20
|
+
const UUID = "([0-9a-fA-F-]{36})";
|
|
21
|
+
|
|
22
|
+
/** Rebuild the standard card keyboard for the freshest on-disk session state. */
|
|
23
|
+
function cardKeyboard(deps: BotDeps, meta: SessionMeta): InlineKeyboard {
|
|
24
|
+
const contextPct = deps.acp.metadataFor(meta.sessionId)?.contextUsagePercentage;
|
|
25
|
+
return buildSessionCard(meta, { contextPct, selfPid: deps.acp.pid }).keyboard;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Re-read the session and decide whether its PID may be killed right now. */
|
|
29
|
+
function killable(
|
|
30
|
+
deps: BotDeps,
|
|
31
|
+
id: string,
|
|
32
|
+
): { ok: true; meta: SessionMeta; pid: number } | { ok: false; meta?: SessionMeta; reason: string } {
|
|
33
|
+
const meta = deps.store.get(id);
|
|
34
|
+
if (!meta) return { ok: false, reason: "Session not found." };
|
|
35
|
+
if (!meta.active || typeof meta.lockPid !== "number") {
|
|
36
|
+
return { ok: false, meta, reason: "Session is no longer running." };
|
|
37
|
+
}
|
|
38
|
+
if (meta.lockPid === deps.acp.pid) {
|
|
39
|
+
return { ok: false, meta, reason: "That's the bot's own agent — can't kill it." };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, meta, pid: meta.lockPid };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function registerSessionKill(bot: Bot, deps: BotDeps): void {
|
|
45
|
+
// Step 1 — ask to confirm. Swap the card's buttons for a Kill/Cancel row.
|
|
46
|
+
bot.callbackQuery(new RegExp(`^killsess:${UUID}$`), async (ctx) => {
|
|
47
|
+
const id = ctx.match![1]!;
|
|
48
|
+
const check = killable(deps, id);
|
|
49
|
+
if (!check.ok) {
|
|
50
|
+
await ctx.answerCallbackQuery({ text: check.reason });
|
|
51
|
+
// The button is stale (session stopped); refresh it to the normal card.
|
|
52
|
+
if (check.meta) await ctx.editMessageReplyMarkup({ reply_markup: cardKeyboard(deps, check.meta) }).catch(() => {});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await ctx.answerCallbackQuery();
|
|
56
|
+
const kb = new InlineKeyboard()
|
|
57
|
+
.text(`\u{1F6D1} Kill pid ${check.pid}`, `killsess:do:${id}`)
|
|
58
|
+
.text("\u21A9 Cancel", `killsess:cancel:${id}`);
|
|
59
|
+
await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Step 2a — confirmed. Re-validate (it may have died meanwhile), then kill.
|
|
63
|
+
bot.callbackQuery(new RegExp(`^killsess:do:${UUID}$`), async (ctx) => {
|
|
64
|
+
const id = ctx.match![1]!;
|
|
65
|
+
const check = killable(deps, id);
|
|
66
|
+
if (!check.ok) {
|
|
67
|
+
await ctx.answerCallbackQuery({ text: check.reason });
|
|
68
|
+
await appendStatus(ctx, check.reason);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const title = check.meta.title;
|
|
72
|
+
const ok = killPid(check.pid);
|
|
73
|
+
await ctx.answerCallbackQuery({ text: ok ? "Killed" : "Kill failed" });
|
|
74
|
+
const note = ok
|
|
75
|
+
? `\u{1F6D1} Killed ${title} (pid ${check.pid}).`
|
|
76
|
+
: `\u26A0\uFE0F Could not kill pid ${check.pid} (already gone, or not permitted).`;
|
|
77
|
+
await appendStatus(ctx, note);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Step 2b — cancelled. Put the card's normal buttons back.
|
|
81
|
+
bot.callbackQuery(new RegExp(`^killsess:cancel:${UUID}$`), async (ctx) => {
|
|
82
|
+
const id = ctx.match![1]!;
|
|
83
|
+
await ctx.answerCallbackQuery({ text: "Cancelled" });
|
|
84
|
+
const meta = deps.store.get(id);
|
|
85
|
+
if (meta) await ctx.editMessageReplyMarkup({ reply_markup: cardKeyboard(deps, meta) }).catch(() => {});
|
|
86
|
+
else await ctx.editMessageReplyMarkup().catch(() => {});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Append a one-line status under the card and drop its keyboard. */
|
|
91
|
+
async function appendStatus(ctx: Context, note: string): Promise<void> {
|
|
92
|
+
const body = ctx.callbackQuery?.message?.text;
|
|
93
|
+
const text = body ? `${body}\n\n${note}` : note;
|
|
94
|
+
await ctx.editMessageText(text).catch(() => {});
|
|
95
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /sessions — list recent Grok sessions and connect to one.
|
|
3
|
+
* /active — list sessions currently running on this PC.
|
|
4
|
+
* /unwatch — stop following a live session.
|
|
5
|
+
*
|
|
6
|
+
* Each session is shown as its own card (status, project + path, times, history
|
|
7
|
+
* size, context %), with Connect (resume, or fork if the session is locked/live),
|
|
8
|
+
* 📜 History (static view), and 📡 Watch (live read-only follow) buttons.
|
|
9
|
+
*/
|
|
10
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import type { BotDeps } from "../deps.js";
|
|
13
|
+
import { readHistory } from "../../sessions/history.js";
|
|
14
|
+
import type { SessionMeta } from "../../sessions/types.js";
|
|
15
|
+
import { refreshMenu } from "../menu/refresh.js";
|
|
16
|
+
import { showHistory } from "./history.js";
|
|
17
|
+
import { buildSessionCard } from "./session-card.js";
|
|
18
|
+
|
|
19
|
+
/** How many session cards per page. */
|
|
20
|
+
const PAGE_SIZE = 10;
|
|
21
|
+
const UUID = "([0-9a-fA-F-]{36})";
|
|
22
|
+
|
|
23
|
+
export async function showSessions(ctx: Context, deps: BotDeps, query?: string): Promise<void> {
|
|
24
|
+
const q = (query ?? "").trim().toLowerCase();
|
|
25
|
+
let metas = deps.store.list(q ? 400 : 200);
|
|
26
|
+
if (q) {
|
|
27
|
+
metas = metas.filter((m) => `${m.title} ${m.cwd} ${m.sessionId}`.toLowerCase().includes(q));
|
|
28
|
+
}
|
|
29
|
+
if (metas.length === 0) {
|
|
30
|
+
await deps.ephemeral.open(ctx);
|
|
31
|
+
await deps.ephemeral.reply(ctx, q ? `No sessions match "${q}".` : "No saved sessions found in ~/.grok/sessions/cli.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
deps.menuCache.setSessions(ctx.chat!.id, metas, q ? `Sessions matching "${q}"` : "Recent sessions");
|
|
35
|
+
await renderSessionPage(ctx, deps, 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Render one page of session cards: header + up to PAGE_SIZE cards + nav footer. */
|
|
39
|
+
async function renderSessionPage(ctx: Context, deps: BotDeps, page: number): Promise<void> {
|
|
40
|
+
await deps.ephemeral.open(ctx);
|
|
41
|
+
const cached = deps.menuCache.getSessions(ctx.chat!.id);
|
|
42
|
+
if (!cached) return;
|
|
43
|
+
const { metas, heading } = cached;
|
|
44
|
+
const totalPages = Math.max(1, Math.ceil(metas.length / PAGE_SIZE));
|
|
45
|
+
const p = Math.min(Math.max(0, page), totalPages - 1);
|
|
46
|
+
const slice = metas.slice(p * PAGE_SIZE, p * PAGE_SIZE + PAGE_SIZE);
|
|
47
|
+
|
|
48
|
+
const live = slice.filter((m) => m.active).length;
|
|
49
|
+
const liveStr = live ? ` \u00B7 \u{1F7E2} ${live} live` : "";
|
|
50
|
+
const pageStr = totalPages > 1 ? ` \u00B7 page ${p + 1}/${totalPages}` : "";
|
|
51
|
+
await deps.ephemeral.reply(ctx, `\u{1F5C2} ${heading} \u2014 ${metas.length} total${liveStr}${pageStr}`);
|
|
52
|
+
|
|
53
|
+
for (const m of slice) {
|
|
54
|
+
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 });
|
|
57
|
+
await deps.ephemeral.reply(ctx, text, { reply_markup: keyboard });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (totalPages > 1) {
|
|
61
|
+
const nav = new InlineKeyboard();
|
|
62
|
+
if (p > 0) nav.text("\u25C0 Prev", `sp:${p - 1}`);
|
|
63
|
+
nav.text(`${p + 1}/${totalPages}`, "noop");
|
|
64
|
+
if (p < totalPages - 1) nav.text("Next \u25B6", `sp:${p + 1}`);
|
|
65
|
+
await deps.ephemeral.reply(ctx, `\u{1F4C4} Page ${p + 1}/${totalPages}`, { reply_markup: nav });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function registerSessions(bot: Bot, deps: BotDeps): void {
|
|
70
|
+
bot.command("sessions", (ctx) => showSessions(ctx, deps, ctx.match?.toString()));
|
|
71
|
+
|
|
72
|
+
bot.command("active", async (ctx) => {
|
|
73
|
+
const metas = deps.store.listActive();
|
|
74
|
+
if (metas.length === 0) {
|
|
75
|
+
await deps.ephemeral.open(ctx);
|
|
76
|
+
await deps.ephemeral.reply(ctx, "No sessions are currently running on this PC.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
deps.menuCache.setSessions(ctx.chat!.id, metas, "Live sessions running now");
|
|
80
|
+
await renderSessionPage(ctx, deps, 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
bot.callbackQuery(/^sp:(\d+)$/, async (ctx) => {
|
|
84
|
+
await ctx.answerCallbackQuery();
|
|
85
|
+
await renderSessionPage(ctx, deps, Number(ctx.match![1]));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
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.");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
bot.callbackQuery(new RegExp(`^sess:${UUID}$`), async (ctx) => {
|
|
94
|
+
const id = ctx.match![1]!;
|
|
95
|
+
const meta = deps.store.get(id);
|
|
96
|
+
if (!meta) {
|
|
97
|
+
await ctx.answerCallbackQuery({ text: "Session not found." });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await ctx.answerCallbackQuery();
|
|
101
|
+
await deps.ephemeral.clear(ctx.chat!.id); // remove the session cards
|
|
102
|
+
const fgCwd = deps.registry.get(ctx.chat!.id).cwd;
|
|
103
|
+
const cwd = meta.cwd || fgCwd;
|
|
104
|
+
const projectName = basename(meta.cwd || fgCwd) || "session";
|
|
105
|
+
const prior = readHistory(deps.store.jsonlPath(id), 24);
|
|
106
|
+
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));
|
|
111
|
+
await refreshMenu(ctx, deps, `\u{1F4C2} ${meta.title}`);
|
|
112
|
+
await showHistory(deps, ctx.chat!.id, id, meta);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
await ctx.reply(`\u274C Could not connect: ${(err as Error).message}`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
bot.callbackQuery(new RegExp(`^hist:${UUID}$`), async (ctx) => {
|
|
119
|
+
const id = ctx.match![1]!;
|
|
120
|
+
await ctx.answerCallbackQuery();
|
|
121
|
+
const meta = deps.store.get(id);
|
|
122
|
+
await showHistory(deps, ctx.chat!.id, id, meta);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
bot.callbackQuery(new RegExp(`^watch:${UUID}$`), async (ctx) => {
|
|
126
|
+
const id = ctx.match![1]!;
|
|
127
|
+
await ctx.answerCallbackQuery();
|
|
128
|
+
const meta = deps.store.get(id);
|
|
129
|
+
const rt = deps.registry.get(ctx.chat!.id);
|
|
130
|
+
rt.startWatch(deps.store.jsonlPath(id));
|
|
131
|
+
await ctx.reply(
|
|
132
|
+
`\u{1F4E1} Watching live: ${meta?.title ?? id.slice(0, 8)}\nNew activity streams here. Send /unwatch to stop.`,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function connectMessage(result: "resumed" | "forked", meta: SessionMeta): string {
|
|
138
|
+
if (result === "resumed") {
|
|
139
|
+
return `\u2705 Resumed: ${meta.title}\n${meta.cwd}\n\nSend a message to continue.`;
|
|
140
|
+
}
|
|
141
|
+
return [
|
|
142
|
+
`\u26A0\uFE0F ${meta.title} is live on your PC right now, so Grok keeps it locked.`,
|
|
143
|
+
`I opened a linked continuation here in the same project with its recent context.`,
|
|
144
|
+
`${meta.cwd}`,
|
|
145
|
+
``,
|
|
146
|
+
`Send a message to keep going \u2014 or tap \u{1F4E1} to watch the original live.`,
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System commands: /queue /clearqueue /model /restart.
|
|
3
|
+
*/
|
|
4
|
+
import type { Bot } from "grammy";
|
|
5
|
+
import type { BotDeps } from "../deps.js";
|
|
6
|
+
|
|
7
|
+
export function registerSystem(bot: Bot, deps: BotDeps): void {
|
|
8
|
+
bot.command("queue", async (ctx) => {
|
|
9
|
+
const rt = deps.registry.get(ctx.chat.id);
|
|
10
|
+
if (rt.queueLength === 0) {
|
|
11
|
+
await ctx.reply("Queue is empty. Send a message while I'm busy, or use /btw <text>.");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
await ctx.reply(`\u{1F4E5} ${rt.queueLength} follow-up(s) queued. They run automatically after the current turn, or use /flush.`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
bot.command("clearqueue", async (ctx) => {
|
|
18
|
+
const rt = deps.registry.get(ctx.chat.id);
|
|
19
|
+
const n = rt.clearQueue();
|
|
20
|
+
await ctx.reply(n > 0 ? `\u{1F5D1} Cleared ${n} queued message(s).` : "Queue was already empty.");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
bot.command("model", async (ctx) => {
|
|
24
|
+
const modelId = (ctx.match || "").toString().trim();
|
|
25
|
+
const rt = deps.registry.get(ctx.chat.id);
|
|
26
|
+
if (!modelId) {
|
|
27
|
+
await ctx.reply("Usage: /model <model-id> (changes the model for the current session)");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!rt.sessionId) {
|
|
31
|
+
await ctx.reply("No active session yet. Send a message or pick a /projects folder first.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await deps.acp.setModel(rt.sessionId, modelId);
|
|
36
|
+
await ctx.reply(`\u2705 Model set to \`${modelId}\` for this session.`, { parse_mode: "Markdown" });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
await ctx.reply(`\u274C Could not set model: ${(err as Error).message}`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
bot.command("restart", async (ctx) => {
|
|
43
|
+
await ctx.reply("\u{1F501} Restarting the Grok agent\u2026");
|
|
44
|
+
try {
|
|
45
|
+
await deps.acp.restart();
|
|
46
|
+
await ctx.reply("\u2705 Grok agent restarted. Your session will re-bind on the next message.");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
await ctx.reply(`\u274C Restart failed: ${(err as Error).message}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /tasks — manage scheduled tasks (create, list, view, edit, delete, run now).
|
|
3
|
+
* /newtask — start the creation wizard.
|
|
4
|
+
*
|
|
5
|
+
* A task is a prompt + a project + a schedule (once/daily/weekly/monthly/
|
|
6
|
+
* interval). The scheduler runs it and delivers the result here.
|
|
7
|
+
*/
|
|
8
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
import type { BotDeps } from "../deps.js";
|
|
11
|
+
import { describeSchedule } from "../../tasks/schedule.js";
|
|
12
|
+
import type { Task } from "../../tasks/types.js";
|
|
13
|
+
import type { ScheduleType } from "../../tasks/types.js";
|
|
14
|
+
import type { WizardPrompt } from "../wizard/task-wizard.js";
|
|
15
|
+
import { sendProjectMenu } from "./projects.js";
|
|
16
|
+
|
|
17
|
+
const UUID = "([0-9a-fA-F-]{36})";
|
|
18
|
+
|
|
19
|
+
export async function showTasks(ctx: Context, deps: BotDeps): Promise<void> {
|
|
20
|
+
await deps.ephemeral.open(ctx);
|
|
21
|
+
const { text, kb } = listView(deps, ctx.chat!.id);
|
|
22
|
+
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function renderWizardPrompt(ctx: Context, deps: BotDeps, p: WizardPrompt): Promise<void> {
|
|
26
|
+
switch (p.kind) {
|
|
27
|
+
case "text":
|
|
28
|
+
await ctx.reply(p.text);
|
|
29
|
+
return;
|
|
30
|
+
case "project":
|
|
31
|
+
await sendProjectMenu(ctx, deps, "wiz:proj:", p.text);
|
|
32
|
+
return;
|
|
33
|
+
case "scheduleType": {
|
|
34
|
+
const kb = new InlineKeyboard()
|
|
35
|
+
.text("Once", "wiz:sched:once")
|
|
36
|
+
.text("Daily", "wiz:sched:daily")
|
|
37
|
+
.row()
|
|
38
|
+
.text("Weekly", "wiz:sched:weekly")
|
|
39
|
+
.text("Monthly", "wiz:sched:monthly")
|
|
40
|
+
.row()
|
|
41
|
+
.text("Every N minutes", "wiz:sched:interval");
|
|
42
|
+
await ctx.reply(p.text, { reply_markup: kb });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
case "confirm": {
|
|
46
|
+
const kb = new InlineKeyboard().text("\u2705 Save", "wiz:confirm").text("\u2716 Cancel", "wiz:cancel");
|
|
47
|
+
await ctx.reply(p.text, { reply_markup: kb });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
case "done":
|
|
51
|
+
await ctx.reply(p.text);
|
|
52
|
+
return;
|
|
53
|
+
case "aborted":
|
|
54
|
+
await ctx.reply("Cancelled.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Wizard text-input interceptor. Registered BEFORE command/prompt handlers so
|
|
61
|
+
* that, while a task wizard is active, free text feeds the wizard. A slash
|
|
62
|
+
* command aborts the wizard and is allowed through.
|
|
63
|
+
*/
|
|
64
|
+
export function registerWizardInput(bot: Bot, deps: BotDeps): void {
|
|
65
|
+
bot.on("message:text", async (ctx, next) => {
|
|
66
|
+
const chatId = ctx.chat.id;
|
|
67
|
+
if (!deps.wizard.isActive(chatId)) return next();
|
|
68
|
+
const text = ctx.message.text;
|
|
69
|
+
if (text.startsWith("/")) {
|
|
70
|
+
deps.wizard.abort(chatId);
|
|
71
|
+
return next();
|
|
72
|
+
}
|
|
73
|
+
const p = deps.wizard.handleText(chatId, text);
|
|
74
|
+
if (p) await renderWizardPrompt(ctx, deps, p);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function registerTasks(bot: Bot, deps: BotDeps): void {
|
|
79
|
+
bot.command("tasks", (ctx) => showTasks(ctx, deps));
|
|
80
|
+
bot.command("newtask", async (ctx) => {
|
|
81
|
+
await renderWizardPrompt(ctx, deps, deps.wizard.startCreate(ctx.chat.id));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
bot.callbackQuery("task:new", async (ctx) => {
|
|
85
|
+
await ctx.answerCallbackQuery();
|
|
86
|
+
await renderWizardPrompt(ctx, deps, deps.wizard.startCreate(ctx.chat!.id));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
bot.callbackQuery(new RegExp(`^task:view:${UUID}$`), async (ctx) => {
|
|
90
|
+
await ctx.answerCallbackQuery();
|
|
91
|
+
const task = deps.tasks.get(ctx.match![1]!);
|
|
92
|
+
if (!task) return void ctx.editMessageText("Task not found.");
|
|
93
|
+
const { text, kb } = detailView(task);
|
|
94
|
+
await ctx.editMessageText(text, { reply_markup: kb });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
bot.callbackQuery("task:list", async (ctx) => {
|
|
98
|
+
await ctx.answerCallbackQuery();
|
|
99
|
+
const { text, kb } = listView(deps, ctx.chat!.id);
|
|
100
|
+
await ctx.editMessageText(text, { reply_markup: kb });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
bot.callbackQuery(new RegExp(`^task:toggle:${UUID}$`), async (ctx) => {
|
|
104
|
+
const task = deps.tasks.get(ctx.match![1]!);
|
|
105
|
+
if (!task) return void ctx.answerCallbackQuery({ text: "Not found" });
|
|
106
|
+
const updated = deps.tasks.update(task.id, { enabled: !task.enabled });
|
|
107
|
+
await ctx.answerCallbackQuery({ text: updated?.enabled ? "Enabled" : "Disabled" });
|
|
108
|
+
if (updated) {
|
|
109
|
+
const { text, kb } = detailView(updated);
|
|
110
|
+
await ctx.editMessageText(text, { reply_markup: kb });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
bot.callbackQuery(new RegExp(`^task:run:${UUID}$`), async (ctx) => {
|
|
115
|
+
const task = deps.tasks.get(ctx.match![1]!);
|
|
116
|
+
if (!task) return void ctx.answerCallbackQuery({ text: "Not found" });
|
|
117
|
+
await ctx.answerCallbackQuery({ text: "Running now\u2026" });
|
|
118
|
+
void deps.taskRunner.run(task);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
bot.callbackQuery(new RegExp(`^task:del:${UUID}$`), async (ctx) => {
|
|
122
|
+
deps.tasks.delete(ctx.match![1]!);
|
|
123
|
+
await ctx.answerCallbackQuery({ text: "Deleted" });
|
|
124
|
+
const { text, kb } = listView(deps, ctx.chat!.id);
|
|
125
|
+
await ctx.editMessageText(text, { reply_markup: kb });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
bot.callbackQuery(new RegExp(`^task:editmenu:${UUID}$`), async (ctx) => {
|
|
129
|
+
await ctx.answerCallbackQuery();
|
|
130
|
+
const task = deps.tasks.get(ctx.match![1]!);
|
|
131
|
+
if (!task) return;
|
|
132
|
+
await ctx.editMessageText(`Edit "${task.name}" — choose what to change:`, {
|
|
133
|
+
reply_markup: editMenu(task.id),
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
bot.callbackQuery(new RegExp(`^task:edit:(name|prompt|project|schedule):${UUID}$`), async (ctx) => {
|
|
138
|
+
await ctx.answerCallbackQuery();
|
|
139
|
+
const field = ctx.match![1] as "name" | "prompt" | "project" | "schedule";
|
|
140
|
+
const p = deps.wizard.startEdit(ctx.chat!.id, ctx.match![2]!, field);
|
|
141
|
+
if (p) await renderWizardPrompt(ctx, deps, p);
|
|
142
|
+
else await ctx.reply("Task not found.");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── wizard inline steps ────────────────────────────────────────────────
|
|
146
|
+
bot.callbackQuery(/^wiz:proj:(\d+)$/, async (ctx) => {
|
|
147
|
+
const entry = deps.menuCache.getProject(ctx.chat!.id, Number(ctx.match![1]));
|
|
148
|
+
if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, restart the task." });
|
|
149
|
+
await ctx.answerCallbackQuery();
|
|
150
|
+
const p = deps.wizard.setProject(ctx.chat!.id, entry.path, entry.name);
|
|
151
|
+
if (p) await renderWizardPrompt(ctx, deps, p);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
bot.callbackQuery(/^wiz:sched:(once|daily|weekly|monthly|interval)$/, async (ctx) => {
|
|
155
|
+
await ctx.answerCallbackQuery();
|
|
156
|
+
const p = deps.wizard.setScheduleType(ctx.chat!.id, ctx.match![1] as ScheduleType);
|
|
157
|
+
if (p) await renderWizardPrompt(ctx, deps, p);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
bot.callbackQuery("wiz:confirm", async (ctx) => {
|
|
161
|
+
await ctx.answerCallbackQuery();
|
|
162
|
+
const p = deps.wizard.confirm(ctx.chat!.id);
|
|
163
|
+
if (p) await renderWizardPrompt(ctx, deps, p);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
bot.callbackQuery("wiz:cancel", async (ctx) => {
|
|
167
|
+
await ctx.answerCallbackQuery();
|
|
168
|
+
deps.wizard.abort(ctx.chat!.id);
|
|
169
|
+
await ctx.editMessageText("Cancelled.");
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── views ────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function listView(deps: BotDeps, chatId: number): { text: string; kb: InlineKeyboard } {
|
|
176
|
+
const tasks = deps.tasks.forChat(chatId);
|
|
177
|
+
const kb = new InlineKeyboard();
|
|
178
|
+
if (tasks.length === 0) {
|
|
179
|
+
kb.text("\u2795 New task", "task:new");
|
|
180
|
+
return { text: "You have no scheduled tasks yet.", kb };
|
|
181
|
+
}
|
|
182
|
+
for (const t of tasks) {
|
|
183
|
+
const dot = t.enabled ? "\u{1F7E2}" : "\u26AA";
|
|
184
|
+
const name = t.name.length > 24 ? t.name.slice(0, 24) + "\u2026" : t.name;
|
|
185
|
+
kb.text(`${dot} ${name} \u00B7 ${describeSchedule(t.schedule)}`, `task:view:${t.id}`).row();
|
|
186
|
+
}
|
|
187
|
+
kb.text("\u2795 New task", "task:new");
|
|
188
|
+
return { text: `\u{1F5D3} Your scheduled tasks (${tasks.length}):`, kb };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function detailView(t: Task): { text: string; kb: InlineKeyboard } {
|
|
192
|
+
const next = t.nextRun ? new Date(t.nextRun).toLocaleString() : "\u2014";
|
|
193
|
+
const last = t.lastRun ? `${new Date(t.lastRun).toLocaleString()} (${t.lastStatus ?? "?"})` : "never";
|
|
194
|
+
const prompt = t.prompt.length > 300 ? t.prompt.slice(0, 300) + "\u2026" : t.prompt;
|
|
195
|
+
const text = [
|
|
196
|
+
`\u{1F5D3} ${t.name} ${t.enabled ? "\u{1F7E2} enabled" : "\u26AA disabled"}`,
|
|
197
|
+
`\u{1F4C1} Project: ${t.projectName || basename(t.projectPath)}`,
|
|
198
|
+
`\u{1F501} Schedule: ${describeSchedule(t.schedule)}`,
|
|
199
|
+
`\u23ED Next run: ${next}`,
|
|
200
|
+
`\u23EE Last run: ${last}`,
|
|
201
|
+
"",
|
|
202
|
+
`\u{1F4AC} ${prompt}`,
|
|
203
|
+
].join("\n");
|
|
204
|
+
const kb = new InlineKeyboard()
|
|
205
|
+
.text("\u25B6 Run now", `task:run:${t.id}`)
|
|
206
|
+
.text(t.enabled ? "\u23F8 Disable" : "\u25B6 Enable", `task:toggle:${t.id}`)
|
|
207
|
+
.row()
|
|
208
|
+
.text("\u270F\uFE0F Edit", `task:editmenu:${t.id}`)
|
|
209
|
+
.text("\u{1F5D1} Delete", `task:del:${t.id}`)
|
|
210
|
+
.row()
|
|
211
|
+
.text("\u2B05 Back", "task:list");
|
|
212
|
+
return { text, kb };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function editMenu(id: string): InlineKeyboard {
|
|
216
|
+
return new InlineKeyboard()
|
|
217
|
+
.text("Rename", `task:edit:name:${id}`)
|
|
218
|
+
.text("Prompt", `task:edit:prompt:${id}`)
|
|
219
|
+
.row()
|
|
220
|
+
.text("Project", `task:edit:project:${id}`)
|
|
221
|
+
.text("Schedule", `task:edit:schedule:${id}`)
|
|
222
|
+
.row()
|
|
223
|
+
.text("\u2B05 Back", `task:view:${id}`);
|
|
224
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /usage — show account info and the current session's context usage.
|
|
3
|
+
*/
|
|
4
|
+
import type { Bot, Context } from "grammy";
|
|
5
|
+
import type { BotDeps } from "../deps.js";
|
|
6
|
+
|
|
7
|
+
export async function showUsage(ctx: Context, deps: BotDeps): Promise<void> {
|
|
8
|
+
await ctx.replyWithChatAction("typing").catch(() => {});
|
|
9
|
+
const rt = deps.registry.get(ctx.chat!.id);
|
|
10
|
+
const acct = await deps.usage.account();
|
|
11
|
+
const meta = rt.contextInfo();
|
|
12
|
+
const ctx100 = meta?.contextUsagePercentage;
|
|
13
|
+
const savedCount = deps.accounts.list().length;
|
|
14
|
+
|
|
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}` : ""}` : "",
|
|
19
|
+
"",
|
|
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` : "",
|
|
27
|
+
"",
|
|
28
|
+
"\u2139\uFE0F Full billing/quota lives in the Grok app; grok doesn't expose limits headlessly.",
|
|
29
|
+
].filter(Boolean);
|
|
30
|
+
|
|
31
|
+
if (!acct) lines.splice(1, 0, "(account info unavailable \u2014 is grok logged in?)");
|
|
32
|
+
await deps.ephemeral.open(ctx);
|
|
33
|
+
await deps.ephemeral.reply(ctx, lines.join("\n"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerUsage(bot: Bot, deps: BotDeps): void {
|
|
37
|
+
bot.command("usage", (ctx) => showUsage(ctx, deps));
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice & audio handler — transcribes Telegram voice notes / audio files to
|
|
3
|
+
* text (any language) and submits them as prompts.
|
|
4
|
+
*/
|
|
5
|
+
import type { Bot, Context } from "grammy";
|
|
6
|
+
import { textPrompt } from "../../app/types.js";
|
|
7
|
+
import { createLogger } from "../../logger.js";
|
|
8
|
+
import type { BotDeps } from "../deps.js";
|
|
9
|
+
import { extractReplyContext } from "../reply-context.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("voice");
|
|
12
|
+
|
|
13
|
+
export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
14
|
+
const handle = async (ctx: Context, fileId: string, mime: string, name: string): Promise<void> => {
|
|
15
|
+
const chatId = ctx.chat!.id;
|
|
16
|
+
if (deps.wizard.isActive(chatId)) {
|
|
17
|
+
await ctx.reply("Finish or /cancel the task wizard before sending voice.");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!deps.stt.enabled) {
|
|
21
|
+
await ctx.reply("\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY) in .env.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
await ctx.replyWithChatAction("typing").catch(() => {});
|
|
25
|
+
try {
|
|
26
|
+
const bytes = await download(ctx, fileId, deps.cfg.token);
|
|
27
|
+
if (!bytes) throw new Error("could not download the audio");
|
|
28
|
+
const text = await deps.stt.transcribe(bytes, mime, name);
|
|
29
|
+
if (!text) {
|
|
30
|
+
await ctx.reply("\u{1F399} I couldn't make out any speech.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await ctx.reply(`\u{1F399} \u201C${text}\u201D`);
|
|
34
|
+
const rt = deps.registry.get(chatId);
|
|
35
|
+
const quoted = extractReplyContext(ctx);
|
|
36
|
+
const outcome = await rt.submit(textPrompt(text, ctx.message?.message_id, quoted));
|
|
37
|
+
if (outcome === "queued") await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.");
|
|
38
|
+
} catch (e) {
|
|
39
|
+
log.warn("voice failed:", (e as Error).message);
|
|
40
|
+
await ctx.reply(`\u274C Voice transcription failed: ${(e as Error).message}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
bot.on("message:voice", (ctx) => handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"));
|
|
45
|
+
bot.on("message:audio", (ctx) => handle(ctx, ctx.message.audio.file_id, ctx.message.audio.mime_type || "audio/mpeg", ctx.message.audio.file_name || "audio.mp3"));
|
|
46
|
+
bot.on("message:video_note", (ctx) => handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function download(ctx: Context, fileId: string, token: string): Promise<Buffer | undefined> {
|
|
50
|
+
const file = await ctx.api.getFile(fileId);
|
|
51
|
+
if (!file.file_path) return undefined;
|
|
52
|
+
const res = await fetch(`https://api.telegram.org/file/bot${token}/${file.file_path}`);
|
|
53
|
+
if (!res.ok) throw new Error(`download HTTP ${res.status}`);
|
|
54
|
+
return Buffer.from(await res.arrayBuffer());
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent image return — detects image files the agent produced this turn
|
|
3
|
+
* (screenshots, diagrams…) from its output and tool inputs, and sends them
|
|
4
|
+
* back to Telegram. Only fresh files (modified during the turn) are sent.
|
|
5
|
+
*/
|
|
6
|
+
import { type Api, InputFile } from "grammy";
|
|
7
|
+
import { existsSync, statSync } from "node:fs";
|
|
8
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
9
|
+
import { createLogger } from "../logger.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("image-return");
|
|
12
|
+
|
|
13
|
+
const PATH_RE = /[^\s"'`<>|()*\[\]]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
|
|
14
|
+
const PHOTO_EXT = new Set(["png", "jpg", "jpeg", "webp"]);
|
|
15
|
+
const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
|
|
16
|
+
const MAX_FILE_BYTES = 45 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/** Pull candidate image paths out of arbitrary text, resolved against cwd. */
|
|
19
|
+
export function extractImagePaths(text: string, cwd: string): string[] {
|
|
20
|
+
const out = new Set<string>();
|
|
21
|
+
for (const m of text.matchAll(PATH_RE)) {
|
|
22
|
+
const raw = m[0].replace(/[).,;:]+$/, "");
|
|
23
|
+
out.add(isAbsolute(raw) ? raw : join(cwd, raw));
|
|
24
|
+
}
|
|
25
|
+
return [...out];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SendImagesOptions {
|
|
29
|
+
/** Only send files modified at/after this epoch ms (fresh this turn). */
|
|
30
|
+
since: number;
|
|
31
|
+
/** Paths already sent (mutated to dedupe). */
|
|
32
|
+
already: Set<string>;
|
|
33
|
+
/** Max images to send in this call. */
|
|
34
|
+
max: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Send the valid, fresh, not-yet-sent images. Returns how many were sent. */
|
|
38
|
+
export async function sendImages(
|
|
39
|
+
api: Api,
|
|
40
|
+
chatId: number,
|
|
41
|
+
paths: string[],
|
|
42
|
+
opts: SendImagesOptions,
|
|
43
|
+
): Promise<number> {
|
|
44
|
+
let sent = 0;
|
|
45
|
+
for (const path of paths) {
|
|
46
|
+
if (sent >= opts.max) break;
|
|
47
|
+
if (opts.already.has(path)) continue;
|
|
48
|
+
let st: ReturnType<typeof statSync>;
|
|
49
|
+
try {
|
|
50
|
+
st = statSync(path);
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
|
|
55
|
+
if (st.mtimeMs < opts.since - 2000) continue; // skip pre-existing files
|
|
56
|
+
opts.already.add(path);
|
|
57
|
+
try {
|
|
58
|
+
const ext = path.toLowerCase().split(".").pop() ?? "";
|
|
59
|
+
const asPhoto = PHOTO_EXT.has(ext) && st.size <= MAX_PHOTO_BYTES;
|
|
60
|
+
const file = new InputFile(path);
|
|
61
|
+
if (asPhoto) await api.sendPhoto(chatId, file, { caption: basename(path) });
|
|
62
|
+
else await api.sendDocument(chatId, file, { caption: basename(path) });
|
|
63
|
+
sent++;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log.debug(`failed to send ${path}:`, (e as Error).message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return sent;
|
|
69
|
+
}
|