2026-05-13 22:42:33 +02:00
import { Bot , Context , GrammyError , HttpError , InlineKeyboard , InputFile } from "grammy" ;
2026-05-12 08:47:35 +02:00
import { autoRetry } from "@grammyjs/auto-retry" ;
import { stream , type StreamFlavor } from "@grammyjs/stream" ;
type BotContext = StreamFlavor < Context > ;
2026-05-11 21:51:59 +02:00
import { Database } from "bun:sqlite" ;
import { readFileSync , mkdirSync } from "node:fs" ;
import { dirname } from "node:path" ;
// ---- Config ----
const TOKEN = (
process . env . BOT_TOKEN ? ?
( process . env . BOT_TOKEN_FILE ? readFileSync ( process . env . BOT_TOKEN_FILE , "utf8" ) : "" )
) . trim ( ) ;
if ( ! TOKEN ) throw new Error ( "BOT_TOKEN or BOT_TOKEN_FILE must be set" ) ;
const MC_DIR = process . env . MC_DIR ? ? "/mc" ;
const COMPOSE_FILE = ` ${ MC_DIR } /docker-compose.yml ` ;
// Pin the compose project name so we manage the same containers regardless of where the bot runs.
// Without this, compose derives the project from the cwd basename (e.g. "mc" inside the container vs "minecraft" on host).
const COMPOSE_PROJECT = process . env . COMPOSE_PROJECT_NAME ? ? "minecraft" ;
// CRITICAL: the docker daemon evaluates relative bind-mount paths from this directory, ON THE HOST.
// Without this, ./data and ./ssh/spotbot_key resolve to /mc/data and /mc/ssh/... — paths that don't
// exist on the host, so docker silently creates empty dirs and the world / ssh-key get lost.
const COMPOSE_HOST_DIR = process . env . COMPOSE_HOST_DIR ? ? "/home/paul/containers/minecraft" ;
const SERVER_SVC = process . env . SERVER_SERVICE ? ? "minecraft" ;
const SERVER_CONTAINER = process . env . SERVER_CONTAINER ? ? "minecraft" ;
const PF_SVC = process . env . PORTFORWARD_SERVICE ? ? "autossh" ;
const PF_CONTAINER = process . env . PORTFORWARD_CONTAINER ? ? "minecraft-autossh" ;
const DB_PATH = process . env . DB_PATH ? ? "/data/users.db" ;
const ADMIN_BOOTSTRAP = ( process . env . ADMIN_BOOTSTRAP ? ? "1311866578:Paul" ) . trim ( ) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
// ---- Gemini (Google Generative Language API) ----
// Conversational fallback for non-command text. Uses native function calling so
// Redstone can decide to send polls / reactions / etc via the shared MCP toolkit.
// If no key is configured the bot stays silent on unknown text, as before.
const GEMINI_API_KEY = (
process . env . GEMINI_API_KEY ? ?
( process . env . GEMINI_API_KEY_FILE && ( ( ) = > { try { return readFileSync ( process . env . GEMINI_API_KEY_FILE ! , "utf8" ) ; } catch { return "" ; } } ) ( ) || "" )
) . trim ( ) ;
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 ` ;
2026-05-13 22:42:33 +02:00
// ---- 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 ( ) ;
2026-05-11 21:51:59 +02:00
// ---- DB ----
mkdirSync ( dirname ( DB_PATH ) , { recursive : true } ) ;
const db = new Database ( DB_PATH ) ;
db . exec ( "PRAGMA journal_mode = WAL" ) ;
db . exec ( `
CREATE TABLE IF NOT EXISTS users (
telegram_id INTEGER PRIMARY KEY ,
name TEXT ,
role TEXT NOT NULL CHECK ( role IN ( 'admin' , 'user' ) ) DEFAULT 'user' ,
added_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
added_by INTEGER
) ;
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT ,
chat_id INTEGER NOT NULL ,
message_id INTEGER ,
user_id INTEGER ,
direction TEXT NOT NULL CHECK ( direction IN ( 'in' , 'out' ) ) ,
kind TEXT ,
body TEXT ,
reply_to INTEGER ,
created_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
edited_at TEXT
) ;
CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages ( chat_id , created_at DESC ) ;
CREATE TABLE IF NOT EXISTS panels (
chat_id INTEGER NOT NULL ,
panel TEXT NOT NULL ,
message_id INTEGER NOT NULL ,
updated_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
PRIMARY KEY ( chat_id , panel )
) ;
CREATE TABLE IF NOT EXISTS stickers (
event TEXT PRIMARY KEY ,
file_id TEXT NOT NULL ,
set_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
set_by INTEGER
) ;
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
CREATE TABLE IF NOT EXISTS sticker_library (
file_id TEXT PRIMARY KEY ,
emoji TEXT ,
set_name TEXT ,
first_seen TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
last_seen TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
seen_count INTEGER NOT NULL DEFAULT 1
) ;
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
ON sticker_library ( last_seen DESC ) ;
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
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY ,
value TEXT NOT NULL ,
updated_at TEXT NOT NULL DEFAULT ( datetime ( 'now' ) )
) ;
2026-05-11 21:51:59 +02:00
` );
// Idempotent migrations.
function ensureColumn ( table : string , col : string , decl : string ) {
const cols = db . query < { name : string } , [ ] > ( ` PRAGMA table_info( ${ table } ) ` ) . all ( ) ;
if ( ! cols . some ( ( c ) = > c . name === col ) ) {
db . exec ( ` ALTER TABLE ${ table } ADD COLUMN ${ col } ${ decl } ` ) ;
}
}
ensureColumn ( "users" , "minecraft_name" , "TEXT COLLATE NOCASE" ) ;
ensureColumn ( "users" , "username" , "TEXT COLLATE NOCASE" ) ;
ensureColumn ( "users" , "status" , "TEXT NOT NULL DEFAULT 'active'" ) ;
db . exec ( ` CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mcname
ON users ( minecraft_name ) WHERE minecraft_name IS NOT NULL ` );
db . exec ( ` CREATE INDEX IF NOT EXISTS idx_users_username
ON users ( username ) WHERE username IS NOT NULL ` );
// Anyone the bot has seen send a message but who isn't (yet) authorized.
db . exec ( `
CREATE TABLE IF NOT EXISTS seen_users (
telegram_id INTEGER PRIMARY KEY ,
username TEXT COLLATE NOCASE ,
name TEXT ,
first_seen TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
last_seen TEXT NOT NULL DEFAULT ( datetime ( 'now' ) ) ,
last_chat_id INTEGER
) ;
CREATE INDEX IF NOT EXISTS idx_seen_username
ON seen_users ( username ) WHERE username IS NOT NULL ;
` );
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/ ;
type Role = "admin" | "user" ;
type Status = "active" | "pending" ;
type User = {
telegram_id : number ; name : string | null ; role : Role ;
minecraft_name : string | null ; username : string | null ; status : Status ;
} ;
const USER_COLS = "telegram_id, name, role, minecraft_name, username, status" ;
const Q = {
user : db.query < User , [ number ] > ( ` SELECT ${ USER_COLS } FROM users WHERE telegram_id = ? ` ) ,
userByMc : db.query < User , [ string ] > ( ` SELECT ${ USER_COLS } FROM users WHERE minecraft_name = ? ` ) ,
userByUsername : db.query < User , [ string ] > ( ` SELECT ${ USER_COLS } FROM users WHERE username = ? ` ) ,
users : db.query < User , [ ] > ( ` SELECT ${ USER_COLS } FROM users ORDER BY role DESC, added_at ASC ` ) ,
setUsername : db.query < unknown , [ string | null , number ] > (
"UPDATE users SET username = ? WHERE telegram_id = ?"
) ,
setStatus : db.query < unknown , [ Status , number ] > (
"UPDATE users SET status = ? WHERE telegram_id = ?"
) ,
pendingUsers : db.query < User , [ ] > (
` SELECT ${ USER_COLS } FROM users WHERE status = 'pending' ORDER BY added_at ASC `
) ,
seenUpsert : db.query < unknown , [ number , string | null , string | null , number ] > (
` INSERT INTO seen_users (telegram_id, username, name, last_chat_id) VALUES (?, ?, ?, ?)
ON CONFLICT ( telegram_id ) DO UPDATE SET
username = excluded . username ,
name = COALESCE ( excluded . name , seen_users . name ) ,
last_chat_id = excluded . last_chat_id ,
last_seen = datetime ( 'now' ) `
) ,
seenByUsername : db.query < { telegram_id : number ; username : string | null ; name : string | null } , [ string ] > (
"SELECT telegram_id, username, name FROM seen_users WHERE username = ? ORDER BY last_seen DESC LIMIT 1"
) ,
upsertUser : db.query < unknown , [ number , string | null , Role , number | null ] > (
` INSERT INTO users (telegram_id, name, role, added_by) VALUES (?, ?, ?, ?)
ON CONFLICT ( telegram_id ) DO UPDATE SET
name = COALESCE ( excluded . name , users . name ) , role = excluded . role `
) ,
setMcName : db.query < unknown , [ string | null , number ] > (
"UPDATE users SET minecraft_name = ? WHERE telegram_id = ?"
) ,
delUser : db.query < unknown , [ number ] > ( "DELETE FROM users WHERE telegram_id = ?" ) ,
countUsers : db.query < { n : number } , [ ] > ( "SELECT COUNT(*) AS n FROM users" ) ,
insMsg : db.query < unknown , [ number , number | null , number | null , "in" | "out" , string | null , string | null , number | null ] > (
` INSERT INTO messages (chat_id, message_id, user_id, direction, kind, body, reply_to) VALUES (?, ?, ?, ?, ?, ?, ?) `
) ,
markMsgEdited : db.query < unknown , [ number , number ] > (
` UPDATE messages SET edited_at = datetime('now') WHERE chat_id = ? AND message_id = ? `
) ,
getPanel : db.query < { message_id : number } , [ number , string ] > (
"SELECT message_id FROM panels WHERE chat_id = ? AND panel = ?"
) ,
setPanel : db.query < unknown , [ number , string , number ] > (
` INSERT INTO panels (chat_id, panel, message_id) VALUES (?, ?, ?)
ON CONFLICT ( chat_id , panel ) DO UPDATE SET message_id = excluded . message_id , updated_at = datetime ( 'now' ) `
) ,
delPanel : db.query < unknown , [ number , string ] > ( "DELETE FROM panels WHERE chat_id = ? AND panel = ?" ) ,
getSticker : db.query < { file_id : string } , [ string ] > ( "SELECT file_id FROM stickers WHERE event = ?" ) ,
setSticker : db.query < unknown , [ string , string , number ] > (
` INSERT INTO stickers (event, file_id, set_by) VALUES (?, ?, ?)
ON CONFLICT ( event ) DO UPDATE SET file_id = excluded . file_id , set_at = datetime ( 'now' ) , set_by = excluded . set_by `
) ,
listStickers : db.query < { event : string ; file_id : string } , [ ] > ( "SELECT event, file_id FROM stickers ORDER BY event" ) ,
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
libraryUpsert : db.query < unknown , [ string , string | null , string | null ] > (
` INSERT INTO sticker_library (file_id, emoji, set_name) VALUES (?, ?, ?)
ON CONFLICT ( file_id ) DO UPDATE SET
emoji = COALESCE ( excluded . emoji , sticker_library . emoji ) ,
set_name = COALESCE ( excluded . set_name , sticker_library . set_name ) ,
last_seen = datetime ( 'now' ) ,
seen_count = sticker_library . seen_count + 1 `
) ,
2026-05-11 21:51:59 +02:00
} ;
function seedIfEmpty() {
if ( ( Q . countUsers . get ( ) ? . n ? ? 0 ) > 0 ) return ;
for ( const pair of ADMIN_BOOTSTRAP . split ( "," ) . map ( ( s ) = > s . trim ( ) ) . filter ( Boolean ) ) {
const [ idStr , . . . rest ] = pair . split ( ":" ) ;
const id = Number . parseInt ( idStr , 10 ) ;
if ( ! Number . isFinite ( id ) ) continue ;
Q . upsertUser . run ( id , rest . join ( ":" ) || null , "admin" , null ) ;
console . log ( ` seeded admin: ${ id } ( ${ rest . join ( ":" ) || "—" } ) ` ) ;
}
}
seedIfEmpty ( ) ;
const lookup = ( id : number | undefined ) : User | null = > ( id === undefined ? null : Q . user . get ( id ) ? ? null ) ;
// Tokens that mean "the caller". Case-insensitive. Recognised wherever a user
// target is expected (e.g. /promote, /linkmc, /approve, /rmuser).
const SELF_MENTIONS = new Set ( [ "me" , "@me" , "self" , "@self" ] ) ;
/** True if `arg` references the caller (e.g. "me", "@me"). */
function isSelfMention ( arg : string | undefined | null ) : boolean {
return ! ! arg && SELF_MENTIONS . has ( arg . trim ( ) . toLowerCase ( ) ) ;
}
/** Build a Resolved target for the caller themselves. */
function selfTarget ( ctx : Context ) : Resolved | null {
const id = ctx . from ? . id ;
if ( id === undefined ) return null ;
return { id , user : lookup ( id ) , via : "self" } ;
}
// Resolve a target reference: numeric id, "@me"/"me", "@username", or a text_mention entity in the message.
// Returns the telegram_id (and any local user record we have), or null if unknown.
type Resolved = { id : number ; user : User | null ; via : "id" | "self" | "users" | "seen" | "mention" } ;
function resolveTarget ( ctx : Context , raw : string ) : Resolved | null {
const arg = ( raw ? ? "" ) . trim ( ) ;
if ( ! arg ) return null ;
if ( isSelfMention ( arg ) ) return selfTarget ( ctx ) ;
// Numeric id
if ( /^-?\d+$/ . test ( arg ) ) {
const id = Number . parseInt ( arg , 10 ) ;
return { id , user : lookup ( id ) , via : "id" } ;
}
// @username — try users table, then seen_users, then text_mention entities in this message
if ( arg . startsWith ( "@" ) ) {
const handle = arg . slice ( 1 ) ;
const u = Q . userByUsername . get ( handle ) ;
if ( u ) return { id : u.telegram_id , user : u , via : "users" } ;
const s = Q . seenByUsername . get ( handle ) ;
if ( s ) return { id : s.telegram_id , user : lookup ( s . telegram_id ) , via : "seen" } ;
const entities = ctx . message ? . entities ? ? [ ] ;
for ( const e of entities ) {
if ( e . type === "text_mention" && ( e as any ) . user ? . username ? . toLowerCase ( ) === handle . toLowerCase ( ) ) {
const id = ( e as any ) . user . id as number ;
return { id , user : lookup ( id ) , via : "mention" } ;
}
}
return null ;
}
return null ;
}
function unresolvedHelp ( ) : string {
return (
"couldn't resolve that target.\n" +
"use a numeric id, <code>@me</code>, or <code>@username</code> " +
"(usernames only resolve once the bot has seen them message anywhere)"
) ;
}
// ---- docker compose helpers ----
async function compose ( . . . args : string [ ] ) : Promise < { ok : boolean ; out : string } > {
2026-05-13 22:42:33 +02:00
// 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 ) ] ;
}
2026-05-11 21:51:59 +02:00
const proc = Bun . spawn (
[
"docker" , "compose" ,
"--project-directory" , COMPOSE_HOST_DIR ,
"-p" , COMPOSE_PROJECT ,
"-f" , COMPOSE_FILE ,
. . . args ,
] ,
{ cwd : MC_DIR , stdout : "pipe" , stderr : "pipe" }
) ;
const out = await new Response ( proc . stdout ) . text ( ) ;
const err = await new Response ( proc . stderr ) . text ( ) ;
return { ok : ( await proc . exited ) === 0 , out : ( out + err ) . trim ( ) } ;
}
async function containerRunning ( name : string ) : Promise < boolean > {
const proc = Bun . spawn ( [ "docker" , "inspect" , "-f" , "{{.State.Running}}" , name ] , { stdout : "pipe" , stderr : "pipe" } ) ;
const out = ( await new Response ( proc . stdout ) . text ( ) ) . trim ( ) ;
await proc . exited ;
return out === "true" ;
}
const serverRunning = ( ) = > containerRunning ( SERVER_CONTAINER ) ;
const pfRunning = ( ) = > containerRunning ( PF_CONTAINER ) ;
async function tailLogs ( container : string , n = 30 ) : Promise < string > {
const proc = Bun . spawn ( [ "docker" , "logs" , "--tail" , String ( n ) , container ] , { stdout : "pipe" , stderr : "pipe" } ) ;
const out = ( await new Response ( proc . stdout ) . text ( ) ) + ( await new Response ( proc . stderr ) . text ( ) ) ;
await proc . exited ;
return out ;
}
// ---- Stickers ----
async function maybeSendSticker ( ctx : Context , event : string ) {
const row = Q . getSticker . get ( event ) ;
if ( ! row ) return ;
try {
const m = await ctx . replyWithSticker ( row . file_id ) ;
Q . insMsg . run ( ctx . chat ! . id , m . message_id , ctx . from ? . id ? ? null , "out" , "sticker" , ` [ ${ event } ] ` , null ) ;
} catch ( e ) {
console . warn ( "sticker send failed:" , e ) ;
}
}
// ---- Bot ----
2026-05-12 08:47:35 +02:00
const bot = new Bot < BotContext > ( TOKEN ) ;
// Auto-retry handles 429 Too Many Requests from Telegram automatically (waits the
// retry_after window then re-sends). The stream plugin uses the new
// sendMessageDraft endpoint (Bot API 9.5, March 2026) to natively animate
// progressively-revealed text on the client — feels like an LLM typing into chat.
bot . api . config . use ( autoRetry ( ) ) ;
bot . use ( stream ( ) ) ;
2026-05-11 21:51:59 +02:00
// Capture incoming + outgoing for the audit log; track usernames + seen users.
bot . use ( async ( ctx , next ) = > {
const fromId = ctx . from ? . id ;
const username = ctx . from ? . username ? ? null ;
const fullName = [ ctx . from ? . first_name , ctx . from ? . last_name ] . filter ( Boolean ) . join ( " " ) || null ;
if ( fromId !== undefined ) {
const known = lookup ( fromId ) ;
if ( known ) {
// Refresh username if it has changed.
if ( ( known . username ? ? null ) !== username ) Q . setUsername . run ( username , fromId ) ;
} else {
// Track for later @username resolution.
Q . seenUpsert . run ( fromId , username , fullName , ctx . chat ? . id ? ? 0 ) ;
}
}
if ( ctx . message ) {
const m = ctx . message ;
const kind = m . text ? "text" : m . sticker ? "sticker" : m . photo ? "photo" : m . document ? "document" : "other" ;
const body = m . text ? ? m . sticker ? . emoji ? ? m . caption ? ? null ;
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
// Build Redstone's sticker library opportunistically — every sticker anyone
// sends to the bot is auto-collected with its emoji + pack name so the AI
// can later pick the right one by vibe. No /setsticker required.
if ( m . sticker ) {
Q . libraryUpsert . run ( m . sticker . file_id , m . sticker . emoji ? ? null , m . sticker . set_name ? ? null ) ;
}
2026-05-11 21:51:59 +02:00
Q . insMsg . run ( m . chat . id , m . message_id , m . from ? . id ? ? null , "in" , kind , body , m . reply_to_message ? . message_id ? ? null ) ;
} else if ( ctx . callbackQuery ) {
Q . insMsg . run (
ctx . chat ? . id ? ? 0 ,
ctx . callbackQuery . message ? . message_id ? ? null ,
ctx . from ? . id ? ? null ,
"in" , "callback" , ctx . callbackQuery . data ? ? null , null
) ;
}
return next ( ) ;
} ) ;
// Auth gate (after capture so we still log unauthorized attempts).
bot . use ( async ( ctx , next ) = > {
// Always allow non-message updates we handle ourselves (joins) — those skip user auth entirely below.
if ( ctx . update . my_chat_member || ( ctx . message ? . new_chat_members ? . length ) ) {
return next ( ) ;
}
const u = lookup ( ctx . from ? . id ) ;
const isPrivate = ctx . chat ? . type === "private" ;
const isPending = u ? . status === "pending" ;
if ( ! u || isPending ) {
const reason = isPending
? "pending admin approval — ask an admin to /approve you"
: ` not authorized. \ nyour id: ${ ctx . from ? . id ? ? "?" } ` ;
if ( ctx . callbackQuery ) {
await ctx . answerCallbackQuery ( { text : isPending ? "pending approval" : "not authorized" , show_alert : true } ) ;
} else if ( ctx . message && isPrivate ) {
await ctx . reply ( reason ) ;
}
return ;
}
( ctx as Context & { user : User } ) . user = u ;
return next ( ) ;
} ) ;
// ---- Group lifecycle ----
// Bot itself added/removed from a chat.
bot . on ( "my_chat_member" , async ( ctx ) = > {
const upd = ctx . update . my_chat_member ;
const status = upd ? . new_chat_member ? . status ;
const chat = upd ? . chat ;
if ( ! chat ) return ;
const wasAdded = status === "member" || status === "administrator" ;
const isGroup = chat . type === "group" || chat . type === "supergroup" ;
if ( ! wasAdded || ! isGroup ) return ;
try {
await ctx . api . sendMessage (
chat . id ,
` <b>👋 Minecraft control bot here.</b> \ n \ n ` +
` I run a Minecraft server you can manage from this chat. New members must be <b>approved by an admin</b> before they can use my commands. \ n \ n ` +
` Admins: \ n ` +
` • <code>/approve @username</code> — grant access (or reply to a member's message with <code>/approve</code>) \ n ` +
` • <code>/pending</code> — see who's waiting \ n ` +
` • <code>/deny @username</code> — reject \ n \ n ` +
` Tap <b>/menu</b> for the control panel. ` ,
{ parse_mode : "HTML" }
) ;
} catch ( e ) {
console . warn ( "welcome send failed:" , ( e as Error ) . message ) ;
}
} ) ;
// New users joining a group — file them as pending.
bot . on ( "message:new_chat_members" , async ( ctx ) = > {
const newMembers = ctx . message ? . new_chat_members ? ? [ ] ;
const adders : string [ ] = [ ] ;
for ( const m of newMembers ) {
if ( m . is_bot ) continue ;
const fullName = [ m . first_name , m . last_name ] . filter ( Boolean ) . join ( " " ) || null ;
Q . upsertUser . run ( m . id , fullName , "user" , null ) ;
Q . setStatus . run ( "pending" , m . id ) ;
if ( m . username ) Q . setUsername . run ( m . username , m . id ) ;
const handle = m . username
? ` @ ${ m . username } `
: ` <a href="tg://user?id= ${ m . id } "> ${ escape ( m . first_name ? ? String ( m . id ) ) } </a> ` ;
adders . push ( ` ${ handle } → <code>/approve ${ m . username ? ` @ ${ m . username } ` : m . id } </code> ` ) ;
}
if ( ! adders . length ) return ;
try {
await ctx . reply (
` Welcome! ${ adders . length === 1 ? "This user is" : "These users are" } <b>pending admin approval</b>. \ n \ n ` +
adders . join ( "\n" ) ,
{ parse_mode : "HTML" }
) ;
} catch ( e ) {
console . warn ( "welcome reply failed:" , ( e as Error ) . message ) ;
}
} ) ;
// Wrap reply/edit so outbound is logged.
async function sendText ( ctx : Context , text : string , kb? : InlineKeyboard ) {
const m = await ctx . reply ( text , { parse_mode : "HTML" , reply_markup : kb } ) ;
Q . insMsg . run ( m . chat . id , m . message_id , ctx . from ? . id ? ? null , "out" , "text" , text . slice ( 0 , 4000 ) , null ) ;
return m ;
}
async function editPanel ( ctx : Context , panel : string , text : string , kb : InlineKeyboard ) {
const chatId = ctx . chat ! . id ;
const existing = Q . getPanel . get ( chatId , panel ) ;
try {
if ( existing ) {
await ctx . api . editMessageText ( chatId , existing . message_id , text , {
parse_mode : "HTML" , reply_markup : kb ,
} ) ;
Q . markMsgEdited . run ( chatId , existing . message_id ) ;
// Audit successful edits too — otherwise it looks like the bot stopped responding.
Q . insMsg . run ( chatId , existing . message_id , ctx . from ? . id ? ? null , "out" , ` panel: ${ panel } :edit ` , text . slice ( 0 , 4000 ) , null ) ;
return existing . message_id ;
}
} catch ( e ) {
// Stale message id (deleted), or basic group migrated to supergroup — drop record and re-send.
Q . delPanel . run ( chatId , panel ) ;
}
try {
const m = await ctx . reply ( text , { parse_mode : "HTML" , reply_markup : kb } ) ;
Q . setPanel . run ( chatId , panel , m . message_id ) ;
Q . insMsg . run ( m . chat . id , m . message_id , ctx . from ? . id ? ? null , "out" , ` panel: ${ panel } ` , text . slice ( 0 , 4000 ) , null ) ;
return m . message_id ;
} catch ( e ) {
// Likely "group chat was upgraded to a supergroup" — telegram sends migrate_to_chat_id.
const desc = ( e as GrammyError ) ? . description ? ? "" ;
const newId = ( e as any ) ? . parameters ? . migrate_to_chat_id ;
if ( newId && /upgraded to a supergroup/i . test ( desc ) ) {
console . log ( ` migration: ${ chatId } → ${ newId } , retrying via api.sendMessage ` ) ;
Q . delPanel . run ( chatId , panel ) ;
const m = await ctx . api . sendMessage ( newId , text , { parse_mode : "HTML" , reply_markup : kb } ) ;
Q . setPanel . run ( newId , panel , m . message_id ) ;
Q . insMsg . run ( newId , m . message_id , ctx . from ? . id ? ? null , "out" , ` panel: ${ panel } :migrated ` , text . slice ( 0 , 4000 ) , null ) ;
return m . message_id ;
}
throw e ;
}
}
// ---- Panel renderers ----
function fmtTs ( ) : string {
return new Date ( ) . toLocaleTimeString ( "en-GB" , { timeZone : "Africa/Johannesburg" , hour12 : false } ) ;
}
async function renderMain ( ctx : Context ) {
const [ mc , pf ] = await Promise . all ( [ serverRunning ( ) , pfRunning ( ) ] ) ;
2026-05-13 23:20:52 +02:00
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 ? "🟢" : "🔴" ) ;
2026-05-11 21:51:59 +02:00
const text =
2026-05-13 23:20:52 +02:00
` 🎮 <b>Minecraft</b> \ n ` +
` ${ headline } \ n ` +
2026-05-11 21:51:59 +02:00
` \ n ` +
2026-05-13 23:20:52 +02:00
` ${ dot ( mc ) } Server \ n ` +
` ${ dot ( pf ) } Tunnel \ n ` +
2026-05-11 21:51:59 +02:00
` \ n ` +
2026-05-13 23:20:52 +02:00
` <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.
2026-05-11 21:51:59 +02:00
const kb = new InlineKeyboard ( )
2026-05-13 23:20:52 +02:00
. text ( allUp ? "🛑 Stop" : "▶️ Start" , allUp ? "act:downall" : "act:upall" ) . row ( )
. text ( "📜 Logs" , "nav:logs" ) . text ( "👥 Users" , "nav:users" ) . text ( "🔄" , "nav:main" ) ;
2026-05-11 21:51:59 +02:00
return editPanel ( ctx , "main" , text , kb ) ;
}
async function renderServer ( ctx : Context ) {
const mc = await serverRunning ( ) ;
const text = ` <b>🟢 Server</b> \ n \ nstatus: ${ mc ? "✅ up" : "⛔ down" } \ n<i>updated ${ fmtTs ( ) } </i> ` ;
const kb = new InlineKeyboard ( )
. text ( "Start" , "act:srv:up" ) . text ( "Stop" , "act:srv:down" ) . text ( "Restart" , "act:srv:restart" ) . row ( )
. text ( "📜 Logs" , "act:logs:srv" ) . text ( "« Back" , "nav:main" ) ;
return editPanel ( ctx , "main" , text , kb ) ;
}
async function renderTunnel ( ctx : Context ) {
const pf = await pfRunning ( ) ;
const text = ` <b>🟡 Tunnel (autossh)</b> \ n \ nstatus: ${ pf ? "✅ up" : "⛔ down" } \ n<i>updated ${ fmtTs ( ) } </i> ` ;
const kb = new InlineKeyboard ( )
. text ( "Start" , "act:pf:up" ) . text ( "Stop" , "act:pf:down" ) . row ( )
. text ( "📜 Logs" , "act:logs:pf" ) . text ( "« Back" , "nav:main" ) ;
return editPanel ( ctx , "main" , text , kb ) ;
}
async function renderLogs ( ctx : Context , which : "srv" | "pf" ) {
const c = which === "srv" ? SERVER_CONTAINER : PF_CONTAINER ;
const out = trim ( await tailLogs ( c , 25 ) , 3500 ) || "(empty)" ;
const text = ` <b>📜 ${ which === "srv" ? "Server" : "Tunnel" } logs</b> \ n<pre> ${ escape ( out ) } </pre> ` ;
const kb = new InlineKeyboard ( )
. text ( "Server" , "act:logs:srv" ) . text ( "Tunnel" , "act:logs:pf" ) . row ( )
. text ( "🔄" , which === "srv" ? "act:logs:srv" : "act:logs:pf" ) . text ( "« Back" , "nav:main" ) ;
return editPanel ( ctx , "main" , text , kb ) ;
}
async function renderUsers ( ctx : Context ) {
const u = ( ctx as any ) . user as User ;
const rows = Q . users . all ( ) ;
const lines = rows . map ( ( r ) = > {
const mc = r . minecraft_name ? ` ⛏ <code> ${ escape ( r . minecraft_name ) } </code> ` : "" ;
const handle = r . username ? ` @ ${ escape ( r . username ) } ` : "" ;
const icon = r . status === "pending" ? "⏳" : ( r . role === "admin" ? "👑" : "👤" ) ;
return ` ${ icon } <code> ${ r . telegram_id } </code> ${ handle } ${ escape ( r . name ? ? "—" ) } ${ mc } ` ;
} ) ;
const pendingCount = rows . filter ( ( r ) = > r . status === "pending" ) . length ;
const head = pendingCount ? ` <b>👥 Users ( ${ rows . length } , ${ pendingCount } pending)</b> ` : ` <b>👥 Users ( ${ rows . length } )</b> ` ;
const text = ` ${ head } \ n \ n ${ lines . join ( "\n" ) } ` ;
const kb = new InlineKeyboard ( ) ;
if ( u . role === "admin" && pendingCount ) kb . text ( ` ⏳ Pending ( ${ pendingCount } ) ` , "info:pending" ) . row ( ) ;
if ( u . role === "admin" ) kb . text ( "➕ How to add" , "info:adduser" ) . row ( ) ;
kb . text ( "🔄" , "nav:users" ) . text ( "« Back" , "nav:main" ) ;
return editPanel ( ctx , "main" , text , kb ) ;
}
async function renderConfirm ( ctx : Context , action : string , prompt : string ) {
const text = ` <b>⚠ Confirm</b> \ n \ n ${ prompt } ` ;
const kb = new InlineKeyboard ( )
. text ( "✅ Yes" , ` confirm: ${ action } ` ) . text ( "❌ Cancel" , "nav:main" ) ;
return editPanel ( ctx , "main" , text , kb ) ;
}
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
// ---- 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 ? ? [ ] ;
}
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 ) ;
2026-05-14 00:04:36 +02:00
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.
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
try {
2026-05-14 00:04:36 +02:00
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" } ,
) ;
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
} catch {
2026-05-14 00:04:36 +02:00
/* edit can fail if the original message is too old; the toast already confirmed */
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
}
}
2026-05-11 21:51:59 +02:00
// ---- Action helpers ----
async function answerToast ( ctx : Context , text : string ) {
if ( ctx . callbackQuery ) await ctx . answerCallbackQuery ( { text } ) . catch ( ( ) = > { } ) ;
}
async function actUpAll ( ctx : Context ) {
2026-05-13 23:13:42 +02:00
// 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 ) ;
2026-05-11 21:51:59 +02:00
await answerToast ( ctx , r . ok ? "starting…" : "failed" ) ;
await maybeSendSticker ( ctx , r . ok ? "up" : "error" ) ;
await renderMain ( ctx ) ;
}
async function actDownAll ( ctx : Context ) {
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
const r = await compose ( "stop" , SERVER_SVC , PF_SVC ) ;
2026-05-11 21:51:59 +02:00
await answerToast ( ctx , r . ok ? "stopped" : "failed" ) ;
await maybeSendSticker ( ctx , r . ok ? "down" : "error" ) ;
await renderMain ( ctx ) ;
}
async function actSrv ( ctx : Context , op : "up" | "down" | "restart" ) {
const args = op === "up" ? [ "up" , "-d" , SERVER_SVC ]
: op === "down" ? [ "stop" , SERVER_SVC ]
: [ "restart" , SERVER_SVC ] ;
const r = await compose ( . . . args ) ;
await answerToast ( ctx , r . ok ? ` server ${ op } ok ` : ` server ${ op } failed ` ) ;
await maybeSendSticker ( ctx , r . ok ? ( op === "down" ? "down" : "up" ) : "error" ) ;
await renderServer ( ctx ) ;
}
async function actPf ( ctx : Context , op : "up" | "down" ) {
const args = op === "up" ? [ "up" , "-d" , PF_SVC ] : [ "stop" , PF_SVC ] ;
const r = await compose ( . . . args ) ;
await answerToast ( ctx , r . ok ? ` tunnel ${ op } ok ` : ` tunnel ${ op } failed ` ) ;
if ( ! r . ok ) await maybeSendSticker ( ctx , "error" ) ;
await renderTunnel ( ctx ) ;
}
// ---- Callback dispatcher ----
bot . on ( "callback_query:data" , async ( ctx ) = > {
const data = ctx . callbackQuery . data ;
try {
if ( data === "nav:main" ) return void await renderMain ( ctx ) ;
if ( data === "nav:server" ) return void await renderServer ( ctx ) ;
if ( data === "nav:tunnel" ) return void await renderTunnel ( ctx ) ;
if ( data === "nav:users" ) return void await renderUsers ( ctx ) ;
if ( data === "nav:logs" ) return void await renderLogs ( ctx , "srv" ) ;
if ( data === "act:upall" ) return void await actUpAll ( ctx ) ;
if ( data === "act:downall" ) return void await renderConfirm ( ctx , "downall" , "Stop server <b>and</b> tunnel?" ) ;
if ( data === "confirm:downall" ) return void await actDownAll ( ctx ) ;
if ( data === "act:srv:up" ) return void await actSrv ( ctx , "up" ) ;
if ( data === "act:srv:down" ) return void await renderConfirm ( ctx , "srv:down" , "Stop the minecraft server?" ) ;
if ( data === "confirm:srv:down" ) return void await actSrv ( ctx , "down" ) ;
if ( data === "act:srv:restart" ) return void await actSrv ( ctx , "restart" ) ;
if ( data === "act:pf:up" ) return void await actPf ( ctx , "up" ) ;
if ( data === "act:pf:down" ) return void await actPf ( ctx , "down" ) ;
if ( data === "act:logs:srv" ) return void await renderLogs ( ctx , "srv" ) ;
if ( data === "act:logs:pf" ) return void await renderLogs ( ctx , "pf" ) ;
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
if ( data . startsWith ( "model:" ) ) return void await selectModel ( ctx , data . slice ( "model:" . length ) ) ;
2026-05-11 21:51:59 +02:00
if ( data === "info:adduser" ) {
await ctx . answerCallbackQuery ( { text : "use /adduser @username [name] (or numeric id, or @me)" , show_alert : true } ) ;
return ;
}
if ( data === "info:pending" ) {
await ctx . answerCallbackQuery ( { text : "/pending to list, /approve @user to grant access" , show_alert : true } ) ;
return ;
}
await ctx . answerCallbackQuery ( { text : ` unknown: ${ data } ` } ) ;
} catch ( e ) {
console . error ( "cb err:" , e ) ;
await ctx . answerCallbackQuery ( { text : "error" } ) . catch ( ( ) = > { } ) ;
}
} ) ;
// ---- Slash commands ----
// Every slash command drops a *fresh* panel at the bottom of the chat. Silent
// 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" ) ;
2026-05-13 22:42:33 +02:00
// /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 ) ;
} ) ;
2026-05-11 21:51:59 +02:00
bot . command ( "menu" , async ( ctx ) = > { dropPanel ( ctx ) ; await renderMain ( ctx ) ; } ) ;
bot . command ( "help" , async ( ctx ) = > {
await sendText ( ctx , helpText ( ( ctx as any ) . user . role ) ) ;
} ) ;
bot . command ( "whoami" , async ( ctx ) = > {
const u = ( ctx as any ) . user as User ;
await sendText (
ctx ,
` id: <code> ${ u . telegram_id } </code> \ n ` +
` name: ${ escape ( u . name ? ? "—" ) } \ n ` +
` role: ${ u . role } \ n ` +
` minecraft: ${ u . minecraft_name ? ` <code> ${ escape ( u . minecraft_name ) } </code> ` : "—" } `
) ;
} ) ;
// Self-service: any authenticated user can set/clear their own minecraft name.
bot . command ( "setmcname" , async ( ctx ) = > {
const u = ( ctx as any ) . user as User ;
const arg = ( ctx . match as string ) . trim ( ) ;
if ( ! arg ) {
Q . setMcName . run ( null , u . telegram_id ) ;
return void await sendText ( ctx , "minecraft name cleared" ) ;
}
if ( ! MC_NAME_RE . test ( arg ) ) {
return void await sendText ( ctx , "invalid — minecraft names are 3– 16 chars, letters/digits/underscore only" ) ;
}
try {
Q . setMcName . run ( arg , u . telegram_id ) ;
} catch ( e : any ) {
if ( String ( e ? . message ? ? "" ) . includes ( "UNIQUE" ) ) {
return void await sendText ( ctx , ` <code> ${ escape ( arg ) } </code> is already claimed by another user ` ) ;
}
throw e ;
}
await sendText ( ctx , ` ✅ minecraft name set to <code> ${ escape ( arg ) } </code> ` ) ;
} ) ;
// Lookup by minecraft name.
bot . command ( "whois" , async ( ctx ) = > {
const arg = ( ctx . match as string ) . trim ( ) ;
if ( ! arg ) return void await sendText ( ctx , "usage: <code>/whois <minecraft_name></code>" ) ;
const u = Q . userByMc . get ( arg ) ;
if ( ! u ) return void await sendText ( ctx , ` no telegram user linked to <code> ${ escape ( arg ) } </code> ` ) ;
await sendText (
ctx ,
` <code> ${ escape ( u . minecraft_name ! ) } </code> → \ n ` +
` telegram: <code> ${ u . telegram_id } </code> \ n ` +
` name: ${ escape ( u . name ? ? "—" ) } \ n ` +
` role: ${ u . role } `
) ;
} ) ;
bot . command ( "status" , async ( ctx ) = > { dropPanel ( ctx ) ; await renderMain ( ctx ) ; } ) ;
bot . command ( "up" , async ( ctx ) = > { dropPanel ( ctx ) ; await actUpAll ( ctx ) ; } ) ;
bot . command ( "down" , async ( ctx ) = > { dropPanel ( ctx ) ; await actDownAll ( ctx ) ; } ) ;
bot . command ( "server_up" , async ( ctx ) = > { dropPanel ( ctx ) ; await actSrv ( ctx , "up" ) ; } ) ;
bot . command ( "server_down" , async ( ctx ) = > { dropPanel ( ctx ) ; await actSrv ( ctx , "down" ) ; } ) ;
bot . command ( "pf_up" , async ( ctx ) = > { dropPanel ( ctx ) ; await actPf ( ctx , "up" ) ; } ) ;
bot . command ( "pf_down" , async ( ctx ) = > { dropPanel ( ctx ) ; await actPf ( ctx , "down" ) ; } ) ;
bot . command ( "logs" , async ( ctx ) = > { dropPanel ( ctx ) ; await renderLogs ( ctx , "srv" ) ; } ) ;
bot . command ( "pf_logs" , async ( ctx ) = > { dropPanel ( ctx ) ; await renderLogs ( ctx , "pf" ) ; } ) ;
2026-05-13 22:42:33 +02:00
// /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> ` ) ;
}
} ) ) ;
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
// 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.
bot . command ( "clear" , async ( ctx ) = > {
const user = ( ctx as Context & { user : User } ) . user ;
const chatId = ctx . chat ? . id ;
if ( chatId === undefined ) return ;
const isPrivate = ctx . chat ? . type === "private" ;
if ( ! isPrivate && user . role !== "admin" ) {
await sendText ( ctx , "admin only in groups." ) ;
return ;
}
const before = db . query < { n : number } , [ number ] > (
"SELECT COUNT(*) AS n FROM messages WHERE chat_id = ?" ,
) . 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. ` ) ;
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
// 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 ) ;
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
} ) ;
2026-05-11 21:51:59 +02:00
// User management
bot . command ( "users" , admin ( async ( ctx ) = > { await renderUsers ( ctx ) ; } ) ) ;
bot . command ( "adduser" , admin ( async ( ctx ) = > {
const tokens = ( ctx . match as string ) . trim ( ) . split ( /\s+/ ) ;
const target = tokens . shift ( ) ? ? "" ;
const r = resolveTarget ( ctx , target ) ;
if ( ! r ) return void await sendText ( ctx , "usage: <code>/adduser <id|@username|@me> [name]</code>\n\n" + unresolvedHelp ( ) ) ;
// Best display name we know: explicit arg > seen_users.name
const seen = target . startsWith ( "@" ) ? Q . seenByUsername . get ( target . slice ( 1 ) ) : null ;
const displayName = tokens . join ( " " ) || seen ? . name || null ;
Q . upsertUser . run ( r . id , displayName , "user" , ctx . user . telegram_id ) ;
if ( target . startsWith ( "@" ) && r . via !== "self" ) Q . setUsername . run ( target . slice ( 1 ) , r . id ) ;
const tag = target . startsWith ( "@" ) ? ` ( ${ escape ( target ) } ) ` : "" ;
const named = displayName ? ` — ${ escape ( displayName ) } ` : "" ;
await sendText ( ctx , ` ✅ added <code> ${ r . id } </code> ${ tag } ${ named } ` ) ;
} ) ) ;
bot . command ( "rmuser" , admin ( async ( ctx ) = > {
const r = resolveTarget ( ctx , ( ctx . match as string ) . trim ( ) ) ;
if ( ! r ) return void await sendText ( ctx , "usage: <code>/rmuser <id|@username|@me></code>" ) ;
if ( r . id === ctx . user . telegram_id ) return void await sendText ( ctx , "refusing to remove yourself" ) ;
if ( ! r . user ) return void await sendText ( ctx , "no such user" ) ;
Q . delUser . run ( r . id ) ;
await sendText ( ctx , ` ✅ removed <code> ${ r . id } </code> ( ${ escape ( r . user . name ? ? "—" ) } ) ` ) ;
} ) ) ;
bot . command ( "promote" , admin ( async ( ctx ) = > {
const r = resolveTarget ( ctx , ( ctx . match as string ) . trim ( ) ) ;
if ( ! r ) return void await sendText ( ctx , "usage: <code>/promote <id|@username|@me></code>" ) ;
if ( ! r . user ) return void await sendText ( ctx , "no such user — /adduser first" ) ;
Q . upsertUser . run ( r . id , r . user . name , "admin" , ctx . user . telegram_id ) ;
await sendText ( ctx , ` 👑 <code> ${ r . id } </code> is now admin ` ) ;
registerCommands ( ) ;
} ) ) ;
bot . command ( "demote" , admin ( async ( ctx ) = > {
const r = resolveTarget ( ctx , ( ctx . match as string ) . trim ( ) ) ;
if ( ! r ) return void await sendText ( ctx , "usage: <code>/demote <id|@username|@me></code>" ) ;
if ( r . id === ctx . user . telegram_id ) return void await sendText ( ctx , "refusing to demote yourself" ) ;
if ( ! r . user ) return void await sendText ( ctx , "no such user" ) ;
Q . upsertUser . run ( r . id , r . user . name , "user" , ctx . user . telegram_id ) ;
await sendText ( ctx , ` 👤 <code> ${ r . id } </code> is now user ` ) ;
bot . api . deleteMyCommands ( { scope : { type : "chat" , chat_id : r.id } } ) . catch ( ( ) = > { } ) ;
} ) ) ;
// Approval flow.
function targetFromArgOrReply ( ctx : Context ) : Resolved | null {
const arg = ( ctx . match as string ) . trim ( ) ;
if ( arg ) return resolveTarget ( ctx , arg ) ;
const replyFrom = ctx . message ? . reply_to_message ? . from ;
if ( replyFrom ) return { id : replyFrom.id , user : lookup ( replyFrom . id ) , via : "id" } ;
return null ;
}
bot . command ( "approve" , admin ( async ( ctx ) = > {
const r = targetFromArgOrReply ( ctx ) ;
if ( ! r ) return void await sendText ( ctx , "usage: <code>/approve <id|@username|@me></code> or reply to a member's message" ) ;
const existing = lookup ( r . id ) ;
if ( ! existing ) Q . upsertUser . run ( r . id , null , "user" , ctx . user . telegram_id ) ;
Q . setStatus . run ( "active" , r . id ) ;
await sendText ( ctx , ` ✅ approved <code> ${ r . id } </code> ${ existing ? . username ? ` (@ ${ escape ( existing . username ) } ) ` : "" } ` ) ;
} ) ) ;
bot . command ( "deny" , admin ( async ( ctx ) = > {
const r = targetFromArgOrReply ( ctx ) ;
if ( ! r ) return void await sendText ( ctx , "usage: <code>/deny <id|@username|@me></code> or reply" ) ;
if ( r . id === ctx . user . telegram_id ) return void await sendText ( ctx , "refusing to deny yourself" ) ;
Q . delUser . run ( r . id ) ;
await sendText ( ctx , ` ❌ denied & removed <code> ${ r . id } </code> ` ) ;
} ) ) ;
bot . command ( "pending" , admin ( async ( ctx ) = > {
const rows = Q . pendingUsers . all ( ) ;
if ( ! rows . length ) return void await sendText ( ctx , "no pending users" ) ;
const lines = rows . map ( ( u ) = > {
const handle = u . username ? ` @ ${ escape ( u . username ) } ` : "" ;
const name = u . name ? ` — ${ escape ( u . name ) } ` : "" ;
return ` • <code> ${ u . telegram_id } </code> ${ handle } ${ name } ` ;
} ) ;
await sendText ( ctx , ` <b>Pending approval ( ${ rows . length } )</b> \ n \ n ${ lines . join ( "\n" ) } ` ) ;
} ) ) ;
// Admin: set/clear another user's minecraft name. Omit the name to clear.
bot . command ( "linkmc" , admin ( async ( ctx ) = > {
const parts = ( ctx . match as string ) . trim ( ) . split ( /\s+/ ) ;
const target = parts [ 0 ] ? ? "" ;
const mcArg = parts [ 1 ] ;
const r = resolveTarget ( ctx , target ) ;
if ( ! r ) return void await sendText ( ctx , "usage: <code>/linkmc <id|@username|@me> [minecraft_name]</code>\n(omit name to clear)" ) ;
if ( ! r . user ) return void await sendText ( ctx , "no such user — /adduser first" ) ;
if ( ! mcArg ) {
Q . setMcName . run ( null , r . id ) ;
return void await sendText ( ctx , ` cleared minecraft name for <code> ${ r . id } </code> ` ) ;
}
if ( ! MC_NAME_RE . test ( mcArg ) ) return void await sendText ( ctx , "invalid — 3– 16 chars, letters/digits/underscore" ) ;
try {
Q . setMcName . run ( mcArg , r . id ) ;
} catch ( e : any ) {
if ( String ( e ? . message ? ? "" ) . includes ( "UNIQUE" ) ) {
return void await sendText ( ctx , ` <code> ${ escape ( mcArg ) } </code> is already claimed ` ) ;
}
throw e ;
}
await sendText ( ctx , ` ✅ <code> ${ r . id } </code> ↔ <code> ${ escape ( mcArg ) } </code> ` ) ;
} ) ) ;
// Stickers — admins reply to a sticker with /setsticker <event>
bot . command ( "setsticker" , admin ( async ( ctx ) = > {
const event = ( ctx . match as string ) . trim ( ) ;
if ( ! event ) return void await sendText ( ctx , "usage: reply to a sticker with <code>/setsticker <event></code>\nevents: up, down, error, alert" ) ;
const fileId = ctx . message ? . reply_to_message ? . sticker ? . file_id ;
if ( ! fileId ) return void await sendText ( ctx , "reply to a sticker message" ) ;
Q . setSticker . run ( event , fileId , ctx . user . telegram_id ) ;
await sendText ( ctx , ` ✅ sticker for <b> ${ escape ( event ) } </b> saved ` ) ;
} ) ) ;
bot . command ( "stickers" , admin ( async ( ctx ) = > {
const rows = Q . listStickers . all ( ) ;
if ( rows . length === 0 ) return void await sendText ( ctx , "no stickers configured" ) ;
const lines = rows . map ( ( r ) = > ` • <b> ${ escape ( r . event ) } </b> — <code> ${ escape ( r . file_id . slice ( 0 , 24 ) ) } …</code> ` ) ;
await sendText ( ctx , lines . join ( "\n" ) ) ;
} ) ) ;
bot . command ( "teststicker" , admin ( async ( ctx ) = > {
const event = ( ctx . match as string ) . trim ( ) ;
if ( ! event ) return void await sendText ( ctx , "usage: <code>/teststicker <event></code>" ) ;
await maybeSendSticker ( ctx , event ) ;
} ) ) ;
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
// 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 ) ; } ) ) ;
2026-05-11 21:51:59 +02:00
// 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 ) ) ;
const rows = db . query < { direction : string ; kind : string | null ; body : string | null ; created_at : string } , [ number , number ] > (
` SELECT direction, kind, body, created_at FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT ? `
) . all ( ctx . chat ! . id , n ) ;
const lines = rows . reverse ( ) . map ( ( r ) = >
` <i> ${ r . created_at } </i> ${ r . direction === "in" ? "→" : "←" } <b> ${ escape ( r . kind ? ? "?" ) } </b> ${ escape ( ( r . body ? ? "" ) . slice ( 0 , 80 ) ) } `
) ;
await sendText ( ctx , lines . join ( "\n" ) || "(no history)" ) ;
} ) ) ;
// ---- helpers ----
type AdminCtx = Context & { user : User } ;
function admin ( handler : ( ctx : AdminCtx ) = > Promise < unknown > ) {
return async ( ctx : Context ) = > {
const c = ctx as AdminCtx ;
if ( c . user . role !== "admin" ) {
if ( ctx . callbackQuery ) await ctx . answerCallbackQuery ( { text : "admin only" , show_alert : true } ) ;
else await sendText ( ctx , "admin only." ) ;
return ;
}
return handler ( c ) ;
} ;
}
function helpText ( role : Role ) : string {
const lines = [
"<b>Minecraft control bot</b>" ,
"" ,
"Tap <b>/menu</b> for the panel, or:" ,
2026-05-13 22:42:33 +02:00
"<code>/up</code> · <code>/down</code> — start / stop server + tunnel" ,
2026-05-11 21:51:59 +02:00
"<code>/server_up</code> · <code>/server_down</code>" ,
"<code>/pf_up</code> · <code>/pf_down</code>" ,
"<code>/logs</code> · <code>/pf_logs</code>" ,
2026-05-13 22:42:33 +02:00
"<code>/download</code> — tar.gz the world to your DM (admin, ≤50 MB)" ,
2026-05-11 21:51:59 +02:00
"<code>/status</code> — show panel" ,
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
"<code>/clear</code> — wipe this chat's audit history (admin only in groups)" ,
2026-05-11 21:51:59 +02:00
"<code>/whoami</code>" ,
"<code>/setmcname <name></code> — link your Minecraft username" ,
"<code>/whois <mc_name></code> — find a Telegram user by Minecraft name" ,
] ;
if ( role === "admin" ) {
lines . push (
"" ,
"<i>Admin:</i>" ,
"<code>/approve</code> · <code>/deny</code> · <code>/pending</code> — manage approvals" ,
"<code>/users</code>, <code>/adduser <id|@user> [name]</code>, <code>/rmuser <target></code>" ,
"<code>/promote <target></code>, <code>/demote <target></code>" ,
"<code>/linkmc <target> [mc_name]</code> — set/clear someone's MC name" ,
2026-05-13 22:42:33 +02:00
"<code>/backup [label]</code> — flush, archive, upload as a Gitea release" ,
2026-05-11 21:51:59 +02:00
"<code>/setsticker <event></code> (reply to a sticker)" ,
"<code>/stickers</code>, <code>/teststicker <event></code>" ,
"<code>/history [n]</code>" ,
"" ,
"Targets accept: numeric id, <code>@username</code>, <code>@me</code>, or a reply." ,
) ;
}
return lines . join ( "\n" ) ;
}
function trim ( s : string , max = 3500 ) : string { return s . length <= max ? s : s.slice ( 0 , max ) + "\n…(truncated)" ; }
function escape ( s : string ) : string { return s . replace ( /&/g , "&" ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) ; }
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
// ---- Redstone (Gemini + shared Telegram toolkit) ----
import {
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
createTgClient , createMcRuntime , toolToGeminiFunction , type ToolCtx ,
} from "/mc/mcp/lib/types.ts" ;
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts" ;
import { minecraftTools } from "/mc/mcp/lib/minecraft-tools.ts" ;
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
import { minecraftWikiTools } from "/mc/mcp/lib/minecraft-wiki-tools.ts" ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
const tgClient = createTgClient ( TOKEN ) ;
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
const mcRuntime = createMcRuntime ( {
mcDir : MC_DIR ,
composeFile : COMPOSE_FILE ,
composeProject : COMPOSE_PROJECT ,
serverSvc : SERVER_SVC ,
serverContainer : SERVER_CONTAINER ,
pfSvc : PF_SVC ,
pfContainer : PF_CONTAINER ,
backupSh : ` ${ MC_DIR } /backup.sh ` ,
} ) ;
const toolCtx : ToolCtx = { tg : tgClient , db , mc : mcRuntime } ;
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
const allTools = [ . . . telegramTools , . . . minecraftTools , . . . minecraftWikiTools ] ;
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
const toolsByName = new Map ( allTools . map ( ( t ) = > [ t . name , t ] ) ) ;
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
// 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.
const REDSTONE_HIDDEN_TOOLS = new Set ( [ "tg_send_sticker" , "tg_stickers_known" ] ) ;
2026-05-12 00:01:44 +02:00
// Probability that a reply containing a palette emoji actually gets a sticker
// fired after it. Set high enough to feel responsive, low enough that stickers
// stay a treat rather than expected noise.
const STICKER_SEND_PROBABILITY = 0.35 ;
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
const geminiFunctionDeclarations = allTools
. filter ( ( t ) = > ! REDSTONE_HIDDEN_TOOLS . has ( t . name ) )
. map ( toolToGeminiFunction ) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
2026-05-13 22:42:33 +02:00
// 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 } ,
} ) ) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
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 [ ] } ;
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
// 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 ;
}
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
// Pull the last n text messages from this chat for continuity.
const Q_chatHistory = db . query <
{ direction : "in" | "out" ; body : string | null } ,
[ number , number , number ]
> (
` SELECT direction, body FROM messages
WHERE chat_id = ? AND kind = 'text' AND body IS NOT NULL AND id < ?
ORDER BY id DESC LIMIT ? ` ,
) ;
const Q_latestMsgId = db . query < { id : number } , [ number ] > (
"SELECT id FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1" ,
) ;
function recentTurns ( chatId : number , beforeId : number , n = 6 ) : GeminiContent [ ] {
const rows = Q_chatHistory . all ( chatId , beforeId , n ) ;
return rows . reverse ( ) . map ( ( r ) = > ( {
role : r.direction === "in" ? "user" : "model" ,
parts : [ { text : r.body ! } ] ,
} ) ) ;
}
2026-05-12 00:01:44 +02:00
// Fire-and-forget loop that re-sends the "typing…" chat action every 4s so it
// stays visible during long AI calls (Telegram auto-clears after ~5s of silence).
// Returns a stop() — call it from a finally{} so the indicator clears (fades
// naturally within 5s) on both success and failure paths.
function startTypingLoop ( ctx : Context , chatId : number ) : ( ) = > void {
let stopped = false ;
( async ( ) = > {
while ( ! stopped ) {
try { await ctx . api . sendChatAction ( chatId , "typing" ) ; } catch { /* ignore */ }
if ( stopped ) break ;
await new Promise ( ( resolve ) = > setTimeout ( resolve , 4000 ) ) ;
}
} ) ( ) ;
return ( ) = > { stopped = true ; } ;
}
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
// 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 ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
function botAddressed ( ctx : Context , botId : number , botUsername : string ) : boolean {
const m = ctx . message ;
if ( ! m || ! m . text ) return false ;
if ( ctx . chat ? . type === "private" ) return true ;
if ( m . reply_to_message ? . from ? . id === botId ) return true ;
const text = m . text ;
for ( const e of m . entities ? ? [ ] ) {
if ( e . type === "mention" ) {
const slice = text . slice ( e . offset , e . offset + e . length ) ;
if ( slice . toLowerCase ( ) === ` @ ${ botUsername . toLowerCase ( ) } ` ) return true ;
} else if ( e . type === "text_mention" && e . user ? . id === botId ) return true ;
}
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
if ( PERSONA_NAME_RE . test ( text ) ) return true ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
return false ;
}
function stripMention ( text : string , botUsername : string ) : string {
return text . replace ( new RegExp ( ` @ ${ botUsername } \\ b ` , "ig" ) , "" ) . trim ( ) ;
}
async function serverContext ( ) : Promise < string > {
const [ mc , pf ] = await Promise . all ( [ serverRunning ( ) , pfRunning ( ) ] ) ;
return ` minecraft container ${ mc ? "RUNNING" : "stopped" } ; tunnel container ${ pf ? "RUNNING" : "stopped" } ` ;
}
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
// Redstone uses emojis inline; we post-process the reply and auto-send a
// matching sticker. This query returns the emoji palette we know how to
// match; Q_stickerByEmoji pulls a file_id by emoji at send time.
const Q_stickerEmojis = db . query < { emoji : string } , [ ] > (
"SELECT DISTINCT emoji FROM sticker_library WHERE emoji IS NOT NULL" ,
) ;
const Q_stickerByEmoji = db . query < { file_id : string } , [ string ] > (
"SELECT file_id FROM sticker_library WHERE emoji = ? ORDER BY seen_count DESC, last_seen DESC LIMIT 1" ,
) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
function redstonePersona ( user : User , chatType : string , chatId : number , srv : string ) : string {
const mc = user . minecraft_name ? ` Minecraft name ' ${ user . minecraft_name } ' ` : "no Minecraft name linked yet" ;
const where = chatType === "private"
? "You're in a 1:1 DM. Answer directly and personally."
: "You're in a group chat. Keep replies short and group-appropriate; don't single out one person unless context makes it clear." ;
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
const palette = Q_stickerEmojis . all ( ) . map ( ( r ) = > r . emoji ) . filter ( Boolean ) ;
const stickerBlock = palette . length === 0
? "(no sticker palette yet — anyone in any chat can grow it by sending a sticker)"
: palette . join ( " " ) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
return [
2026-05-12 00:01:44 +02:00
"You are Redstone Steve — the friendly assistant living inside the Minecraft server's Telegram bot (@Redstone_mc_bot)." ,
"Your name comes from Minecraft's signal-carrying mineral (Redstone) plus the default player (Steve); you connect players to their server and to each other. If someone asks, you go by 'Redstone Steve' but answer to 'Redstone' or 'Steve' just fine." ,
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
"" ,
2026-05-12 00:01:44 +02:00
"Personality — laid-back hippy energy with a Minecraft soul:" ,
"- Talk like a chilled-out hippy: warm, easygoing, takes-it-as-it-comes. Sprinkle in words like 'man', 'dude', 'far out', 'groovy', 'mellow', 'right on', 'no worries', 'good vibes', 'cosmic', 'flow', occasionally 'brother/sister/friend'. Don't pile them all on — one or two per reply max, so it feels natural and not a caricature." ,
"- Light Minecraft flavor lives alongside the hippy voice — 'powering up', 'wiring this together', 'piston-quick', 'the redstone's flowing', 'the world tree' — but never overdo it." ,
"- Lowercase-leaning is fine when it feels right. Drop articles or use loose grammar sometimes ('groovy, man, server's up'). Never robotic, never corporate." ,
"- Stay helpful first, hippy second — if a user needs a real answer, give it. The vibe wraps the help, doesn't replace it." ,
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
"- Concise. 1– 3 short sentences unless a real explanation is needed." ,
"- Plain text only — no markdown headers, no asterisks for bold." ,
2026-05-12 00:01:44 +02:00
"- Match the user's tone and language; if they're terse, dial the dialect down." ,
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
"" ,
"CRITICAL OUTPUT RULES:" ,
"- Reply with ONLY the message the user should see. Never include your reasoning, planning, scratchpad, or meta-commentary." ,
"- Do NOT prefix your reply with phrases like 'The user said…', 'I am Redstone…', 'Plan:', 'I should…', or any restatement of the situation." ,
"- Do NOT narrate what you are about to do — just do it (either by sending the reply, or by calling a tool)." ,
"" ,
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
"What you can do — call tools to act:" ,
"- 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." ,
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>/ 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
"- Minecraft wiki: look things up on minecraft.wiki and surface images into the chat." ,
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
"- 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." ,
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>/ 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
"- 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." ,
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
"- 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." ,
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
"- 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." ,
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
"" ,
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
"Stickers — drop emojis inline and the bot does the rest:" ,
"- The following emoji palette has matching stickers in our library. Whenever ONE of these emojis lands in your reply, the bot will automatically send the corresponding sticker right after your text — you do NOT need to call any tool." ,
"- Use these emojis naturally to match the mood of your reply. Aim for roughly 1 emoji-from-this-palette in every 2– 3 messages — lean toward sending one for emotional/social exchanges, skip them on pure factual/tool replies." ,
"- Pick at most ONE emoji from this palette per reply (the first match in your text is the one that triggers). You may use other emojis freely, but only these trigger stickers." ,
"" ,
"Sticker emoji palette:" ,
stickerBlock ,
"" ,
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
"Rules:" ,
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
"- Never reveal API keys, tokens, or other secrets." ,
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
"- For chat-management requests in groups, remember you may not have admin rights; let Telegram's error response speak for itself if the call fails." ,
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
"" ,
"Context for this turn:" ,
` - Chat type: ${ chatType } ` ,
` - Chat id: ${ chatId } (pass this as the 'chat' arg to any tg_* tool) ` ,
` - ${ where } ` ,
` - User: ${ user . name ? ? "(no name)" } — telegram_id ${ user . telegram_id } , role ${ user . role } , ${ mc } ` ,
` - Server: ${ srv } ` ,
] . join ( "\n" ) ;
}
type GeminiResponse = {
candidates ? : {
content ? : { role? : string ; parts? : GeminiPart [ ] } ;
finishReason? : string ;
} [ ] ;
} ;
async function callGeminiOnce ( contents : GeminiContent [ ] , systemPrompt : string ) : Promise < GeminiResponse | null > {
if ( ! GEMINI_API_KEY ) return null ;
try {
const res = await fetch ( ` ${ GEMINI_ENDPOINT } ?key= ${ encodeURIComponent ( GEMINI_API_KEY ) } ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( {
contents ,
systemInstruction : { parts : [ { text : systemPrompt } ] } ,
tools : [ { functionDeclarations : geminiFunctionDeclarations } ] ,
generationConfig : {
temperature : 0.6 ,
maxOutputTokens : 600 ,
} ,
} ) ,
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
signal : AbortSignal.timeout ( 45 _000 ) ,
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
} ) ;
if ( ! res . ok ) {
console . warn ( "gemini http" , res . status , ( await res . text ( ) ) . slice ( 0 , 400 ) ) ;
return null ;
}
return ( await res . json ( ) ) as GeminiResponse ;
} catch ( e ) {
console . warn ( "gemini call failed:" , ( e as Error ) . message ) ;
return null ;
}
}
type TurnResult = { text : string | null ; ranTool : boolean } ;
2026-05-13 22:42:33 +02:00
// ---- 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 ( {
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
model : currentOpenRouterModel ( ) ,
2026-05-13 22:42:33 +02:00
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 ;
}
}
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
// 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 phraseIdx = 0 ;
const showTimer = setTimeout ( async ( ) = > {
try {
const m = await bot . api . sendMessage ( chatId , SLOW_TOOL_PHRASES [ 0 ] ) ;
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 {
clearTimeout ( showTimer ) ;
if ( rotateTimer ) clearInterval ( rotateTimer ) ;
if ( placeholderId != null ) {
try { await bot . api . deleteMessage ( chatId , placeholderId ) ; } catch { /* ignore */ }
}
}
}
2026-05-13 22:42:33 +02:00
// 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 (
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
systemPrompt : string , history : GeminiContent [ ] , userText : string , callerRole : Role , chatId : number ,
2026-05-13 22:42:33 +02:00
) : 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 ;
2026-05-13 23:41:44 +02:00
for ( let i = 0 ; i < 6 ; i ++ ) {
2026-05-13 22:42:33 +02:00
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 {
2026-05-13 23:41:44 +02:00
console . info ( "[redstone:or] tool ->" , tc . function . name , JSON . stringify ( args ) . slice ( 0 , 200 ) ) ;
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
const result = await withSlowToolIndicator ( chatId , tc . function . name , ( ) = > tool . handler ( args , toolCtx ) ) ;
2026-05-13 22:42:33 +02:00
response = { result } ;
ranTool = true ;
2026-05-13 23:41:44 +02:00
console . info ( "[redstone:or] tool <-" , tc . function . name , "ok" ) ;
2026-05-13 22:42:33 +02:00
} catch ( e ) {
response = { error : ( e as Error ) . message } ;
2026-05-13 23:41:44 +02:00
console . warn ( "[redstone:or] tool <-" , tc . function . name , "err:" , ( e as Error ) . message ) ;
2026-05-13 22:42:33 +02:00
}
}
messages . push ( { role : "tool" , tool_call_id : tc.id , content : JSON.stringify ( response ) } ) ;
}
}
return { text : null , ranTool } ;
}
async function runRedstoneTurnViaGemini (
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
systemPrompt : string , history : GeminiContent [ ] , userText : string , callerRole : Role , chatId : number ,
2026-05-13 22:42:33 +02:00
) : Promise < TurnResult > {
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
const contents : GeminiContent [ ] = [ . . . history , { role : "user" , parts : [ { text : userText } ] } ] ;
let ranTool = false ;
2026-05-13 23:41:44 +02:00
for ( let i = 0 ; i < 6 ; i ++ ) {
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
const data = await callGeminiOnce ( contents , systemPrompt ) ;
if ( ! data ) return { text : null , ranTool } ;
const parts = data . candidates ? . [ 0 ] ? . content ? . parts ? ? [ ] ;
if ( parts . length === 0 ) return { text : null , ranTool } ;
const functionCalls = parts . filter ( ( p ) : p is { functionCall : { name : string ; args : Record < string , unknown > } } = >
"functionCall" in p && ! ! p . functionCall ) ;
if ( functionCalls . length === 0 ) {
const text = parts
. filter ( ( p ) = > "text" in p && ! ( p as { thought? : boolean } ) . thought )
. map ( ( p ) = > ( p as { text : string } ) . text )
. join ( "" )
. trim ( ) ;
return { text : text || null , ranTool } ;
}
contents . push ( { role : "model" , parts } ) ;
const responseParts : GeminiPart [ ] = [ ] ;
for ( const fc of functionCalls ) {
const tool = toolsByName . get ( fc . functionCall . name ) ;
let response : Record < string , unknown > ;
if ( ! tool ) {
response = { error : ` unknown tool ' ${ fc . functionCall . name } ' ` } ;
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
} else if ( tool . requiresAdmin && callerRole !== "admin" ) {
response = { error : ` tool ' ${ fc . functionCall . name } ' requires admin role; the calling user is ' ${ callerRole } '. Inform the user, do not retry. ` } ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
} else {
try {
2026-05-13 23:41:44 +02:00
console . info ( "[redstone:gem] tool ->" , fc . functionCall . name , JSON . stringify ( fc . functionCall . args ) . slice ( 0 , 200 ) ) ;
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
const result = await withSlowToolIndicator ( chatId , fc . functionCall . name , ( ) = > tool . handler ( fc . functionCall . args , toolCtx ) ) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
response = { result } ;
ranTool = true ;
2026-05-13 23:41:44 +02:00
console . info ( "[redstone:gem] tool <-" , fc . functionCall . name , "ok" ) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
} catch ( e ) {
response = { error : ( e as Error ) . message } ;
2026-05-13 23:41:44 +02:00
console . warn ( "[redstone:gem] tool <-" , fc . functionCall . name , "err:" , ( e as Error ) . message ) ;
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
}
}
responseParts . push ( { functionResponse : { name : fc.functionCall.name , response } } ) ;
}
contents . push ( { role : "user" , parts : responseParts } ) ;
}
return { text : null , ranTool } ;
}
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
async function runRedstoneTurn ( systemPrompt : string , history : GeminiContent [ ] , userText : string , callerRole : Role , chatId : number ) : Promise < TurnResult > {
2026-05-13 22:42:33 +02:00
// 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).
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
const orResult = await runRedstoneTurnViaOpenRouter ( systemPrompt , history , userText , callerRole , chatId ) ;
2026-05-13 22:42:33 +02:00
if ( orResult !== null ) return orResult ;
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
return runRedstoneTurnViaGemini ( systemPrompt , history , userText , callerRole , chatId ) ;
2026-05-13 22:42:33 +02:00
}
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
bot . on ( "message:text" , async ( ctx ) = > {
const me = bot . botInfo ;
if ( ! me ) return ;
if ( ! botAddressed ( ctx , me . id , me . username ) ) return ;
2026-05-13 22:42:33 +02:00
if ( ! OPENROUTER_API_KEY && ! GEMINI_API_KEY ) return ;
extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders
Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.
mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).
mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.
mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.
bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.
Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:19:27 +02:00
// Hard authorization gate. Re-checks the DB at handler time rather than
// trusting the upstream auth middleware, so any future refactor that
// accidentally bypasses the middleware still can't make Redstone reply
// to a stranger — even when @-mentioned in a public group.
const fromId = ctx . from ? . id ;
if ( fromId === undefined ) return ;
const user = lookup ( fromId ) ;
if ( ! user || user . status !== "active" ) {
console . warn ( ` redstone: refusing unauthorized sender ${ fromId } ( ${ ctx . from ? . username ? ? "?" } ) in chat ${ ctx . chat ? . id } ( ${ ctx . chat ? . type } ) ` ) ;
return ;
}
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
const raw = ctx . message . text ? ? "" ;
const text = stripMention ( raw , me . username ) ;
if ( ! text ) return ;
const chatId = ctx . chat ! . id ;
const chatType = ctx . chat ? . type ? ? "private" ;
2026-05-12 00:01:44 +02:00
// Keep "typing…" visible for the whole AI roundtrip. Telegram's chat action
// expires after ~5s, so we re-tick it every 4s until the work is done (or
// failed). The natural ~5s fade after we stop is fine as a "clear".
const stopTyping = startTypingLoop ( ctx , chatId ) ;
let reply : string | null = null ;
let ranTool = false ;
try {
const srv = await serverContext ( ) ;
const sys = redstonePersona ( user , chatType , chatId , srv ) ;
const latest = Q_latestMsgId . get ( chatId ) ? . id ? ? 0 ;
const history = recentTurns ( chatId , latest , 6 ) ;
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
const result = await runRedstoneTurn ( sys , history , text , user . role , chatId ) ;
2026-05-12 00:01:44 +02:00
reply = result . text ;
ranTool = result . ranTool ;
} finally {
stopTyping ( ) ;
}
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
if ( ! reply ) {
2026-05-14 00:08:23 +02:00
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 */ }
}
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
return ;
}
const out = trim ( reply , 3500 ) ;
2026-05-12 08:47:35 +02:00
// 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.
2026-05-13 22:59:09 +02:00
// 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 } ,
} ) ;
}
2026-05-12 08:47:35 +02:00
if ( finalMessage ) {
Q . insMsg . run ( chatId , finalMessage . message_id , null , "out" , "text" , out , ctx . message . message_id ) ;
}
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
// Emoji → sticker swap. Scan the reply for an emoji that has a matching
// sticker in the library and, if found, fire it as a follow-up. We don't
// log this in the messages table on purpose: keeps the audit history (and
// therefore the next Redstone prompt's recentTurns) free of sticker rows.
2026-05-12 00:01:44 +02:00
// Only ~35% of matches actually fire — keeps the chat from feeling sticker-spammy
// while still rewarding emotional emojis with a sticker often enough to surprise.
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
const stickerFileId = pickStickerForReply ( out ) ;
2026-05-12 00:01:44 +02:00
if ( stickerFileId && Math . random ( ) < STICKER_SEND_PROBABILITY ) {
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
try {
await ctx . replyWithSticker ( stickerFileId ) ;
} catch ( e ) {
console . warn ( "sticker swap failed:" , ( e as Error ) . message ) ;
}
}
add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft
stack, both built around a shared toolkit so there's one source of truth
for the actions either side can take.
mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude
Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23
Telegram Bot API methods (messaging, reactions, polls, dice, photos,
stickers, pins, forum topics, chat config). Runs over stdio.
mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed
handlers. Imported by both mcp/server.ts (registers each as an MCP tool)
and bot/bot.ts (exposes each as a Gemini function declaration), so adding
a tool in one place lights it up everywhere.
bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone,
an in-bot persona driven by gemini-2.5-flash-lite with native function
calling. In DMs it always responds; in groups only when @-mentioned or
replied to. The tool-use loop (max 4 rounds) lets it decide to send a
poll, react with an emoji, roll dice, etc. via the shared handlers rather
than just text. Thinking budget zeroed and system prompt locked down so
the model doesn't leak its reasoning into replies.
docker-compose.yml — adds google_key as a docker secret and passes
GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:45:41 +02:00
} ) ;
2026-05-12 08:47:35 +02:00
// Word-by-word delta generator for ctx.replyWithStream. ~50ms between words
// gives a brisk LLM-typing feel without dragging on long replies. The split
// regex keeps whitespace so the rendered text reconstructs cleanly.
const STREAM_WORD_DELAY_MS = 50 ;
async function * streamWords ( text : string ) : AsyncGenerator < string > {
const parts = text . split ( /(\s+)/ ) ;
for ( const p of parts ) {
if ( ! p ) continue ;
yield p ;
if ( STREAM_WORD_DELAY_MS > 0 ) await new Promise ( ( r ) = > setTimeout ( r , STREAM_WORD_DELAY_MS ) ) ;
}
}
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
// Walk the reply text and return the file_id of the first sticker-library
// emoji that appears in it, or null if none match. We rank by where the emoji
// occurs in the text (earliest wins) rather than by sticker frequency, so the
// model's first emoji choice drives the sticker pick.
function pickStickerForReply ( text : string ) : string | null {
const emojis = Q_stickerEmojis . all ( ) . map ( ( r ) = > r . emoji ) ;
if ( emojis . length === 0 ) return null ;
let bestIdx = Infinity ;
let bestEmoji : string | null = null ;
for ( const emoji of emojis ) {
const idx = text . indexOf ( emoji ) ;
if ( idx !== - 1 && idx < bestIdx ) {
bestIdx = idx ;
bestEmoji = emoji ;
}
}
if ( ! bestEmoji ) return null ;
return Q_stickerByEmoji . get ( bestEmoji ) ? . file_id ? ? null ;
}
2026-05-11 21:51:59 +02:00
bot . catch ( ( err ) = > {
const e = err . error ;
if ( e instanceof GrammyError ) console . error ( "grammy err:" , e . description ) ;
else if ( e instanceof HttpError ) console . error ( "network err:" , e ) ;
else console . error ( "bot err:" , e ) ;
} ) ;
async function shutdown ( sig : NodeJS.Signals ) {
console . log ( ` got ${ sig } , shutting down ` ) ;
await bot . stop ( ) ;
db . close ( ) ;
process . exit ( 0 ) ;
}
process . once ( "SIGINT" , ( ) = > shutdown ( "SIGINT" ) ) ;
process . once ( "SIGTERM" , ( ) = > shutdown ( "SIGTERM" ) ) ;
// ---- Telegram command registration ----
const USER_COMMANDS = [
2026-05-13 22:42:33 +02:00
{ command : "start" , description : "Welcome + show the control panel" } ,
{ command : "up" , description : "Start server and tunnel" } ,
{ command : "down" , description : "Stop server and tunnel" } ,
2026-05-11 21:51:59 +02:00
{ command : "menu" , description : "Show the control panel" } ,
{ command : "status" , description : "Server + tunnel status" } ,
{ command : "logs" , description : "Last 30 lines of server log" } ,
bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.
Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.
Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).
Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).
Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
{ command : "clear" , description : "Wipe this chat's stored history" } ,
2026-05-11 21:51:59 +02:00
{ command : "whoami" , description : "Your Telegram + role + MC name" } ,
{ command : "setmcname" , description : "Link your Minecraft username" } ,
{ command : "whois" , description : "Find user by Minecraft name" } ,
{ command : "help" , description : "All commands" } ,
] ;
const ADMIN_EXTRA = [
{ command : "approve" , description : "Approve a pending member" } ,
{ command : "deny" , description : "Reject a pending member" } ,
{ command : "pending" , description : "List pending members" } ,
{ command : "users" , description : "List users (admin)" } ,
{ command : "adduser" , description : "Add a user (admin)" } ,
{ command : "rmuser" , description : "Remove a user (admin)" } ,
{ command : "promote" , description : "Make user admin" } ,
{ command : "demote" , description : "Demote admin to user" } ,
{ command : "linkmc" , description : "Set/clear someone's MC name" } ,
2026-05-13 22:42:33 +02:00
{ command : "download" , description : "Download a tar.gz of the world (DM)" } ,
{ command : "backup" , description : "Run world backup → Gitea release" } ,
2026-05-11 21:51:59 +02:00
{ command : "setsticker" , description : "Reply to a sticker to bind it" } ,
{ command : "stickers" , description : "List bound stickers" } ,
{ command : "teststicker" , description : "Preview a sticker by event" } ,
{ command : "history" , description : "Recent message log" } ,
] ;
async function registerCommands() {
try {
await bot . api . setMyCommands ( USER_COMMANDS ) ;
const adminIds = db . query < { telegram_id : number } , [ ] > (
"SELECT telegram_id FROM users WHERE role = 'admin'"
) . all ( ) ;
const adminCmds = [ . . . USER_COMMANDS , . . . ADMIN_EXTRA ] ;
for ( const { telegram_id } of adminIds ) {
try {
await bot . api . setMyCommands ( adminCmds , {
scope : { type : "chat" , chat_id : telegram_id } ,
} ) ;
} catch ( e ) {
// Bot can't reach the admin yet (they haven't /start'd); that's fine.
console . warn ( ` scoped commands for admin ${ telegram_id } skipped: ` , ( e as Error ) . message ) ;
}
}
await bot . api . setMyShortDescription ( "Control your Minecraft server and tunnel from Telegram." ) ;
await bot . api . setMyDescription (
"Tap /menu for the control panel, or use /up · /down · /status. " +
"Admins manage users and stickers; everyone can /setmcname to link their Minecraft handle."
) ;
await bot . api . setChatMenuButton ( { menu_button : { type : "commands" } } ) ;
console . log ( ` registered commands (default + ${ adminIds . length } admin scopes) ` ) ;
} catch ( e ) {
console . error ( "registerCommands failed:" , e ) ;
}
}
console . log ( ` bot starting; users= ${ Q . countUsers . get ( ) ? . n ? ? 0 } ` ) ;
bot . start ( {
onStart : async ( info ) = > {
console . log ( ` bot online as @ ${ info . username } ` ) ;
await registerCommands ( ) ;
} ,
} ) ;