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>
This commit is contained in:
233
bot/bot.ts
233
bot/bot.ts
@@ -26,6 +26,17 @@ 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();
|
||||
|
||||
// ---- 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`;
|
||||
|
||||
// ---- DB ----
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
const db = new Database(DB_PATH);
|
||||
@@ -534,7 +545,7 @@ async function actUpAll(ctx: Context) {
|
||||
}
|
||||
|
||||
async function actDownAll(ctx: Context) {
|
||||
const r = await compose("down");
|
||||
const r = await compose("stop", SERVER_SVC, PF_SVC);
|
||||
await answerToast(ctx, r.ok ? "stopped" : "failed");
|
||||
await maybeSendSticker(ctx, r.ok ? "down" : "error");
|
||||
await renderMain(ctx);
|
||||
@@ -854,6 +865,226 @@ function helpText(role: Role): string {
|
||||
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, ">"); }
|
||||
|
||||
// ---- Redstone (Gemini + shared Telegram toolkit) ----
|
||||
|
||||
import {
|
||||
createTgClient, telegramTools, toolToGeminiFunction, type ToolCtx,
|
||||
} from "/mc/mcp/lib/telegram-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);
|
||||
|
||||
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[] };
|
||||
|
||||
// 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! }],
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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"}`;
|
||||
}
|
||||
|
||||
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.";
|
||||
return [
|
||||
"You are Redstone — the friendly assistant living inside the Minecraft server's Telegram bot (@Redstone_mc_bot).",
|
||||
"Your name comes from Minecraft's signal-carrying mineral; you connect players to their server and to each other.",
|
||||
"",
|
||||
"Personality:",
|
||||
"- Helpful, chill, a touch of dry humor. Occasionally use light Minecraft flavor ('powering up', 'wiring this together', 'piston-quick') but never overdo it.",
|
||||
"- Concise. 1–3 short sentences unless a real explanation is needed.",
|
||||
"- Plain text only — no markdown headers, no asterisks for bold.",
|
||||
"- Match the user's tone and language.",
|
||||
"",
|
||||
"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).",
|
||||
"",
|
||||
"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 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.",
|
||||
"- Never reveal API keys, tokens, or other secrets.",
|
||||
"",
|
||||
"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,
|
||||
// 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),
|
||||
});
|
||||
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 };
|
||||
|
||||
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string): 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 };
|
||||
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) {
|
||||
// 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)
|
||||
.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}'` };
|
||||
} else {
|
||||
try {
|
||||
const result = await tool.handler(fc.functionCall.args, toolCtx);
|
||||
response = { result };
|
||||
ranTool = true;
|
||||
} catch (e) {
|
||||
response = { error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
responseParts.push({ functionResponse: { name: fc.functionCall.name, response } });
|
||||
}
|
||||
contents.push({ role: "user", parts: responseParts });
|
||||
}
|
||||
return { text: null, ranTool };
|
||||
}
|
||||
|
||||
bot.on("message:text", async (ctx) => {
|
||||
const me = bot.botInfo;
|
||||
if (!me) return;
|
||||
if (!botAddressed(ctx, me.id, me.username)) return;
|
||||
if (!GEMINI_API_KEY) return;
|
||||
const user = (ctx as Context & { user?: User }).user;
|
||||
if (!user) return;
|
||||
|
||||
const raw = ctx.message.text ?? "";
|
||||
const text = stripMention(raw, me.username);
|
||||
if (!text) return;
|
||||
|
||||
try { await ctx.replyWithChatAction("typing"); } catch { /* ignore */ }
|
||||
|
||||
const chatId = ctx.chat!.id;
|
||||
const chatType = ctx.chat?.type ?? "private";
|
||||
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);
|
||||
|
||||
const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text);
|
||||
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.
|
||||
if (!ranTool) console.warn("redstone: no reply and no tool ran for", { chatId, text: text.slice(0, 80) });
|
||||
return;
|
||||
}
|
||||
|
||||
const out = trim(reply, 3500);
|
||||
const sent = await ctx.reply(out, { reply_parameters: { message_id: ctx.message.message_id } });
|
||||
Q.insMsg.run(chatId, sent.message_id, null, "out", "text", out, ctx.message.message_id);
|
||||
});
|
||||
|
||||
bot.catch((err) => {
|
||||
const e = err.error;
|
||||
if (e instanceof GrammyError) console.error("grammy err:", e.description);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
image: minecraft-telegram-bot:latest
|
||||
container_name: minecraft-bot
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BOT_TOKEN_FILE: /run/secrets/bot_token
|
||||
MC_DIR: /mc
|
||||
DB_PATH: /data/users.db
|
||||
# Seeded only when DB is empty. Format: "id:Name,id:Name,..."
|
||||
ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}"
|
||||
# The docker daemon resolves relative bind-mounts in /mc/docker-compose.yml from THIS host path.
|
||||
COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}"
|
||||
TZ: Africa/Johannesburg
|
||||
volumes:
|
||||
# Talk to host docker daemon to manage minecraft + autossh containers.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# The minecraft compose project (compose file lives here).
|
||||
- ..:/mc
|
||||
# Persistent SQLite users DB.
|
||||
- ./data:/data
|
||||
secrets:
|
||||
- bot_token
|
||||
|
||||
secrets:
|
||||
bot_token:
|
||||
file: ../bot.token
|
||||
Reference in New Issue
Block a user