14 Commits

Author SHA1 Message Date
978211dc65 fix(redstone): close race in withSlowToolIndicator orphaning wiki loop
The setTimeout callback awaited sendMessage before installing the
rotation interval. If the tool resolved during that await, the finally
cleanup ran before rotateTimer existed, leaving an interval that edited
the "reading the wiki…" placeholder forever. Added a `done` flag so the
callback bails (and deletes the placeholder) if the tool already finished.
2026-05-14 12:42:55 +02:00
3647174c30 feat(redstone): /ai_use — OpenRouter key usage report
Admin-only command. Calls GET https://openrouter.ai/api/v1/key (the
REST equivalent of the @openrouter/sdk's apiKeys.getCurrent()) and
formats the result:

- active model (from the settings table)
- tier (🟢 free / 💰 paid)
- credit limit + remaining (or "none")
- credits spent today / week / month / all-time

Plus a note that free-model requests don't draw credits — they're
capped separately at 50/day (UTC-midnight reset), liftable to
1000/day with $10 credit. Kept fetch-based like the rest of the
OpenRouter integration rather than pulling in the SDK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 08:23:08 +02:00
633a4443c2 ux(redstone): surface AI provider failure instead of silent drop
When both OpenRouter AND Gemini return null (no key / 429 / 503 / network)
and no tool ran during the turn, the handler used to silently return,
leaving the user staring at their unanswered message wondering if the
bot was broken. Real cause is usually the daily free-tier rate-limit
exhausting + Gemini's intermittent 503s lining up.

Now post a one-liner reply explaining:
  "🤖 AI is unreachable right now — both OpenRouter and Gemini just
  declined. Usually the free-tier daily cap resetting around UTC
  midnight. Try again in a few minutes, or use /model to pick a
  different model."

Only fires when ranTool is false — if the model successfully called
a tool earlier in the turn (image already sent, message posted, etc.)
the user has already seen something visible and we keep the silent
return for that case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:23 +02:00
a9a8bbe3dd ui(redstone): dismiss /model keyboard on selection
selectModel was re-rendering the full picker with the  moved to the
new pick. Per design feedback, drop the keyboard once a choice is made
— the callback toast plus a confirmation body is enough. Keeps the
chat tidy after the switch.

editMessageText without reply_markup clears the inline keyboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:04:36 +02:00
aae9ddd429 feat(redstone): /model picker (OpenRouter) + fix /clear leaving its own reply
Two changes:

1) /model — admin-only Telegram command that fetches GET
   https://openrouter.ai/api/v1/models, filters to models whose
   supported_parameters includes "tools", pins openrouter/free and
   openrouter/auto to the top, then sorts the rest free-first /
   alpha-by-name. Renders a vertically-stacked InlineKeyboard
   (one model per row, capped at 20) with badges:

      <current>   🟢 free / 💰 paid   🛠 tools   🧠 thinking

   Tap-to-select callback writes the model id into the new
   settings(key,value) table. callOpenRouterOnce resolves the
   model at every call (currentOpenRouterModel() → kvGet ??
   OPENROUTER_MODEL env ?? "openrouter/free"), so the new pick
   takes effect on the very next message — no restart, no
   container rebuild.

2) /clear — was leaving its own confirmation message ("🧹 cleared
   N messages from this chat.") in the DB, so the next /clear
   would count it. Now wipes once, replies, wipes again — the
   chat's messages row count truly stays at 0 across repeats.

Schema migration: CREATE TABLE IF NOT EXISTS settings(key, value,
updated_at) — idempotent, no Bun migration needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:00:08 +02:00
7217e3553b feat(redstone): wiki_recipe_image — composite real recipe PNG and upload
Previous wiki_recipe only returned the page thumbnail, so "send me the
recipe image" delivered a picture of the item, not the recipe itself.
minecraft.wiki renders recipes as live HTML grids, not standalone PNGs,
so we now generate the image ourselves.

mcp/lib/minecraft-wiki-tools.ts:
- parseCraftingGrid(html): pulls the 3×3 input grid + result item URL
  from <span class="mcui mcui-Crafting_Table">. Each <span class="invslot">
  is either empty (chunk starts with </span>) or has an <img src="/images/
  Invicon_X.png?h">. Reading-order chunks → grid; first non-null after
  the 9th input slot → result.
- composeRecipePng(grid, result): fetches each unique sprite, resizes
  to 64×64 via sharp (nearest-neighbour to keep pixel art crisp),
  composites onto a #8b8b8b inventory-style background with #6f6f6f
  slot squares + dark borders, places an SVG-rendered "→" arrow,
  then the result cell next to it. PNG buffer returned.
- New tool wiki_recipe_image(title, chat, caption?) that runs the
  pipeline end-to-end and uploads via tg.sendPhotoBytes — one tool
  call, image already in the chat when it returns.

mcp/lib/types.ts:
- TgClient gains sendPhotoBytes({ chat_id, bytes, filename?, caption?,
  parse_mode?, reply_to_message_id? }). Multipart/form-data POST to
  sendPhoto, since Telegram's URL-mode can only fetch public URLs and
  the composited PNG only lives in memory. 30s timeout (vs the 20s on
  the JSON call) because uploads are bigger.

mcp/package.json + bot/package.json: add sharp ^0.33.5. mcp/ pulls it
in too so /mc/mcp/lib resolves it at the bot's runtime (the bot's
node_modules is at /app while the lib files live at /mc/mcp/lib).

Persona prompt: wiki_recipe_image is now the FIRST call for any
"recipe" / "send me the recipe image" request. Single tool turn ends
with the photo already in the chat. wiki_recipe stays as a fallback
when the page has no 3×3 crafting widget (smelting / brewing / smithing
use different mcui classes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:54:20 +02:00
f1d4fb596a feat(redstone): wiki_recipe tool — extract recipe + thumbnail in one call
Adds a new MCP tool that parses minecraft.wiki's <table data-description=
"Crafting recipes"> directly, pulling the ingredients column out of each
row (e.g. "Block of Iron + Iron Ingot" for Anvil), then chases up the
page's main item thumbnail via prop=pageimages so the bot can deliver
text + a real image in one tool round.

Why a dedicated tool: minecraft.wiki does not host standalone "recipe.png"
files — recipes are rendered live as HTML grids composed from individual
BlockSprite icons. Filtering wiki_page_images for 'recipe' / 'craft' /
'grid' returned empty for every craftable item, leaving the model with
nothing to call tg_send_photo with. Confirmed by inspecting the Anvil
page's image inventory (Anvil1-6.png + GUI + sound files, no recipe).

Tool returns {
  title, page_url,
  recipes: [{ ingredients: "…" }, …],   // strips <br>/<a>/&nbsp; cleanly
  thumbnail_url,                          // for tg_send_photo
  note?,                                  // present when no recipe rows
}

Persona prompt tightened:
- wiki_recipe is now the FIRST call for "how do I craft X" / "recipe for X"
- wiki_page_images is downgraded to "specific non-thumbnail images only"
- IMAGE DELIVERY MANDATORY clause: must end the turn with tg_send_photo
  using a real URL or say plainly that no thumbnail was found — no more
  describing-the-image-in-prose-and-stopping failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:47:25 +02:00
8ae89610f3 fix(redstone): force image delivery + raise tool-loop ceiling + log tool calls
Reproduced: "send me the recipe for an anvil" got a TEXT reply describing
the recipe but no image. Three changes to fix this class of problem:

1) Iteration cap 4 → 6. The wiki+photo flow is naturally
   wiki_search → wiki_page → wiki_page_images → tg_send_photo → final
   text — five tool calls + one text response. With only 4 iterations
   the model often ran out before reaching tg_send_photo or before
   emitting closing text, returning {text:null, ranTool:true} which
   the handler swallows silently.

2) Persona prompt: image delivery is now MANDATORY when the user asks
   for a picture / image / recipe / "what it looks like". Added an
   explicit fallback chain: filter='recipe' → 'craft' → 'grid' → the
   wiki_page thumbnail_url. The previous phrasing was suggestive only,
   which let the model answer in pure text.

3) Tool-call tracing. Both dispatchers now log `[redstone:or|gem]
   tool -> name args` before each call and `tool <- name ok|err msg`
   after. Without this, silent "model called tools but no image
   showed up" failures had no log line at all and we had to guess.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:41:44 +02:00
811bf582c6 feat(redstone): slow-tool indicator + persona-name addressing
Two changes that came out of a real "I said hey steve, bot didn't reply"
report:

1) Persona-name addressing in groups. botAddressed now ALSO triggers
   when the message text contains the bot's persona name (regex
   /\b(redstone|steve)\b/i, case-insensitive, word-boundary). The
   persona prompt already invites users to call the bot "Redstone" or
   "Steve" — without this matcher, those messages were silently
   dropped in groups because Telegram didn't recognise the plain
   word as a @-mention. Private chats still trigger on every text
   message (unchanged).

