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
// Telegram tool catalog. Shared types + helpers live in ./types.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
import { z } from "zod" ;
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
import { tool , type Tool } from "./types.ts" ;
export { createTgClient , toolToGeminiFunction , type ToolCtx , type Tool , type TgClient } from "./types.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
// ---- Shared sub-schemas ----
const chatField = z . union ( [ z . number ( ) . int ( ) , z . string ( ) . min ( 1 ) ] )
. describe ( "Telegram chat id (integer) OR a public @username string." ) ;
const ParseMode = z . enum ( [ "HTML" , "MarkdownV2" , "Markdown" ] ) . optional ( ) ;
const ButtonSchema = z . object ( {
text : z.string ( ) . min ( 1 ) . max ( 64 ) ,
url : z.string ( ) . url ( ) . optional ( ) ,
callback_data : z.string ( ) . max ( 64 ) . optional ( ) ,
copy_text : z.string ( ) . max ( 256 ) . optional ( ) ,
switch_inline_query : z.string ( ) . optional ( ) ,
switch_inline_query_current_chat : z.string ( ) . optional ( ) ,
} ) ;
function buildKeyboard ( rows : z.infer < typeof ButtonSchema > [ ] [ ] | undefined ) {
if ( ! rows || rows . length === 0 ) return undefined ;
return {
inline_keyboard : rows.map ( ( row ) = >
row . map ( ( b ) = > {
const out : Record < string , unknown > = { text : b.text } ;
if ( b . url ) out . url = b . url ;
if ( b . callback_data ) out . callback_data = b . callback_data ;
if ( b . copy_text ) out . copy_text = { text : b.copy_text } ;
if ( b . switch_inline_query !== undefined ) out . switch_inline_query = b . switch_inline_query ;
if ( b . switch_inline_query_current_chat !== undefined ) out . switch_inline_query_current_chat = b . switch_inline_query_current_chat ;
return out ;
} ) ,
) ,
} ;
}
// ---- The tool catalog ----
export const telegramTools : Tool < z.ZodRawShape > [ ] = [
// ---- discovery ----
tool ( {
name : "tg_me" ,
title : "Bot identity" ,
description : "Return basic info about the bot — id, username, name, capability flags." ,
parameters : { } ,
handler : async ( _a , { tg } ) = > tg . call ( "getMe" ) ,
} ) ,
tool ( {
name : "tg_resolve_chat" ,
title : "Resolve a user to a DM chat id" ,
description : "Look up a user in users.db and return their telegram_id (== their DM chat_id). Provide exactly one of telegram_id, minecraft_name, telegram_username." ,
parameters : {
telegram_id : z.number ( ) . int ( ) . optional ( ) ,
minecraft_name : z.string ( ) . optional ( ) ,
telegram_username : z.string ( ) . optional ( ) ,
} ,
handler : async ( { telegram_id , minecraft_name , telegram_username } , { db } ) = > {
const n = [ telegram_id , minecraft_name , telegram_username ] . filter ( ( v ) = > v !== undefined ) . length ;
if ( n !== 1 ) throw new Error ( "Provide exactly one of telegram_id, minecraft_name, telegram_username." ) ;
const sql = "SELECT telegram_id, name, role, minecraft_name, username, status FROM users WHERE " +
( telegram_id !== undefined ? "telegram_id = ?"
: minecraft_name !== undefined ? "minecraft_name = ?"
: "username = ?" ) ;
const arg = telegram_id ? ? minecraft_name ? ? telegram_username ! . replace ( /^@/ , "" ) ;
const row = db . prepare ( sql ) . get ( arg ) as { telegram_id : number } | undefined ;
if ( ! row ) throw new Error ( "Not found in users table." ) ;
return { chat_id : row.telegram_id , user : row } ;
} ,
} ) ,
tool ( {
name : "tg_known_chats" ,
title : "Known chats" ,
description : "List chat_ids the bot has ever sent or received a message in — find group chat_ids here." ,
parameters : { } ,
handler : async ( _a , { db } ) = > ( {
chats : db.prepare (
"SELECT chat_id, COUNT(*) AS n, MAX(created_at) AS last_at FROM messages GROUP BY chat_id ORDER BY last_at DESC" ,
) . all ( ) ,
} ) ,
} ) ,
tool ( {
name : "tg_get_chat" ,
title : "Get chat info" ,
description : "Telegram getChat — full info about a chat (type, title, description, photo, member count for groups)." ,
parameters : { chat : chatField } ,
handler : async ( { chat } , { tg } ) = > tg . call ( "getChat" , { chat_id : chat } ) ,
} ) ,
tool ( {
name : "tg_get_chat_admins" ,
title : "List chat admins" ,
description : "Telegram getChatAdministrators — list admin members of a group/supergroup/channel." ,
parameters : { chat : chatField } ,
handler : async ( { chat } , { tg } ) = > tg . call ( "getChatAdministrators" , { chat_id : chat } ) ,
} ) ,
// ---- messaging ----
tool ( {
name : "tg_send_message" ,
title : "Send a text message" ,
description : "sendMessage. Supports HTML/Markdown, quote-replies, silent sends, link-preview suppression, and inline keyboards." ,
parameters : {
chat : chatField ,
text : z.string ( ) . min ( 1 ) . max ( 4096 ) ,
parse_mode : ParseMode ,
reply_to_message_id : z.number ( ) . int ( ) . optional ( ) ,
disable_notification : z.boolean ( ) . optional ( ) ,
disable_link_preview : z.boolean ( ) . optional ( ) ,
protect_content : z.boolean ( ) . optional ( ) ,
buttons : z.array ( z . array ( ButtonSchema ) ) . optional ( ) ,
message_thread_id : z.number ( ) . int ( ) . optional ( ) ,
} ,
handler : async ( a , { tg } ) = > {
const p : Record < string , unknown > = {
chat_id : a.chat , text : a.text , parse_mode : a.parse_mode ,
disable_notification : a.disable_notification , protect_content : a.protect_content ,
message_thread_id : a.message_thread_id ,
} ;
if ( a . reply_to_message_id !== undefined ) p . reply_parameters = { message_id : a.reply_to_message_id } ;
if ( a . disable_link_preview ) p . link_preview_options = { is_disabled : true } ;
const kb = buildKeyboard ( a . buttons ) ;
if ( kb ) p . reply_markup = kb ;
return tg . call ( "sendMessage" , p ) ;
} ,
} ) ,
tool ( {
name : "tg_edit_message" ,
title : "Edit a message" ,
description : "editMessageText — replace text + keyboard of a previously-sent message." ,
parameters : {
chat : chatField ,
message_id : z.number ( ) . int ( ) ,
text : z.string ( ) . min ( 1 ) . max ( 4096 ) ,
parse_mode : ParseMode ,
disable_link_preview : z.boolean ( ) . optional ( ) ,
buttons : z.array ( z . array ( ButtonSchema ) ) . optional ( ) ,
} ,
handler : async ( a , { tg } ) = > {
const p : Record < string , unknown > = { chat_id : a.chat , message_id : a.message_id , text : a.text , parse_mode : a.parse_mode } ;
if ( a . disable_link_preview ) p . link_preview_options = { is_disabled : true } ;
const kb = buildKeyboard ( a . buttons ) ;
if ( kb ) p . reply_markup = kb ;
return tg . call ( "editMessageText" , p ) ;
} ,
} ) ,
tool ( {
name : "tg_delete_message" ,
title : "Delete a message" ,
description : "deleteMessage. Bots can always delete their own messages; others require admin rights." ,
parameters : { chat : chatField , message_id : z.number ( ) . int ( ) } ,
handler : async ( { chat , message_id } , { tg } ) = > ( { deleted : await tg . call ( "deleteMessage" , { chat_id : chat , message_id } ) } ) ,
} ) ,
tool ( {
name : "tg_pin_message" ,
title : "Pin a message" ,
description : "pinChatMessage. Needs admin rights in groups." ,
parameters : {
chat : chatField ,
message_id : z.number ( ) . int ( ) ,
disable_notification : z.boolean ( ) . optional ( ) ,
} ,
handler : async ( { chat , message_id , disable_notification } , { tg } ) = >
( { pinned : await tg . call ( "pinChatMessage" , { chat_id : chat , message_id , disable_notification } ) } ) ,
} ) ,
tool ( {
name : "tg_unpin_message" ,
title : "Unpin a message" ,
description : "unpinChatMessage. Omit message_id to unpin the most recent pin." ,
parameters : { chat : chatField , message_id : z.number ( ) . int ( ) . optional ( ) } ,
handler : async ( { chat , message_id } , { tg } ) = >
( { unpinned : await tg . call ( "unpinChatMessage" , { chat_id : chat , message_id } ) } ) ,
} ) ,
tool ( {
name : "tg_forward_message" ,
title : "Forward a message" ,
description : "forwardMessage — relay a message between chats, preserving original author attribution." ,
parameters : {
from_chat : chatField ,
to_chat : chatField ,
message_id : z.number ( ) . int ( ) ,
disable_notification : z.boolean ( ) . optional ( ) ,
} ,
handler : async ( { from_chat , to_chat , message_id , disable_notification } , { tg } ) = >
tg . call ( "forwardMessage" , { from_chat_id : from_chat , chat_id : to_chat , message_id , disable_notification } ) ,
} ) ,
// ---- interactivity ----
tool ( {
name : "tg_chat_action" ,
title : "Show a busy indicator" ,
description : "sendChatAction — show 'typing…' or similar for ~5s. Re-call to extend." ,
parameters : {
chat : chatField ,
action : z.enum ( [
"typing" , "upload_photo" , "record_video" , "upload_video" , "record_voice" , "upload_voice" ,
"upload_document" , "choose_sticker" , "find_location" , "record_video_note" , "upload_video_note" ,
] ) ,
message_thread_id : z.number ( ) . int ( ) . optional ( ) ,
} ,
handler : async ( { chat , action , message_thread_id } , { tg } ) = >
( { shown : await tg . call ( "sendChatAction" , { chat_id : chat , action , message_thread_id } ) } ) ,
} ) ,
tool ( {
name : "tg_react" ,
title : "React to a message" ,
description : "setMessageReaction — attach emoji reactions or clear them by passing emojis=[]." ,
parameters : {
chat : chatField ,
message_id : z.number ( ) . int ( ) ,
emojis : z.array ( z . string ( ) . min ( 1 ) . max ( 8 ) ) . max ( 11 )
. describe ( "Emoji to apply. Pass [] to clear all reactions." ) ,
is_big : z.boolean ( ) . optional ( ) ,
} ,
handler : async ( { chat , message_id , emojis , is_big } , { tg } ) = > {
const reaction = emojis . map ( ( e ) = > ( { type : "emoji" as const , emoji : e } ) ) ;
return { reacted : await tg . call ( "setMessageReaction" , { chat_id : chat , message_id , reaction , is_big } ) } ;
} ,
} ) ,
tool ( {
name : "tg_send_poll" ,
title : "Send a poll" ,
description : "sendPoll — regular or quiz-style. For quiz, set type='quiz' and correct_option_id (0-based)." ,
parameters : {
chat : chatField ,
question : z.string ( ) . min ( 1 ) . max ( 300 ) ,
options : z.array ( z . string ( ) . min ( 1 ) . max ( 100 ) ) . min ( 2 ) . max ( 12 ) ,
type : z . enum ( [ "regular" , "quiz" ] ) . default ( "regular" ) ,
is_anonymous : z.boolean ( ) . default ( true ) ,
allows_multiple_answers : z.boolean ( ) . optional ( ) ,
correct_option_id : z.number ( ) . int ( ) . min ( 0 ) . optional ( ) ,
explanation : z.string ( ) . max ( 200 ) . optional ( ) ,
open_period : z.number ( ) . int ( ) . min ( 5 ) . max ( 600 ) . optional ( ) ,
} ,
handler : async ( a , { tg } ) = > tg . call ( "sendPoll" , {
chat_id : a.chat , question : a.question ,
options : a.options.map ( ( text ) = > ( { text } ) ) ,
type : a . type , is_anonymous : a.is_anonymous ,
allows_multiple_answers : a.allows_multiple_answers ,
correct_option_id : a.correct_option_id ,
explanation : a.explanation , open_period : a.open_period ,
} ) ,
} ) ,
tool ( {
name : "tg_send_dice" ,
title : "Send a dice / slot / dart" ,
description : "sendDice — animated random emoji. Telegram decides the outcome; check result.dice.value." ,
parameters : {
chat : chatField ,
emoji : z.enum ( [ "🎲" , "🎯" , "🏀" , "⚽" , "🎳" , "🎰" ] ) . default ( "🎲" ) ,
} ,
handler : async ( { chat , emoji } , { tg } ) = > tg . call ( "sendDice" , { chat_id : chat , emoji } ) ,
} ) ,
// ---- rich content ----
tool ( {
name : "tg_send_photo" ,
title : "Send a photo" ,
description : "sendPhoto — pass an HTTPS URL Telegram can fetch, or a previously-uploaded file_id." ,
parameters : {
chat : chatField ,
photo : z.string ( ) . min ( 1 ) . describe ( "HTTPS URL or Telegram file_id." ) ,
caption : z.string ( ) . max ( 1024 ) . optional ( ) ,
parse_mode : ParseMode ,
has_spoiler : z.boolean ( ) . optional ( ) ,
disable_notification : z.boolean ( ) . optional ( ) ,
reply_to_message_id : z.number ( ) . int ( ) . optional ( ) ,
buttons : z.array ( z . array ( ButtonSchema ) ) . optional ( ) ,
} ,
handler : async ( a , { tg } ) = > {
const p : Record < string , unknown > = {
chat_id : a.chat , photo : a.photo , caption : a.caption ,
parse_mode : a.parse_mode , has_spoiler : a.has_spoiler , disable_notification : a.disable_notification ,
} ;
if ( a . reply_to_message_id !== undefined ) p . reply_parameters = { message_id : a.reply_to_message_id } ;
const kb = buildKeyboard ( a . buttons ) ;
if ( kb ) p . reply_markup = kb ;
return tg . call ( "sendPhoto" , p ) ;
} ,
} ) ,
tool ( {
name : "tg_send_sticker" ,
title : "Send a sticker" ,
description : "sendSticker — pass a sticker file_id (see tg_stickers_known)." ,
parameters : {
chat : chatField ,
file_id : z.string ( ) . min ( 1 ) ,
reply_to_message_id : z.number ( ) . int ( ) . optional ( ) ,
disable_notification : z.boolean ( ) . optional ( ) ,
} ,
handler : async ( { chat , file_id , reply_to_message_id , disable_notification } , { tg } ) = > {
const p : Record < string , unknown > = { chat_id : chat , sticker : file_id , disable_notification } ;
if ( reply_to_message_id !== undefined ) p . reply_parameters = { message_id : reply_to_message_id } ;
return tg . call ( "sendSticker" , p ) ;
} ,
} ) ,
tool ( {
name : "tg_stickers_known" ,
title : "List bot-bound stickers" ,
description : "Return event→file_id mappings the bot stored via /setsticker." ,
parameters : { } ,
handler : async ( _a , { db } ) = > {
const rows = db . prepare ( "SELECT event, file_id FROM stickers ORDER BY event" ) . all ( ) ;
return { count : rows.length , stickers : rows } ;
} ,
} ) ,
// ---- forum topics ----
tool ( {
name : "tg_create_topic" ,
title : "Create a forum topic" ,
description : "createForumTopic — only in forum-mode supergroups. Returns message_thread_id." ,
parameters : {
chat : chatField ,
name : z.string ( ) . min ( 1 ) . max ( 128 ) ,
icon_color : z.number ( ) . int ( ) . optional ( ) ,
} ,
handler : async ( { chat , name , icon_color } , { tg } ) = >
tg . call ( "createForumTopic" , { chat_id : chat , name , icon_color } ) ,
} ) ,
tool ( {
name : "tg_close_topic" ,
title : "Close a forum topic" ,
description : "closeForumTopic." ,
parameters : { chat : chatField , message_thread_id : z.number ( ) . int ( ) } ,
handler : async ( { chat , message_thread_id } , { tg } ) = >
( { closed : await tg . call ( "closeForumTopic" , { chat_id : chat , message_thread_id } ) } ) ,
} ) ,
tool ( {
name : "tg_reopen_topic" ,
title : "Reopen a forum topic" ,
description : "reopenForumTopic." ,
parameters : { chat : chatField , message_thread_id : z.number ( ) . int ( ) } ,
handler : async ( { chat , message_thread_id } , { tg } ) = >
( { reopened : await tg . call ( "reopenForumTopic" , { chat_id : chat , message_thread_id } ) } ) ,
} ) ,
// ---- bot config ----
tool ( {
name : "tg_set_chat_title" ,
title : "Set a chat title" ,
description : "setChatTitle — requires bot admin rights." ,
parameters : { chat : chatField , title : z.string ( ) . min ( 1 ) . max ( 128 ) } ,
handler : async ( { chat , title } , { tg } ) = >
( { updated : await tg . call ( "setChatTitle" , { chat_id : chat , title } ) } ) ,
} ) ,
tool ( {
name : "tg_set_my_commands" ,
title : "Set the bot's slash-command menu" ,
description : "setMyCommands. Scope=default for the global menu; pass `chat` for a single chat." ,
parameters : {
commands : z.array ( z . object ( {
command : z.string ( ) . min ( 1 ) . max ( 32 ) ,
description : z.string ( ) . min ( 1 ) . max ( 256 ) ,
} ) ) . min ( 0 ) . max ( 100 ) ,
scope : z.enum ( [
"default" , "all_private_chats" , "all_group_chats" , "all_chat_administrators" ,
] ) . optional ( ) ,
chat : chatField.optional ( ) ,
language_code : z.string ( ) . optional ( ) ,
} ,
handler : async ( { commands , scope , chat , language_code } , { tg } ) = > {
const p : Record < string , unknown > = { commands , language_code } ;
if ( chat !== undefined ) p . scope = { type : scope === "all_chat_administrators" ? "chat_administrators" : "chat" , chat_id : chat } ;
else if ( scope ) p . scope = { type : scope } ;
return { updated : await tg . call ( "setMyCommands" , p ) } ;
} ,
} ) ,
] ;