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>
This commit is contained in:
61
bot/bot.ts
61
bot/bot.ts
@@ -868,13 +868,26 @@ function escape(s: string): string { return s.replace(/&/g, "&").replace(/</
|
||||
// ---- Redstone (Gemini + shared Telegram toolkit) ----
|
||||
|
||||
import {
|
||||
createTgClient, telegramTools, toolToGeminiFunction, type ToolCtx,
|
||||
} from "/mc/mcp/lib/telegram-tools.ts";
|
||||
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";
|
||||
|
||||
const tgClient = createTgClient(TOKEN);
|
||||
const toolCtx: ToolCtx = { tg: tgClient, db };
|
||||
const toolsByName = new Map(telegramTools.map((t) => [t.name, t]));
|
||||
const geminiFunctionDeclarations = telegramTools.map(toolToGeminiFunction);
|
||||
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 };
|
||||
const allTools = [...telegramTools, ...minecraftTools];
|
||||
const toolsByName = new Map(allTools.map((t) => [t.name, t]));
|
||||
const geminiFunctionDeclarations = allTools.map(toolToGeminiFunction);
|
||||
|
||||
type GeminiPart =
|
||||
| { text: string }
|
||||
@@ -947,14 +960,15 @@ function redstonePersona(user: User, chatType: string, chatId: number, srv: stri
|
||||
"- 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).",
|
||||
"",
|
||||
"What you can do:",
|
||||
"- You have tools to act inside this Telegram chat: send messages, polls, reactions, dice rolls, stickers, edit/pin/delete your own messages, query chat info, etc.",
|
||||
"- Use a tool when the user wants you to *do* something in the chat (e.g. 'run a poll', 'react with fire', 'pin that').",
|
||||
"- When you do use a tool, you usually do not need a separate text reply unless you're adding context — the tool's effect IS the response.",
|
||||
"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.",
|
||||
"- 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.",
|
||||
"",
|
||||
"What you cannot do:",
|
||||
"- You CANNOT start/stop the server, run rcon commands, take backups, or change Minecraft state. Those have slash commands (/up, /down, /status, /logs, /setmcname, /whois, /menu) — point users at them instead of pretending.",
|
||||
"Rules:",
|
||||
"- Never reveal API keys, tokens, or other secrets.",
|
||||
"- 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.",
|
||||
"",
|
||||
"Context for this turn:",
|
||||
`- Chat type: ${chatType}`,
|
||||
@@ -985,9 +999,6 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string):
|
||||
generationConfig: {
|
||||
temperature: 0.6,
|
||||
maxOutputTokens: 600,
|
||||
// Disable Gemini's internal reasoning trace — Flash Lite sometimes leaks
|
||||
// its plan into the user-visible text part. Zero budget = no thinking.
|
||||
thinkingConfig: { thinkingBudget: 0, includeThoughts: false },
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
@@ -1005,10 +1016,9 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string):
|
||||
|
||||
type TurnResult = { text: string | null; ranTool: boolean };
|
||||
|
||||
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string): Promise<TurnResult> {
|
||||
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise<TurnResult> {
|
||||
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
|
||||
let ranTool = false;
|
||||
// Cap tool-use iterations so a confused model can't loop forever.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const data = await callGeminiOnce(contents, systemPrompt);
|
||||
if (!data) return { text: null, ranTool };
|
||||
@@ -1018,7 +1028,6 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u
|
||||
const functionCalls = parts.filter((p): p is { functionCall: { name: string; args: Record<string, unknown> } } =>
|
||||
"functionCall" in p && !!p.functionCall);
|
||||
if (functionCalls.length === 0) {
|
||||
// Drop any thought parts (`thought: true`) if they slipped through.
|
||||
const text = parts
|
||||
.filter((p) => "text" in p && !(p as { thought?: boolean }).thought)
|
||||
.map((p) => (p as { text: string }).text)
|
||||
@@ -1034,6 +1043,8 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u
|
||||
let response: Record<string, unknown>;
|
||||
if (!tool) {
|
||||
response = { error: `unknown tool '${fc.functionCall.name}'` };
|
||||
} 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.` };
|
||||
} else {
|
||||
try {
|
||||
const result = await tool.handler(fc.functionCall.args, toolCtx);
|
||||
@@ -1055,8 +1066,18 @@ bot.on("message:text", async (ctx) => {
|
||||
if (!me) return;
|
||||
if (!botAddressed(ctx, me.id, me.username)) return;
|
||||
if (!GEMINI_API_KEY) return;
|
||||
const user = (ctx as Context & { user?: User }).user;
|
||||
if (!user) return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const raw = ctx.message.text ?? "";
|
||||
const text = stripMention(raw, me.username);
|
||||
@@ -1072,7 +1093,7 @@ bot.on("message:text", async (ctx) => {
|
||||
const latest = Q_latestMsgId.get(chatId)?.id ?? 0;
|
||||
const history = recentTurns(chatId, latest, 6);
|
||||
|
||||
const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text);
|
||||
const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text, user.role);
|
||||
if (!reply) {
|
||||
// If Redstone took an action (e.g. sent a poll) and didn't add commentary,
|
||||
// staying silent is correct. Only warn-silently when nothing happened.
|
||||
|
||||
Reference in New Issue
Block a user