2) Slow-tool indicator for wiki_* calls. minecraft.wiki can lag /
   rate-limit. New withSlowToolIndicator(chatId, toolName, fn) wraps
   the tool execution: if the call takes longer than 1500 ms,
   send a "🔍 looking it up…" placeholder message into the chat
   and rotate through a 4-phrase loop ("📖 reading the wiki…",
   "🔎 searching…", "🧭 cross-referencing…") every 900 ms. When
   the tool resolves, the timer + interval are cleared and the
   placeholder is deleted, so fast calls (<1.5 s) leave no trace
   in the chat. Only fires for tools whose name matches /^wiki_/.

Wired through the dispatch chain — runRedstoneTurn,
runRedstoneTurnViaOpenRouter, and runRedstoneTurnViaGemini all
gained a `chatId` parameter so the indicator knows which chat to
post into.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:36:11 +02:00
f05db39903 feat(redstone): connect minecraft.wiki via three new MCP tools
Redstone can now query the official Minecraft wiki and deliver
crafting recipe / item images straight into the chat.

New: mcp/lib/minecraft-wiki-tools.ts exports three tools, all hitting
https://minecraft.wiki/api.php (MediaWiki API, formatversion=2):

- wiki_search(query, limit?) — opensearch top results, namespace 0,
  returns { title, description, url } for each hit.
- wiki_page(title, thumb_size?) — extracts|pageimages → intro extract
  (plain text, ~600 chars) + thumbnail_url + page_url.
- wiki_page_images(title, filter?, limit?) — generator=images +
  prop=imageinfo (iiprop=url|mime|size) returns every image embedded
  on the page along with its direct https URL. The `filter`
  substring is case-insensitive against the filename — pass 'recipe'
  / 'craft' / 'grid' to surface crafting-recipe diagrams.

Bot wiring (bot/bot.ts):
- import minecraftWikiTools, merge into allTools so it flows through
  the existing OpenRouter + Gemini function/tool schemas automatically.
- Persona prompt teaches the model the canonical flow:
  wiki_search → wiki_page → wiki_page_images(filter='recipe') →
  tg_send_photo(url, caption). Composes with the already-existing
  tg_send_photo tool — no new telegram tool needed.

User-Agent on every wiki request, 15s timeout, JSON-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:29:45 +02:00
404fee3405 ui(bot): simplify /status panel — one power toggle, cleaner layout
Replaces the old "Up All / Server ▾ / Tunnel ▾" five-button grid with a
single power-toggle button (▶️ Start / 🛑 Stop) plus three flat-nav
buttons (Logs / Users / Refresh). Granular per-service control stays
reachable via the existing /server_up, /server_down, /pf_up, /pf_down
slash commands.

Message body redesigned: top-line health pill (🟢 Online / 🟡 Partial /
🔴 Offline), then aligned status rows for Server and Tunnel, then the
update timestamp. Uses the same 🟢/🔴 pair throughout for consistency,
dropping the mixed / + 🟢/🟡 from before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:20:52 +02:00
b99c36c13c fix(bot): scope /up to server+tunnel — bare compose up -d killed the bot
actUpAll was running `docker compose up -d` with no service filter, so it
re-evaluated every service in the project (including the bot). docker
compose then recreated the bot container, SIGTERM'd the running process
mid-RPC, and left a renamed replacement (`<oldhash>_minecraft-bot`) stuck
in "Created" state because the original container hadn't yet released
the name. End result: /up silently took the bot offline and required
manual `docker rm + docker compose up -d bot` to recover.

Scope to SERVER_SVC + PF_SVC (matching actDownAll which already does the
same thing). The bot can't sensibly restart itself anyway.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:13:42 +02:00
bc87681085 fix(redstone): use plain reply in non-private chats (TEXTDRAFT_PEER_INVALID)
sendMessageDraft (Bot API 9.5) only works in private chats. Group,
supergroup and channel peers reject it with TEXTDRAFT_PEER_INVALID,
so Redstone was silently failing the entire reply path in groups
after the streaming feature landed in cab5337.

Keep replyWithStream for private chats (the typing animation is
the whole point); fall back to plain ctx.reply() everywhere else.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:59:09 +02:00
4ebbcef76b feat(redstone): add OpenRouter primary AI with Gemini fallback
Redstone now hits OpenRouter's `openrouter/free` auto-router first,
falling back to direct Gemini if the OpenRouter call fails (no key,
network error, or `:free` upstream rejected tool use). The auto-
router filters its free-model pool by the request's tool-calling
requirement, so we don't have to pin a specific free model.

- bot/bot.ts:
  - OPENROUTER_API_KEY / _FILE env (mirrors GEMINI_API_KEY_FILE)
  - OPENROUTER_MODEL defaults to "openrouter/free"
  - openRouterTools[] derived from existing geminiFunctionDeclarations
    (OpenAI-style {type:"function", function:{name,description,parameters}})
  - callOpenRouterOnce + runRedstoneTurnViaOpenRouter (parses JSON-string
    tool_calls.arguments, replies with role:"tool" + tool_call_id)
  - Existing Gemini path moved into runRedstoneTurnViaGemini
  - runRedstoneTurn dispatches OpenRouter first, Gemini on null
  - Early-return gate now passes if either key is configured

- docker-compose.yml:
  - new openrouter_token secret -> ./openrouter.token (gitignored)
  - OPENROUTER_API_KEY_FILE + OPENROUTER_MODEL env wired to the bot

- .gitignore: add openrouter.token (plus pre-existing gitea.token entry
  that was sitting uncommitted).

The key file itself is NOT committed (verified via git check-ignore).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:42:33 +02:00
7 changed files with 1015 additions and 28 deletions

6
.gitignore vendored
View File

@@ -32,6 +32,12 @@ bot/data/
# Google Generative Language API key
google_key.txt
# Gitea API token (mounted as a docker secret into the bot for release uploads)
gitea.token
# OpenRouter API key
openrouter.token
# MCP server
mcp/node_modules/
mcp/bun.lock

View File

@@ -1,4 +1,4 @@
import { Bot, Context, GrammyError, HttpError, InlineKeyboard } from "grammy";
import { Bot, Context, GrammyError, HttpError, InlineKeyboard, InputFile } from "grammy";
import { autoRetry } from "@grammyjs/auto-retry";
import { stream, type StreamFlavor } from "@grammyjs/stream";
@@ -41,6 +41,20 @@ const GEMINI_API_KEY = (
const GEMINI_MODEL = (process.env.GEMINI_MODEL ?? "gemini-2.5-flash-lite").trim();
const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
// ---- OpenRouter (primary) ----
// `openrouter/free` is OpenRouter's auto-router across free models; it filters
// for models that support whatever features the request needs (tool calling in
// our case). Falls back to direct Gemini below if OpenRouter returns no key,
// errors, or a `:free` upstream rejects tool use.
const OPENROUTER_API_KEY = (
process.env.OPENROUTER_API_KEY ??
(process.env.OPENROUTER_API_KEY_FILE && (() => { try { return readFileSync(process.env.OPENROUTER_API_KEY_FILE!, "utf8"); } catch { return ""; } })() || "")
).trim();
const OPENROUTER_MODEL = (process.env.OPENROUTER_MODEL ?? "openrouter/free").trim();
const OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
const OPENROUTER_REFERER = (process.env.OPENROUTER_REFERER ?? "https://thinkstation-web.spot-bot.co.za").trim();
const OPENROUTER_TITLE = (process.env.OPENROUTER_TITLE ?? "Redstone (minecraft-telegram-bot)").trim();
// ---- DB ----
mkdirSync(dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
@@ -89,6 +103,11 @@ db.exec(`
);
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
ON sticker_library(last_seen DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Idempotent migrations.
@@ -273,6 +292,11 @@ function unresolvedHelp(): string {
// ---- docker compose helpers ----
async function compose(...args: string[]): Promise<{ ok: boolean; out: string }> {
// Auto-inject --remove-orphans on `up` so leftover stale containers from prior
// runs don't keep the new ones off the compose network (DNS misses on service names).
if (args[0] === "up" && !args.includes("--remove-orphans")) {
args = ["up", "--remove-orphans", ...args.slice(1)];
}
const proc = Bun.spawn(
[
"docker", "compose",
@@ -498,18 +522,24 @@ function fmtTs(): string {
async function renderMain(ctx: Context) {
const [mc, pf] = await Promise.all([serverRunning(), pfRunning()]);
const allUp = mc && pf;
const allDown = !mc && !pf;
const headline = allUp ? "🟢 <b>Online</b>" : allDown ? "🔴 <b>Offline</b>" : "🟡 <b>Partial</b>";
const dot = (ok: boolean) => (ok ? "🟢" : "🔴");
const text =
`<b>🎮 Minecraft Control</b>\n` +
`🎮 <b>Minecraft</b>\n` +
`${headline}\n` +
`\n` +
`server : ${mc ? "✅ up" : "⛔ down"}\n` +
`tunnel : ${pf ? "✅ up" : "⛔ down"}\n` +
`${dot(mc)} Server\n` +
`${dot(pf)} Tunnel\n` +
`\n` +
`<i>updated ${fmtTs()}</i>`;
`<i> ${fmtTs()}</i>`;
// Single control button: power toggle. Drives both server + tunnel at once
// via actUpAll / actDownAll. Granular per-service control is still reachable
// via the /server_up, /server_down, /pf_up, /pf_down slash commands.
const kb = new InlineKeyboard()
.text(mc && pf ? "⏹ Down All" : "▶ Up All", mc && pf ? "act:downall" : "act:upall").row()
.text(mc ? "🟢 Server ▾" : "🟢 Server ▾", "nav:server").text(pf ? "🟡 Tunnel ▾" : "🟡 Tunnel ▾", "nav:tunnel").row()
.text("📜 Logs", "nav:logs").text("👥 Users", "nav:users").row()
.text("🔄 Refresh", "nav:main");
.text(allUp ? "🛑 Stop" : "▶️ Start", allUp ? "act:downall" : "act:upall").row()
.text("📜 Logs", "nav:logs").text("👥 Users", "nav:users").text("🔄", "nav:main");
return editPanel(ctx, "main", text, kb);
}
@@ -567,13 +597,174 @@ async function renderConfirm(ctx: Context, action: string, prompt: string) {
return editPanel(ctx, "main", text, kb);
}
// ---- OpenRouter model picker ----
type ORModelListItem = {
id: string;
name?: string;
description?: string;
pricing?: { prompt?: string; completion?: string };
supported_parameters?: string[];
context_length?: number;
};
function isFreeModel(m: ORModelListItem): boolean {
if (m.id.endsWith(":free") || m.id === "openrouter/free") return true;
const p = Number.parseFloat(m.pricing?.prompt ?? "");
const c = Number.parseFloat(m.pricing?.completion ?? "");
return Number.isFinite(p) && Number.isFinite(c) && p === 0 && c === 0;
}
async function fetchOpenRouterModels(): Promise<ORModelListItem[]> {
const headers: Record<string, string> = { Accept: "application/json" };
if (OPENROUTER_API_KEY) headers.Authorization = `Bearer ${OPENROUTER_API_KEY}`;
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers,
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`openrouter /models -> ${res.status}`);
const data = (await res.json()) as { data?: ORModelListItem[] };
return data.data ?? [];
}
// OpenRouter key/usage info — GET /api/v1/key (equivalent to the SDK's
// openRouter.apiKeys.getCurrent()). Surfaces credit usage + tier.
type ORKeyInfo = {
label?: string;
is_free_tier?: boolean;
limit?: number | null;
limit_remaining?: number | null;
usage?: number;
usage_daily?: number;
usage_weekly?: number;
usage_monthly?: number;
};
async function fetchOpenRouterKeyInfo(): Promise<ORKeyInfo> {
if (!OPENROUTER_API_KEY) throw new Error("OPENROUTER_API_KEY not configured");
const res = await fetch("https://openrouter.ai/api/v1/key", {
headers: { Authorization: `Bearer ${OPENROUTER_API_KEY}`, Accept: "application/json" },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`openrouter /key -> ${res.status}`);
const data = (await res.json()) as { data?: ORKeyInfo };
return data.data ?? {};
}
async function renderAiUsage(ctx: Context) {
if (!OPENROUTER_API_KEY) {
await sendText(ctx, "OpenRouter is not configured — set OPENROUTER_API_KEY to see usage.");
return;
}
let info: ORKeyInfo;
try {
info = await fetchOpenRouterKeyInfo();
} catch (e) {
await sendText(ctx, `Couldn't load OpenRouter usage: ${escape((e as Error).message)}`);
return;
}
const usd = (n: number | null | undefined) =>
n == null ? "—" : `$${n.toFixed(n < 1 ? 4 : 2)}`;
const tier = info.is_free_tier ? "🟢 free tier" : "💰 paid";
const limitLine = info.limit == null
? "Credit limit: <i>none</i>"
: `Credit limit: ${usd(info.limit)} (remaining ${usd(info.limit_remaining)})`;
const text =
`<b>🤖 OpenRouter usage</b>\n\n` +
`Active model: <code>${escape(currentOpenRouterModel())}</code>\n` +
`Tier: ${tier}\n` +
`${limitLine}\n\n` +
`<b>Credits spent</b>\n` +
`• today: ${usd(info.usage_daily)}\n` +
`• this week: ${usd(info.usage_weekly)}\n` +
`• this month: ${usd(info.usage_monthly)}\n` +
`• all time: ${usd(info.usage)}\n\n` +
`<i>Free-model requests don't consume credits — they're capped separately at 50/day, resetting at UTC midnight. Add $10 of credit to lift that to 1000/day.</i>`;
await sendText(ctx, text);
}
function modelButtonLabel(m: ORModelListItem, isCurrent: boolean): string {
const free = isFreeModel(m);
const thinking = m.supported_parameters?.includes("reasoning") ?? false;
const flag = isCurrent ? "✅ " : "";
// 🟢 free / 💰 paid · 🛠 tools (guaranteed by filter) · 🧠 thinking (or
// a half-width spacer when absent so columns stay aligned in the keyboard).
const badges = `${free ? "🟢" : "💰"}🛠${thinking ? "🧠" : "·"}`;
const name = (m.name || m.id).replace(/\s*\(free\)\s*$/i, "");
return `${flag}${badges} ${name}`.slice(0, 64);
}
async function renderModelPicker(ctx: Context) {
if (!OPENROUTER_API_KEY) {
await sendText(ctx, "OpenRouter is not configured — set OPENROUTER_API_KEY to enable model switching.");
return;
}
const current = currentOpenRouterModel();
let models: ORModelListItem[];
try {
models = await fetchOpenRouterModels();
} catch (e) {
await sendText(ctx, `Couldn't load model list: ${(e as Error).message}`);
return;
}
// Must support tool calling — Redstone won't work otherwise.
const usable = models.filter((m) => m.supported_parameters?.includes("tools"));
// Router models (auto-pickers) pinned to the top, then alpha by display name.
const ROUTER_RE = /^openrouter\/(free|auto)\b/;
const routers = usable.filter((m) => ROUTER_RE.test(m.id));
const rest = usable.filter((m) => !ROUTER_RE.test(m.id))
.sort((a, b) => {
const af = isFreeModel(a), bf = isFreeModel(b);
if (af !== bf) return af ? -1 : 1;
return (a.name || a.id).localeCompare(b.name || b.id);
});
const list = [...routers, ...rest].slice(0, 20); // Telegram keyboard practicality
if (list.length === 0) {
await sendText(ctx, "No tool-capable models returned by OpenRouter.");
return;
}
const kb = new InlineKeyboard();
for (const m of list) {
kb.text(modelButtonLabel(m, m.id === current), `model:${m.id}`).row();
}
const header =
`<b>🧠 LLM model picker</b>\n\n` +
`Active: <code>${escape(current)}</code>\n\n` +
`🟢 free · 💰 paid · 🛠 tools · 🧠 thinking`;
await sendText(ctx, header, kb);
}
async function selectModel(ctx: Context, modelId: string) {
const c = ctx as Context & { user?: User };
if (c.user?.role !== "admin") {
await ctx.answerCallbackQuery({ text: "admin only", show_alert: true });
return;
}
kvSet("llm_model", modelId);
await ctx.answerCallbackQuery({ text: `model set: ${modelId}` });
// Dismiss the keyboard and replace the picker body with a confirmation.
// editMessageText with no reply_markup clears the inline keyboard.
try {
await ctx.editMessageText(
`<b>🧠 LLM model</b>\n\nActive: <code>${escape(modelId)}</code>\n\n<i>Switched. The new model takes effect on the next message.</i>`,
{ parse_mode: "HTML" },
);
} catch {
/* edit can fail if the original message is too old; the toast already confirmed */
}
}
// ---- Action helpers ----
async function answerToast(ctx: Context, text: string) {
if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text }).catch(() => {});
}
async function actUpAll(ctx: Context) {
const r = await compose("up", "-d");
// Only the server + tunnel — never the bot itself. A bare `compose up -d`
// would re-evaluate every service in the project, recreate the bot
// container, SIGTERM the running process mid-call, and leave a renamed
// replacement stuck in "Created" state because the name collision prevented
// it from starting. Matches actDownAll, which already scopes to these two.
const r = await compose("up", "-d", SERVER_SVC, PF_SVC);
await answerToast(ctx, r.ok ? "starting…" : "failed");
await maybeSendSticker(ctx, r.ok ? "up" : "error");
await renderMain(ctx);
@@ -629,6 +820,8 @@ bot.on("callback_query:data", async (ctx) => {
if (data === "act:logs:srv") return void await renderLogs(ctx, "srv");
if (data === "act:logs:pf") return void await renderLogs(ctx, "pf");
if (data.startsWith("model:")) return void await selectModel(ctx, data.slice("model:".length));
if (data === "info:adduser") {
await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true });
return;
@@ -649,8 +842,19 @@ bot.on("callback_query:data", async (ctx) => {
// in-place edits would otherwise be invisible if the existing panel is buried.
// Callback button presses still edit in place (they're tied to a visible panel).
const dropPanel = (ctx: Context) => Q.delPanel.run(ctx.chat!.id, "main");
bot.command("start", async (ctx) => { dropPanel(ctx); await actUpAll(ctx); });
bot.command("stop", async (ctx) => { dropPanel(ctx); await actDownAll(ctx); });
// /start is Telegram's first-contact command — show a welcome + the main panel
// instead of hijacking it to boot the server. Use /up for that.
bot.command("start", async (ctx) => {
const u = (ctx as any).user as User;
const name = escape(u.name ?? "there");
await sendText(
ctx,
`👋 hi ${name}, I'm <b>Redstone</b> — the Minecraft server bot.\n\n` +
`Use <code>/up</code> to start the server, <code>/down</code> to stop it, ` +
`<code>/status</code> for the menu, or <code>/help</code> for everything I can do.`,
);
await renderMain(ctx);
});
bot.command("menu", async (ctx) => { dropPanel(ctx); await renderMain(ctx); });
bot.command("help", async (ctx) => {
await sendText(ctx, helpText((ctx as any).user.role));
@@ -713,6 +917,94 @@ bot.command("pf_down", async (ctx) => { dropPanel(ctx); await actPf(ctx, "do
bot.command("logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "srv"); });
bot.command("pf_logs", async (ctx) => { dropPanel(ctx); await renderLogs(ctx, "pf"); });
// /download — tar.gz the current world and send it to the caller as a Telegram
// document. Flushes the world via rcon first when the server is running so the
// snapshot is consistent. Admin-only and DMs-only (big file, possibly sensitive).
bot.command("download", admin(async (ctx) => {
if (ctx.chat?.type !== "private") {
return void await sendText(ctx, "Use <code>/download</code> in a DM with me — won't dump a world into a group.");
}
const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 16);
const outPath = `/tmp/world-${stamp}.tar.gz`;
await sendText(ctx, "📦 packaging world… this can take a minute.");
// Flush the world via rcon if the server is up, then tar inside the container
// so we use docker's view of the volume (matches what backup.sh does).
const serverUp = await serverRunning();
if (serverUp) {
await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-off"]).exited;
await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-all", "flush"]).exited;
}
try {
const tarCmd = serverUp
? ["docker", "exec", SERVER_CONTAINER, "tar", "-czf", "-", "-C", "/data", "world"]
: ["tar", "-czf", "-", "-C", `${COMPOSE_HOST_DIR}/data`, "world"];
const proc = Bun.spawn(tarCmd, { stdout: "pipe", stderr: "pipe" });
await Bun.write(outPath, proc.stdout);
const code = await proc.exited;
if (code !== 0) {
const err = await new Response(proc.stderr).text();
await sendText(ctx, `❌ tar failed (exit ${code})\n<pre>${escape(err.slice(0, 500))}</pre>`);
return;
}
} finally {
if (serverUp) {
await Bun.spawn(["docker", "exec", SERVER_CONTAINER, "rcon-cli", "save-on"]).exited;
}
}
// Telegram bot API caps document uploads at 50 MB — fail loudly instead of
// a cryptic "Bad Request: file too large" later.
const size = Bun.file(outPath).size;
const MAX_BYTES = 50 * 1024 * 1024;
if (size > MAX_BYTES) {
await sendText(
ctx,
`❌ archive is ${(size / 1024 / 1024).toFixed(1)} MB — Telegram bots can only send up to 50 MB. ` +
`Use <code>/menu</code> → Backup (Gitea release) for big worlds.`,
);
await Bun.spawn(["rm", "-f", outPath]).exited;
return;
}
try {
await ctx.replyWithDocument(new InputFile(outPath), {
caption: `world snapshot · ${(size / 1024 / 1024).toFixed(1)} MB`,
});
} finally {
await Bun.spawn(["rm", "-f", outPath]).exited;
}
}));
// /backup — run backup.sh: flushes the world via rcon (if up), archives it,
// and uploads to Gitea as a release asset with a `backup-<stamp>` tag. The
// label arg becomes a filename + tag suffix. Admin-only, output sent inline.
bot.command("backup", admin(async (ctx) => {
const rawArg = (ctx.match as string).trim();
const labelRe = /^[A-Za-z0-9._-]{1,40}$/;
if (rawArg && !labelRe.test(rawArg)) {
return void await sendText(ctx, "usage: <code>/backup [label]</code> — label must match <code>[A-Za-z0-9._-]{1,40}</code>");
}
await sendText(ctx, `📦 starting backup${rawArg ? ` (label: <code>${escape(rawArg)}</code>)` : ""}`);
const args = rawArg ? ["bash", "/mc/backup.sh", rawArg] : ["bash", "/mc/backup.sh"];
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
const [out, err] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const code = await proc.exited;
const combined = (out + err).trim();
// Surface the "Uploaded: <url>" line as a clickable link if present.
const urlMatch = combined.match(/Uploaded:\s*(\S+)/);
if (code === 0 && urlMatch) {
await sendText(ctx, `✅ backup uploaded — <a href="${escape(urlMatch[1])}">view release</a>`);
} else if (code === 0) {
await sendText(ctx, `✅ backup done\n<pre>${escape(combined.slice(-500))}</pre>`);
} else {
await sendText(ctx, `❌ backup failed (exit ${code})\n<pre>${escape(combined.slice(-500))}</pre>`);
}
}));
// Wipe this chat's audit-log rows from the messages table — shrinks the
// context Redstone pulls into its prompt and clears any prior conversation.
// DMs: any authorized user can clear their own history. Groups: admin-only.
@@ -730,6 +1022,10 @@ bot.command("clear", async (ctx) => {
).get(chatId)?.n ?? 0;
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
await sendText(ctx, `🧹 cleared <b>${before}</b> message${before === 1 ? "" : "s"} from this chat.`);
// sendText auto-logs the confirmation back into messages → the next /clear
// would otherwise count it as a leftover. Wipe again so the chat is truly
// empty afterwards and subsequent /clear keeps returning 0.
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
});
// User management
@@ -859,6 +1155,14 @@ bot.command("teststicker", admin(async (ctx) => {
await maybeSendSticker(ctx, event);
}));
// LLM model picker — admin can switch the OpenRouter model the bot uses
// for Redstone replies. Persists in settings(llm_model); active selection
// is resolved at every callOpenRouterOnce so changes apply immediately.
bot.command("model", admin(async (ctx) => { await renderModelPicker(ctx); }));
// OpenRouter usage — admin views credit spend + tier for the configured key.
bot.command("ai_use", admin(async (ctx) => { await renderAiUsage(ctx); }));
// History — last N messages in this chat
bot.command("history", admin(async (ctx) => {
const n = Math.min(50, Math.max(5, Number.parseInt((ctx.match as string).trim(), 10) || 20));
@@ -890,10 +1194,11 @@ function helpText(role: Role): string {
"<b>Minecraft control bot</b>",
"",
"Tap <b>/menu</b> for the panel, or:",
"<code>/start</code> · <code>/stop</code> — start / stop server + tunnel (<code>/up</code>, <code>/down</code> still work)",
"<code>/up</code> · <code>/down</code> — start / stop server + tunnel",
"<code>/server_up</code> · <code>/server_down</code>",
"<code>/pf_up</code> · <code>/pf_down</code>",
"<code>/logs</code> · <code>/pf_logs</code>",
"<code>/download</code> — tar.gz the world to your DM (admin, ≤50 MB)",
"<code>/status</code> — show panel",
"<code>/clear</code> — wipe this chat's audit history (admin only in groups)",
"<code>/whoami</code>",
@@ -908,6 +1213,7 @@ function helpText(role: Role): string {
"<code>/users</code>, <code>/adduser &lt;id|@user&gt; [name]</code>, <code>/rmuser &lt;target&gt;</code>",
"<code>/promote &lt;target&gt;</code>, <code>/demote &lt;target&gt;</code>",
"<code>/linkmc &lt;target&gt; [mc_name]</code> — set/clear someone's MC name",
"<code>/backup [label]</code> — flush, archive, upload as a Gitea release",
"<code>/setsticker &lt;event&gt;</code> (reply to a sticker)",
"<code>/stickers</code>, <code>/teststicker &lt;event&gt;</code>",
"<code>/history [n]</code>",
@@ -928,6 +1234,7 @@ import {
} from "/mc/mcp/lib/types.ts";
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts";
import { minecraftTools } from "/mc/mcp/lib/minecraft-tools.ts";
import { minecraftWikiTools } from "/mc/mcp/lib/minecraft-wiki-tools.ts";
const tgClient = createTgClient(TOKEN);
const mcRuntime = createMcRuntime({
@@ -941,7 +1248,7 @@ const mcRuntime = createMcRuntime({
backupSh: `${MC_DIR}/backup.sh`,
});
const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime };
const allTools = [...telegramTools, ...minecraftTools];
const allTools = [...telegramTools, ...minecraftTools, ...minecraftWikiTools];
const toolsByName = new Map(allTools.map((t) => [t.name, t]));
// Hidden from Redstone's view: emoji→sticker is handled by post-processing
// the reply text, so the model never has to manage file_ids itself.
@@ -954,12 +1261,35 @@ const geminiFunctionDeclarations = allTools
.filter((t) => !REDSTONE_HIDDEN_TOOLS.has(t.name))
.map(toolToGeminiFunction);
// OpenAI/OpenRouter tool format wraps the same JSON-Schema parameter blob
// produced for Gemini — `cleanForGemini` strips a few keys (`$schema`,
// `additionalProperties`, `default`) which OpenAI also tolerates being absent.
type GeminiFunctionDecl = { name: string; description: string; parameters: Record<string, unknown> };
const openRouterTools = (geminiFunctionDeclarations as GeminiFunctionDecl[]).map((f) => ({
type: "function" as const,
function: { name: f.name, description: f.description, parameters: f.parameters },
}));
type GeminiPart =
| { text: string }
| { functionCall: { name: string; args: Record<string, unknown> } }
| { functionResponse: { name: string; response: Record<string, unknown> } };
type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] };
// Generic kv-style settings store. The /model command writes the active
// OpenRouter model id here, and callOpenRouterOnce reads it at call time
// so changes take effect on the very next message — no restart needed.
const Q_kvGet = db.query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?");
const Q_kvSet = db.query<unknown, [string, string]>(
"INSERT INTO settings (key, value) VALUES (?, ?) " +
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
);
function kvGet(key: string): string | null { return Q_kvGet.get(key)?.value ?? null; }
function kvSet(key: string, value: string): void { Q_kvSet.run(key, value); }
function currentOpenRouterModel(): string {
return kvGet("llm_model") || OPENROUTER_MODEL;
}
// Pull the last n text messages from this chat for continuity.
const Q_chatHistory = db.query<
{ direction: "in" | "out"; body: string | null },
@@ -997,6 +1327,11 @@ function startTypingLoop(ctx: Context, chatId: number): () => void {
return () => { stopped = true; };
}
// Persona names Redstone answers to in groups when @-mention is missing.
// Word-boundary match; case-insensitive. Lets "hey steve, …" land without
// requiring users to remember the bot's @handle.
const PERSONA_NAME_RE = /\b(redstone|steve)\b/i;
function botAddressed(ctx: Context, botId: number, botUsername: string): boolean {
const m = ctx.message;
if (!m || !m.text) return false;
@@ -1009,6 +1344,7 @@ function botAddressed(ctx: Context, botId: number, botUsername: string): boolean
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`) return true;
} else if (e.type === "text_mention" && e.user?.id === botId) return true;
}
if (PERSONA_NAME_RE.test(text)) return true;
return false;
}
@@ -1062,6 +1398,12 @@ function redstonePersona(user: User, chatType: string, chatId: number, srv: stri
"- Telegram chat: send messages/polls/reactions/dice/stickers/photos, edit/pin/delete your own messages, query chat info (tg_*).",
"- Minecraft server: check status (server_status), start/stop/restart services (server_up/down/restart), tail logs (server_logs), look up players in the bot's database (players_list/player_get).",
"- Admin-only tools (the caller's role is shown in the context block): run RCON commands (rcon), trigger a world backup (backup), and modify player records (player_upsert/set_*/remove, seen_list). If a non-admin asks for one, politely refuse and don't call it.",
"- Minecraft wiki: look things up on minecraft.wiki and surface images into the chat.",
"- For 'recipe for X' / 'how do I craft X' / 'send me the recipe image': call wiki_recipe_image(title=…, chat=<chatId>) — ONE call that composites a real 3×3 grid PNG from the wiki's slot sprites and uploads it straight to the chat. Returns {sent:true,message_id} when it's already in the chat — you do NOT need a separate tg_send_photo turn afterwards. Only add a short text reply if you want to caption the recipe with extra context.",
"- If wiki_recipe_image fails (e.g. 'no 3×3 crafting recipe' — the item is smelted, brewed, or only obtained from loot), use wiki_recipe to fetch the ingredient text, then tg_send_photo with the page's thumbnail_url as a fallback so SOMETHING visual lands.",
"- For 'what is X?' / general lookup: wiki_page for the intro extract + thumbnail. Then a short text reply, optionally + tg_send_photo of the thumbnail.",
"- For specific non-thumbnail images (block variants, GUI screenshots, charts): wiki_page_images with an optional filename filter.",
"- IMAGE DELIVERY IS MANDATORY when the user asks for a picture / image / recipe / what something looks like. End the turn with a tool call that actually puts a photo into the chat (wiki_recipe_image OR tg_send_photo). Never describe an image in words and stop.",
"- When you use a tool, you usually don't need a separate text reply unless you're adding context — the tool's effect IS the response. After a server_status / players_list lookup, summarize the answer in one sentence.",
"",
"Stickers — drop emojis inline and the bot does the rest:",
@@ -1122,10 +1464,167 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string):
type TurnResult = { text: string | null; ranTool: boolean };
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise<TurnResult> {
// ---- OpenRouter chat-completions types (OpenAI-compatible) ----
type ORToolCall = { id: string; type: "function"; function: { name: string; arguments: string } };
type ORMessage =
| { role: "system" | "user"; content: string }
| { role: "assistant"; content: string | null; tool_calls?: ORToolCall[] }
| { role: "tool"; tool_call_id: string; content: string };
type ORResponse = {
choices?: { message?: { role?: string; content?: string | null; tool_calls?: ORToolCall[] }; finish_reason?: string }[];
error?: { message?: string };
};
async function callOpenRouterOnce(messages: ORMessage[]): Promise<ORResponse | null> {
if (!OPENROUTER_API_KEY) return null;
try {
const res = await fetch(OPENROUTER_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"HTTP-Referer": OPENROUTER_REFERER,
"X-Title": OPENROUTER_TITLE,
},
body: JSON.stringify({
model: currentOpenRouterModel(),
messages,
tools: openRouterTools,
temperature: 0.6,
max_tokens: 600,
}),
signal: AbortSignal.timeout(45_000),
});
if (!res.ok) {
console.warn("openrouter http", res.status, (await res.text()).slice(0, 400));
return null;
}
return (await res.json()) as ORResponse;
} catch (e) {
console.warn("openrouter call failed:", (e as Error).message);
return null;
}
}
// Wiki calls can be slow when minecraft.wiki rate-limits or just lags. For
// tools that match WIKI_TOOL_RE, show a visible "I'm working on it" message
// in the chat with a word rotation — but only if the tool actually takes
// longer than SLOW_TOOL_THRESHOLD_MS. Fast calls stay invisible.
const WIKI_TOOL_RE = /^wiki_/;
const SLOW_TOOL_THRESHOLD_MS = 1500;
const SLOW_TOOL_ROTATE_MS = 900;
const SLOW_TOOL_PHRASES = [
"🔍 looking it up…",
"📖 reading the wiki…",
"🔎 searching…",
"🧭 cross-referencing…",
];
async function withSlowToolIndicator<T>(chatId: number, toolName: string, fn: () => Promise<T>): Promise<T> {
if (!WIKI_TOOL_RE.test(toolName)) return fn();
let placeholderId: number | null = null;
let rotateTimer: ReturnType<typeof setInterval> | null = null;
let done = false;
let phraseIdx = 0;
const showTimer = setTimeout(async () => {
try {
const m = await bot.api.sendMessage(chatId, SLOW_TOOL_PHRASES[0]);
// fn() may have resolved while sendMessage was in flight — if so the
// finally block already ran and saw no placeholder/timer to clean up.
// Delete the just-sent message and bail instead of installing an
// orphaned rotation loop that nothing will ever clear.
if (done) {
try { await bot.api.deleteMessage(chatId, m.message_id); } catch { /* ignore */ }
return;
}
placeholderId = m.message_id;
rotateTimer = setInterval(async () => {
phraseIdx = (phraseIdx + 1) % SLOW_TOOL_PHRASES.length;
try { await bot.api.editMessageText(chatId, placeholderId!, SLOW_TOOL_PHRASES[phraseIdx]); }
catch { /* edit failures (rate-limit / deleted) are fine */ }
}, SLOW_TOOL_ROTATE_MS);
} catch { /* send failure → just no indicator */ }
}, SLOW_TOOL_THRESHOLD_MS);
try {
return await fn();
} finally {
done = true;
clearTimeout(showTimer);
if (rotateTimer) clearInterval(rotateTimer);
if (placeholderId != null) {
try { await bot.api.deleteMessage(chatId, placeholderId); } catch { /* ignore */ }
}
}
}
// History is plain text-only (see recentTurns); either provider can render it.
function historyToOpenRouter(history: GeminiContent[]): ORMessage[] {
return history.map((c) => ({
role: c.role === "user" ? "user" : "assistant",
content: c.parts.map((p) => ("text" in p ? p.text : "")).join(""),
}) as ORMessage);
}
async function runRedstoneTurnViaOpenRouter(
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
): Promise<TurnResult | null> {
if (!OPENROUTER_API_KEY) return null;
const messages: ORMessage[] = [
{ role: "system", content: systemPrompt },
...historyToOpenRouter(history),
{ role: "user", content: userText },
];
let ranTool = false;
for (let i = 0; i < 6; i++) {
const data = await callOpenRouterOnce(messages);
if (!data) return null; // hard failure — caller falls back to Gemini
const msg = data.choices?.[0]?.message;
if (!msg) return null;
const toolCalls = msg.tool_calls ?? [];
if (toolCalls.length === 0) {
const text = (msg.content ?? "").trim();
return { text: text || null, ranTool };
}
messages.push({ role: "assistant", content: msg.content ?? null, tool_calls: toolCalls });
for (const tc of toolCalls) {
const tool = toolsByName.get(tc.function.name);
let response: Record<string, unknown>;
if (!tool) {
response = { error: `unknown tool '${tc.function.name}'` };
} else if (tool.requiresAdmin && callerRole !== "admin") {
response = { error: `tool '${tc.function.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
} else {
let args: Record<string, unknown> = {};
try { args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {}; } catch {
response = { error: `invalid JSON in tool arguments for '${tc.function.name}'` };
messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(response) });
continue;
}
try {
console.info("[redstone:or] tool ->", tc.function.name, JSON.stringify(args).slice(0, 200));
const result = await withSlowToolIndicator(chatId, tc.function.name, () => tool.handler(args, toolCtx));
response = { result };
ranTool = true;
console.info("[redstone:or] tool <-", tc.function.name, "ok");
} catch (e) {
response = { error: (e as Error).message };
console.warn("[redstone:or] tool <-", tc.function.name, "err:", (e as Error).message);
}
}
messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(response) });
}
}
return { text: null, ranTool };
}
async function runRedstoneTurnViaGemini(
systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number,
): Promise<TurnResult> {
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
let ranTool = false;
for (let i = 0; i < 4; i++) {
for (let i = 0; i < 6; i++) {
const data = await callGeminiOnce(contents, systemPrompt);
if (!data) return { text: null, ranTool };
const parts = data.candidates?.[0]?.content?.parts ?? [];
@@ -1153,11 +1652,14 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u
response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` };
} else {
try {
const result = await tool.handler(fc.functionCall.args, toolCtx);
console.info("[redstone:gem] tool ->", fc.functionCall.name, JSON.stringify(fc.functionCall.args).slice(0, 200));
const result = await withSlowToolIndicator(chatId, fc.functionCall.name, () => tool.handler(fc.functionCall.args, toolCtx));
response = { result };
ranTool = true;
console.info("[redstone:gem] tool <-", fc.functionCall.name, "ok");
} catch (e) {
response = { error: (e as Error).message };
console.warn("[redstone:gem] tool <-", fc.functionCall.name, "err:", (e as Error).message);
}
}
responseParts.push({ functionResponse: { name: fc.functionCall.name, response } });
@@ -1167,11 +1669,20 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u
return { text: null, ranTool };
}
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role, chatId: number): Promise<TurnResult> {
// OpenRouter `openrouter/free` is the primary path — picks the best free
// tool-calling model automatically. Falls through to Gemini on null (no key,
// network error, upstream rejected tool use).
const orResult = await runRedstoneTurnViaOpenRouter(systemPrompt, history, userText, callerRole, chatId);
if (orResult !== null) return orResult;
return runRedstoneTurnViaGemini(systemPrompt, history, userText, callerRole, chatId);
}
bot.on("message:text", async (ctx) => {
const me = bot.botInfo;
if (!me) return;
if (!botAddressed(ctx, me.id, me.username)) return;
if (!GEMINI_API_KEY) return;
if (!OPENROUTER_API_KEY && !GEMINI_API_KEY) return;
// Hard authorization gate. Re-checks the DB at handler time rather than
// trusting the upstream auth middleware, so any future refactor that
@@ -1203,7 +1714,7 @@ bot.on("message:text", async (ctx) => {
const sys = redstonePersona(user, chatType, chatId, srv);
const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
const history = recentTurns(chatId, latest, 6);
const result = await runRedstoneTurn(sys, history, text, user.role);
const result = await runRedstoneTurn(sys, history, text, user.role, chatId);
reply = result.text;
ranTool = result.ranTool;
} finally {
@@ -1211,7 +1722,21 @@ bot.on("message:text", async (ctx) => {
}
if (!reply) {
if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) });
if (!ranTool) {
// Both providers returned null AND nothing useful happened during the
// turn — surface that to the user instead of silently dropping the
// message. Usually means OpenRouter hit its free-tier rate limit and
// Gemini is also down/throttled; tell them what to do.
console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) });
try {
await ctx.reply(
"🤖 AI is unreachable right now — both OpenRouter and Gemini just declined. "
+ "Usually the free-tier daily cap resetting around UTC midnight. Try again in a few minutes, "
+ "or use <code>/model</code> to pick a different model.",
{ parse_mode: "HTML", reply_parameters: { message_id: ctx.message.message_id } },
);
} catch { /* if even the error reply fails, nothing more we can do */ }
}
return;
}
@@ -1219,10 +1744,19 @@ bot.on("message:text", async (ctx) => {
// Stream the reply word-by-word so the message animates into the chat like
// an LLM typing. @grammyjs/stream uses sendMessageDraft (Bot API 9.5) to push
// each delta as a native, animated draft and finalizes with sendMessage.
const messages = await ctx.replyWithStream(streamWords(out), {}, {
reply_parameters: { message_id: ctx.message.message_id },
});
const finalMessage = messages[messages.length - 1];
// sendMessageDraft is only supported in private chats — group / supergroup
// / channel peers return TEXTDRAFT_PEER_INVALID. Use plain reply there.
let finalMessage: { message_id: number } | undefined;
if (chatType === "private") {
const messages = await ctx.replyWithStream(streamWords(out), {}, {
reply_parameters: { message_id: ctx.message.message_id },
});
finalMessage = messages[messages.length - 1];
} else {
finalMessage = await ctx.reply(out, {
reply_parameters: { message_id: ctx.message.message_id },
});
}
if (finalMessage) {
Q.insMsg.run(chatId, finalMessage.message_id, null, "out", "text", out, ctx.message.message_id);
}
@@ -1294,8 +1828,9 @@ process.once("SIGTERM", () => shutdown("SIGTERM"));
// ---- Telegram command registration ----
const USER_COMMANDS = [
{ command: "start", description: "Start server and tunnel" },
{ command: "stop", description: "Stop server and tunnel" },
{ command: "start", description: "Welcome + show the control panel" },
{ command: "up", description: "Start server and tunnel" },
{ command: "down", description: "Stop server and tunnel" },
{ command: "menu", description: "Show the control panel" },
{ command: "status", description: "Server + tunnel status" },
{ command: "logs", description: "Last 30 lines of server log" },
@@ -1316,6 +1851,8 @@ const ADMIN_EXTRA = [
{ command: "promote", description: "Make user admin" },
{ command: "demote", description: "Demote admin to user" },
{ command: "linkmc", description: "Set/clear someone's MC name" },
{ command: "download", description: "Download a tar.gz of the world (DM)" },
{ command: "backup", description: "Run world backup → Gitea release" },
{ command: "setsticker", description: "Reply to a sticker to bind it" },
{ command: "stickers", description: "List bound stickers" },
{ command: "teststicker", description: "Preview a sticker by event" },

View File

@@ -9,7 +9,8 @@
"dependencies": {
"@grammyjs/auto-retry": "^2.0.2",
"@grammyjs/stream": "^1.0.1",
"grammy": "^1.30.0"
"grammy": "^1.30.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/bun": "latest",

View File

@@ -50,12 +50,25 @@ services:
environment:
BOT_TOKEN_FILE: /run/secrets/bot_token
GEMINI_API_KEY_FILE: /run/secrets/google_key
GEMINI_MODEL: "${GEMINI_MODEL:-gemma-4-26b-a4b-it}"
GEMINI_MODEL: "${GEMINI_MODEL:-gemini-2.5-flash-lite}"
# OpenRouter primary; Gemini stays as fallback. `openrouter/free` auto-
# selects a free model that supports tool calling for this request.
OPENROUTER_API_KEY_FILE: /run/secrets/openrouter_token
OPENROUTER_MODEL: "${OPENROUTER_MODEL:-openrouter/free}"
MC_DIR: /mc
DB_PATH: /data/users.db
ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}"
COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}"
TZ: Africa/Johannesburg
# Gitea release upload from inside the container. The token is mounted
# as a docker secret (see `secrets:` below + ./gitea.token, gitignored);
# backup.sh reads it via $GITEA_TOKEN_FILE. GITEA_API_BASE / GITEA_REPO
# let backup.sh skip its `git config remote.origin.url` parse — needed
# because the host's origin URL points at localhost:3000, which the bot
# container can't reach.
GITEA_TOKEN_FILE: /run/secrets/gitea_token
GITEA_API_BASE: "${GITEA_API_BASE:-https://thinkstation-web.spot-bot.co.za/api/v1}"
GITEA_REPO: "${GITEA_REPO:-paul/Minecraft-Server}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/mc
@@ -63,9 +76,15 @@ services:
secrets:
- bot_token
- google_key
- gitea_token
- openrouter_token
secrets:
bot_token:
file: ./bot.token
google_key:
file: ./google_key.txt
gitea_token:
file: ./gitea.token
openrouter_token:
file: ./openrouter.token

View File

@@ -0,0 +1,392 @@
// Minecraft Wiki tool catalog. Hits https://minecraft.wiki (MediaWiki API).
// Composes well with telegram-tools.ts → after wiki_page_images returns a URL
// the model can pass it to tg_send_photo to surface a recipe / item image
// directly into the chat.
import { z } from "zod";
import sharp from "sharp";
import { tool, type Tool } from "./types.ts";
const WIKI_BASE = "https://minecraft.wiki";
const WIKI_API = `${WIKI_BASE}/api.php`;
const UA = "Redstone-bot/1.0 (Minecraft container; contact via telegram bot)";
const TIMEOUT_MS = 15_000;
async function wikiGet<T>(params: Record<string, string | number | undefined>): Promise<T> {
const url = new URL(WIKI_API);
url.searchParams.set("format", "json");
url.searchParams.set("formatversion", "2");
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
const res = await fetch(url.toString(), {
headers: { "User-Agent": UA, Accept: "application/json" },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`wiki ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
function pageUrl(title: string): string {
return `${WIKI_BASE}/w/${encodeURIComponent(title.replace(/ /g, "_"))}`;
}
function absolutize(url: string): string {
if (url.startsWith("//")) return `https:${url}`;
if (url.startsWith("/")) return `${WIKI_BASE}${url}`;
return url;
}
// Parse the rendered HTML of a Minecraft wiki page and pull out:
// - the 3×3 input grid as [[url|null, …], …] (9 slots, in reading order)
// - the result item URL (the contents of <span class="mcui-output">)
// Returns null when the page has no `mcui-Crafting_Table` block (uncraftable,
// or only smelting / brewing / smithing recipes, which use other mcui classes).
function parseCraftingGrid(html: string): { grid: (string | null)[][]; result: string | null } | null {
const tableMatch = html.match(/<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/);
if (!tableMatch) return null;
const tableHtml = tableMatch[1];
// The crafting widget: <span class="mcui mcui-Crafting_Table …"> … </span>. Find the input + output sections.
const widget = tableHtml.match(/<span class="mcui mcui-Crafting[^"]*"[^>]*>([\s\S]*?)<\/span>\s*<\/div>/);
if (!widget) return null;
const w = widget[1];
// Each slot starts with `<span class="invslot">`. Splitting on that marker gives us one chunk per slot
// (the first chunk is whatever preceded the first invslot — discarded). An empty slot's chunk starts
// with `</span>` immediately; a filled slot's chunk contains an <img src="..."> first.
const chunks = w.split('<span class="invslot">').slice(1);
const allSlots: (string | null)[] = chunks.map((chunk) => {
if (chunk.startsWith("</span>")) return null;
const img = chunk.match(/<img\b[^>]*\bsrc="([^"]+)"/);
return img ? absolutize(img[1]) : null;
});
// The output uses `invslot invslot-large`, but the split keys on `invslot">` so the trailing
// -large slot is split too. The first 9 slots are the input grid; the next non-null is the
// result (any "auxiliary" empty filler slots between input and output are tolerable to skip).
if (allSlots.length < 9) return null;
const inputs = allSlots.slice(0, 9);
const result = allSlots.slice(9).find((s) => s != null) ?? null;
return {
grid: [inputs.slice(0, 3), inputs.slice(3, 6), inputs.slice(6, 9)],
result,
};
}
async function fetchBytes(url: string): Promise<Buffer> {
const res = await fetch(url, {
headers: { "User-Agent": UA },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`${url}${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
// Compose a Minecraft-style crafting recipe PNG. Layout (each cell 64×64 px,
// scaled up from the wiki's native 32 px for clarity on phones):
// [grid 3×3] [arrow "→"] [result]
// Background is the classic inventory gray (#8b8b8b) with darker slot borders.
const CELL = 64;
const GAP = 4;
const PAD = 8;
const ARROW_W = 56;
async function composeRecipePng(grid: (string | null)[][], result: string | null): Promise<Buffer> {
// Collect unique URLs and fetch each once.
const unique = new Set<string>();
for (const row of grid) for (const u of row) if (u) unique.add(u);
if (result) unique.add(result);
const blobs: Record<string, Buffer> = {};
await Promise.all(
[...unique].map(async (u) => {
blobs[u] = await fetchBytes(u);
}),
);
const gridW = CELL * 3 + GAP * 2;
const gridH = CELL * 3 + GAP * 2;
const totalW = PAD + gridW + (result ? PAD + ARROW_W + PAD + CELL : 0) + PAD;
const totalH = PAD + gridH + PAD;
// Resize every needed sprite to CELL×CELL up front, with nearest-neighbour so
// the 16-pixel inventory icons stay crisp (no antialiasing). Sharp's input
// is the original buffer; outputs are 64×64 PNG buffers.
const sized: Record<string, Buffer> = {};
await Promise.all(
[...unique].map(async (u) => {
sized[u] = await sharp(blobs[u]).resize(CELL, CELL, { kernel: "nearest" }).png().toBuffer();
}),
);
// Arrow rendered as SVG → PNG.
const arrowSvg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${ARROW_W}" height="${CELL}" viewBox="0 0 ${ARROW_W} ${CELL}">` +
`<text x="${ARROW_W / 2}" y="${CELL / 2 + 16}" text-anchor="middle" font-family="serif" font-size="48" fill="#222">→</text>` +
`</svg>`,
);
const arrowPng = await sharp(arrowSvg).png().toBuffer();
const composites: { input: Buffer; top: number; left: number }[] = [];
// Slot backgrounds (slightly darker squares to look like inventory cells).
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
const left = PAD + c * (CELL + GAP);
const top = PAD + r * (CELL + GAP);
composites.push({
input: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${CELL}" height="${CELL}">` +
`<rect width="${CELL}" height="${CELL}" fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/>` +
`</svg>`,
),
top, left,
});
const u = grid[r][c];
if (u) composites.push({ input: sized[u], top, left });
}
}
if (result) {
const ax = PAD + gridW + PAD;
const rx = ax + ARROW_W + PAD;
composites.push({ input: arrowPng, top: PAD + (gridH - CELL) / 2, left: ax });
composites.push({
input: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${CELL}" height="${CELL}">` +
`<rect width="${CELL}" height="${CELL}" fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/>` +
`</svg>`,
),
top: PAD + (gridH - CELL) / 2,
left: rx,
});
composites.push({ input: sized[result], top: PAD + (gridH - CELL) / 2, left: rx });
}
return sharp({
create: { width: totalW, height: totalH, channels: 4, background: { r: 139, g: 139, b: 139, alpha: 1 } },
})
.composite(composites)
.png()
.toBuffer();
}
export const minecraftWikiTools: Tool<z.ZodRawShape>[] = [
tool({
name: "wiki_search",
title: "Search the Minecraft wiki",
description:
"Search minecraft.wiki for pages matching a query. Returns top matches with title, short description, and canonical page URL. Use this first to find the exact page title before calling wiki_page or wiki_page_images.",
parameters: {
query: z.string().min(1).describe("Search terms, e.g. 'iron pickaxe', 'crafting table', 'beacon'"),
limit: z.number().int().min(1).max(15).optional().describe("Max results (default 5)"),
},
handler: async ({ query, limit }) => {
// opensearch returns a 4-tuple: [query, titles[], descriptions[], urls[]]
type OpenSearch = [string, string[], string[], string[]];
const data = await wikiGet<OpenSearch>({
action: "opensearch",
search: query,
limit: limit ?? 5,
namespace: 0,
});
const [, titles, descriptions, urls] = data;
return {
results: titles.map((title, i) => ({
title,
description: descriptions[i] || "",
url: urls[i] || pageUrl(title),
})),
};
},
}),
tool({
name: "wiki_page",
title: "Get a Minecraft wiki page summary",
description:
"Fetch the intro extract (plain text) plus the page's thumbnail image URL and canonical URL. Good for a one-line answer about an item, mob, or mechanic, plus a representative image to send.",
parameters: {
title: z.string().min(1).describe("Exact wiki page title (use wiki_search first if unsure)"),
thumb_size: z.number().int().min(120).max(1200).optional().describe("Thumbnail pixel width (default 600)"),
},
handler: async ({ title, thumb_size }) => {
type PageRes = {
query?: {
pages?: {
title: string;
missing?: boolean;
extract?: string;
thumbnail?: { source: string; width: number; height: number };
pageimage?: string;
}[];
};
};
const data = await wikiGet<PageRes>({
action: "query",
prop: "extracts|pageimages",
titles: title,
exintro: "1",
explaintext: "1",
exchars: 600,
piprop: "thumbnail|name",
pithumbsize: thumb_size ?? 600,
redirects: "1",
});
const page = data.query?.pages?.[0];
if (!page || page.missing) return { error: `no wiki page titled '${title}'` };
return {
title: page.title,
extract: page.extract ?? "",
thumbnail_url: page.thumbnail?.source ?? null,
page_url: pageUrl(page.title),
};
},
}),
tool({
name: "wiki_recipe_image",
title: "Send a Minecraft crafting recipe image to a chat",
description:
"Generate a 3×3 crafting-grid image of the recipe (with the result item beside an arrow) and send it directly to a Telegram chat. Use this for 'send me the recipe' / 'image of recipe' requests instead of chaining wiki_recipe + tg_send_photo — minecraft.wiki has no standalone recipe PNG, so this tool composites one from the per-slot ingredient sprites. Returns { sent: true, message_id } on success. Fails if the page has no 3×3 crafting recipe (smelting, brewing, smithing all use different mcui widgets).",
parameters: {
title: z.string().min(1).describe("Page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'"),
chat: z.union([z.number().int(), z.string()]).describe("Telegram chat id to send the photo into"),
caption: z.string().max(1024).optional().describe("Optional caption — defaults to the page title if omitted"),
},
handler: async ({ title, chat, caption }, { tg }) => {
type ParseRes = { parse?: { title?: string; text?: string } };
const data = await wikiGet<ParseRes>({
action: "parse",
page: title,
prop: "text",
redirects: "1",
disableeditsection: "1",
});
const html = data.parse?.text ?? "";
if (!html) return { error: `no wiki page titled '${title}'` };
const resolved = data.parse?.title ?? title;
const parsed = parseCraftingGrid(html);
if (!parsed) {
return {
error:
`no 3×3 crafting recipe on '${resolved}' (page may use smelting / brewing / smithing, or item is uncraftable)`,
};
}
const png = await composeRecipePng(parsed.grid, parsed.result);
const sent = await tg.sendPhotoBytes({
chat_id: chat,
bytes: png,
filename: `${resolved.replace(/[^A-Za-z0-9._-]+/g, "_")}_recipe.png`,
caption: caption ?? `${resolved} — crafting recipe`,
});
return { sent: true, message_id: sent.message_id, title: resolved };
},
}),
tool({
name: "wiki_recipe",
title: "Get a crafting recipe from the Minecraft wiki",
description:
"Extracts the crafting recipe ingredient list from a wiki page (parses the live recipe table — minecraft.wiki renders recipes as HTML grids, not standalone images, so wiki_page_images is the wrong tool for recipe queries). Returns the ingredients string for each recipe variant, plus the page's main item thumbnail URL to send with tg_send_photo. Use this whenever a user asks 'how do I craft X' or 'recipe for X'.",
parameters: {
title: z.string().min(1).describe("Exact wiki page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'. Use wiki_search first if unsure."),
},
handler: async ({ title }) => {
// 1. Parsed HTML — the recipe lives in a wikitable with data-description="Crafting recipes"
type ParseRes = { parse?: { title?: string; text?: string } };
const data = await wikiGet<ParseRes>({
action: "parse",
page: title,
prop: "text",
redirects: "1",
disableeditsection: "1",
});
const html = data.parse?.text ?? "";
if (!html) return { error: `no wiki page titled '${title}'` };
const resolvedTitle = data.parse?.title ?? title;
// 2. Find each crafting-recipes table and pull the first <td> (ingredients) of each data row.
const stripHtml = (s: string) =>
s.replace(/<br\s*\/?>/gi, " + ")
.replace(/<[^>]+>/g, "")
.replace(/&#160;|&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/\s*\+\s*/g, " + ")
.replace(/\s+/g, " ")
.trim();
const tableRe = /<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/g;
const recipes: { ingredients: string }[] = [];
let m: RegExpExecArray | null;
while ((m = tableRe.exec(html)) !== null) {
const rowRe = /<tr\b[^>]*>([\s\S]*?)<\/tr>/g;
let r: RegExpExecArray | null;
while ((r = rowRe.exec(m[1])) !== null) {
if (/<th\b/.test(r[1])) continue;
const td = /<td\b[^>]*>([\s\S]*?)<\/td>/.exec(r[1]);
if (!td) continue;
const text = stripHtml(td[1]);
if (text) recipes.push({ ingredients: text });
}
}
// 3. Pull the page thumbnail in a second query — gives the bot a URL to send.
type ThumbRes = { query?: { pages?: { title: string; thumbnail?: { source: string } }[] } };
const meta = await wikiGet<ThumbRes>({
action: "query",
prop: "pageimages",
titles: resolvedTitle,
piprop: "thumbnail",
pithumbsize: 600,
redirects: "1",
});
const thumbnail = meta.query?.pages?.[0]?.thumbnail?.source ?? null;
return {
title: resolvedTitle,
recipes, // [{ ingredients: "Block of Iron + Iron Ingot" }, ...]
thumbnail_url: thumbnail, // page's main item image — pass to tg_send_photo
page_url: pageUrl(resolvedTitle),
note: recipes.length === 0
? "No recipe table on this page — the item may be uncraftable, found only via loot/structure, or listed under a different page."
: undefined,
};
},
}),
tool({
name: "wiki_page_images",
title: "List images on a Minecraft wiki page (with full URLs)",
description:
"Returns every image embedded on a wiki page along with its direct https URL. Use the optional `filter` to narrow by filename substring (case-insensitive) — pass 'recipe' or 'craft' or 'grid' to surface crafting-recipe diagrams. Hand a URL from this result to tg_send_photo to deliver the image into a chat.",
parameters: {
title: z.string().min(1).describe("Exact wiki page title"),
filter: z.string().optional().describe("Case-insensitive substring filter on the filename (e.g. 'recipe', 'craft', 'grid')"),
limit: z.number().int().min(1).max(50).optional().describe("Max images to return (default 20)"),
},
handler: async ({ title, filter, limit }) => {
type ImgRes = {
query?: {
pages?: {
title: string;
imageinfo?: { url: string; mime?: string; width?: number; height?: number }[];
}[];
};
};
const data = await wikiGet<ImgRes>({
action: "query",
generator: "images",
titles: title,
prop: "imageinfo",
iiprop: "url|mime|size",
gimlimit: 50,
redirects: "1",
});
const pages = data.query?.pages ?? [];
let images = pages.map((p) => ({
filename: p.title.replace(/^File:/, ""),
url: p.imageinfo?.[0]?.url ?? "",
mime: p.imageinfo?.[0]?.mime,
})).filter((i) => i.url && (!i.mime || i.mime.startsWith("image/")));
if (filter) {
const needle = filter.toLowerCase();
images = images.filter((i) => i.filename.toLowerCase().includes(needle));
}
return { images: images.slice(0, limit ?? 20) };
},
}),
];

View File

@@ -7,6 +7,19 @@ import type { Database } from "bun:sqlite";
export type TgClient = {
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
/**
* Upload a binary photo to Telegram via multipart/form-data sendPhoto.
* Use for synthesized images (e.g. composited recipe grids) that don't
* live at a public URL Telegram can fetch itself.
*/
sendPhotoBytes: (params: {
chat_id: number | string;
bytes: Uint8Array;
filename?: string;
caption?: string;
parse_mode?: "HTML" | "MarkdownV2";
reply_to_message_id?: number;
}) => Promise<{ message_id: number }>;
};
export function createTgClient(token: string): TgClient {
@@ -24,6 +37,24 @@ export function createTgClient(token: string): TgClient {
if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`);
return data.result as T;
},
async sendPhotoBytes({ chat_id, bytes, filename = "photo.png", caption, parse_mode, reply_to_message_id }) {
const fd = new FormData();
fd.set("chat_id", String(chat_id));
if (caption !== undefined) fd.set("caption", caption);
if (parse_mode !== undefined) fd.set("parse_mode", parse_mode);
if (reply_to_message_id !== undefined) {
fd.set("reply_parameters", JSON.stringify({ message_id: reply_to_message_id }));
}
fd.set("photo", new Blob([bytes], { type: "image/png" }), filename);
const res = await fetch(`${base}/sendPhoto`, {
method: "POST",
body: fd,
signal: AbortSignal.timeout(30_000),
});
const data = (await res.json()) as { ok: boolean; result?: { message_id: number }; description?: string; error_code?: number };
if (!data.ok) throw new Error(`sendPhoto(bytes): ${data.description} (code ${data.error_code})`);
return data.result!;
},
};
}

View File

@@ -8,6 +8,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"sharp": "^0.33.5",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.2"
},