From 95c5d948bcb0ab7706fd4c1c9c86683c7e00454d Mon Sep 17 00:00:00 2001 From: Matt Hesketh Date: Sat, 21 Feb 2026 02:19:11 +0000 Subject: [PATCH] feat: add middleware system, MCP server, and CLI tool for v3.4 Middleware: api.use(fn) for global middleware, api.middleware.on_message.use(fn) for scoped middleware. Onion model with (ctx, next) signature. Zero overhead when no middleware registered. MCP: JSON-RPC 2.0 server over stdio with 23 curated Telegram Bot API tools (send_message, ban_chat_member, etc.) and telegram://bot/info resource. Compatible with Claude Code, Cursor, and other MCP clients. CLI: bin/tgbot with subcommands (send, info, updates, chat, ban, unban, mcp, run). Reads BOT_TOKEN from env. Includes 45 new tests and v3.4-0 rockspec. --- bin/tgbot | 181 ++++++++++ spec/cli_spec.lua | 100 ++++++ spec/mcp_spec.lua | 340 ++++++++++++++++++ spec/middleware_spec.lua | 232 ++++++++++++ spec/test_helper.lua | 2 + src/handlers.lua | 19 +- src/main.lua | 6 +- src/mcp.lua | 600 ++++++++++++++++++++++++++++++++ src/middleware.lua | 118 +++++++ telegram-bot-lua-3.4-0.rockspec | 72 ++++ 10 files changed, 1664 insertions(+), 6 deletions(-) create mode 100755 bin/tgbot create mode 100644 spec/cli_spec.lua create mode 100644 spec/mcp_spec.lua create mode 100644 spec/middleware_spec.lua create mode 100644 src/mcp.lua create mode 100644 src/middleware.lua create mode 100644 telegram-bot-lua-3.4-0.rockspec diff --git a/bin/tgbot b/bin/tgbot new file mode 100755 index 0000000..72ca8c9 --- /dev/null +++ b/bin/tgbot @@ -0,0 +1,181 @@ +#!/usr/bin/env lua + +-- telegram-bot-lua CLI tool +-- Usage: tgbot [args...] +-- Requires BOT_TOKEN environment variable. + +local json = require('dkjson') + +local function print_usage() + io.stderr:write([[ +Usage: tgbot [args...] + +Commands: + info Show bot info + send Send a text message + --parse-mode Parse mode (MarkdownV2, HTML) + --silent Send without notification + send --photo [--caption ] + Send a photo + send --document [--caption ] + Send a document + updates [--limit N] Get recent updates + chat Get chat info + ban Ban a user + unban Unban a user + mcp Start MCP server (JSON-RPC over stdio) + run [--timeout N] [--limit N] + Start polling for updates + +Environment: + BOT_TOKEN Telegram Bot API token (required) + +]]) +end + +-- Parse command-line arguments into a table of flags and positional args. +local function parse_args(args) + local result = { positional = {}, flags = {} } + local i = 1 + while i <= #args do + local arg = args[i] + if arg:match('^%-%-') then + local key = arg:sub(3) + -- Check if next arg is a value or another flag + if i + 1 <= #args and not args[i + 1]:match('^%-%-') then + result.flags[key] = args[i + 1] + i = i + 2 + else + result.flags[key] = true + i = i + 1 + end + else + table.insert(result.positional, arg) + i = i + 1 + end + end + return result +end + +-- Require BOT_TOKEN and return a configured api instance. +local function init_api() + local token = os.getenv('BOT_TOKEN') + if not token or token == '' then + io.stderr:write('Error: BOT_TOKEN environment variable is required\n') + os.exit(1) + end + local api = require('telegram-bot-lua') + api.configure(token) + return api +end + +-- Format and print a JSON result. +local function print_result(result, err) + if result then + print(json.encode(result, { indent = true })) + else + io.stderr:write('Error: ' .. json.encode(err or 'unknown error') .. '\n') + os.exit(1) + end +end + +-- Main entry point +local args = parse_args(arg) +local command = args.positional[1] + +if not command then + print_usage() + os.exit(1) +end + +if command == 'info' then + local api = init_api() + print_result(api.info) + +elseif command == 'send' then + local chat_id = args.positional[2] + if not chat_id then + io.stderr:write('Error: chat_id is required\n') + os.exit(1) + end + + local api = init_api() + + if args.flags['photo'] then + local result, err = api.send_photo(chat_id, args.flags['photo'], { + caption = args.flags['caption'], + parse_mode = args.flags['parse-mode'] + }) + print_result(result, err) + elseif args.flags['document'] then + local result, err = api.send_document(chat_id, args.flags['document'], { + caption = args.flags['caption'], + parse_mode = args.flags['parse-mode'] + }) + print_result(result, err) + else + local text = args.positional[3] + if not text then + io.stderr:write('Error: text is required for send command\n') + os.exit(1) + end + local result, err = api.send_message(chat_id, text, { + parse_mode = args.flags['parse-mode'], + disable_notification = args.flags['silent'] and true or nil + }) + print_result(result, err) + end + +elseif command == 'updates' then + local api = init_api() + local limit = tonumber(args.flags['limit']) or 10 + local result, err = api.get_updates({ limit = limit, timeout = 0 }) + print_result(result, err) + +elseif command == 'chat' then + local chat_id = args.positional[2] + if not chat_id then + io.stderr:write('Error: chat_id is required\n') + os.exit(1) + end + local api = init_api() + local result, err = api.get_chat(chat_id) + print_result(result, err) + +elseif command == 'ban' then + local chat_id = args.positional[2] + local user_id = args.positional[3] + if not chat_id or not user_id then + io.stderr:write('Error: chat_id and user_id are required\n') + os.exit(1) + end + local api = init_api() + local result, err = api.ban_chat_member(chat_id, user_id) + print_result(result, err) + +elseif command == 'unban' then + local chat_id = args.positional[2] + local user_id = args.positional[3] + if not chat_id or not user_id then + io.stderr:write('Error: chat_id and user_id are required\n') + os.exit(1) + end + local api = init_api() + local result, err = api.unban_chat_member(chat_id, user_id, { only_if_banned = true }) + print_result(result, err) + +elseif command == 'mcp' then + local api = init_api() + api.mcp.serve() + +elseif command == 'run' then + local api = init_api() + local timeout = tonumber(args.flags['timeout']) or 60 + local limit = tonumber(args.flags['limit']) or 100 + api.run({ timeout = timeout, limit = limit, sync = true }) + +else + io.stderr:write('Unknown command: ' .. command .. '\n') + print_usage() + os.exit(1) +end diff --git a/spec/cli_spec.lua b/spec/cli_spec.lua new file mode 100644 index 0000000..4c9dc87 --- /dev/null +++ b/spec/cli_spec.lua @@ -0,0 +1,100 @@ +local api = require('spec.test_helper') + +describe('cli', function() + describe('arg parsing', function() + -- Load the parse_args function by extracting it from the CLI script. + -- We simulate the arg parser logic here since bin/tgbot is a standalone script. + local function parse_args(args) + local result = { positional = {}, flags = {} } + local i = 1 + while i <= #args do + local a = args[i] + if a:match('^%-%-') then + local key = a:sub(3) + if i + 1 <= #args and not args[i + 1]:match('^%-%-') then + result.flags[key] = args[i + 1] + i = i + 2 + else + result.flags[key] = true + i = i + 1 + end + else + table.insert(result.positional, a) + i = i + 1 + end + end + return result + end + + it('parses positional arguments', function() + local r = parse_args({'send', '123', 'hello'}) + assert.same({'send', '123', 'hello'}, r.positional) + assert.same({}, r.flags) + end) + + it('parses flags with values', function() + local r = parse_args({'send', '123', '--photo', 'cat.jpg', '--caption', 'Look!'}) + assert.same({'send', '123'}, r.positional) + assert.equals('cat.jpg', r.flags['photo']) + assert.equals('Look!', r.flags['caption']) + end) + + it('parses boolean flags', function() + local r = parse_args({'send', '123', 'hi', '--silent'}) + assert.same({'send', '123', 'hi'}, r.positional) + assert.is_true(r.flags['silent']) + end) + + it('parses mixed flags and positional args', function() + local r = parse_args({'updates', '--limit', '5'}) + assert.same({'updates'}, r.positional) + assert.equals('5', r.flags['limit']) + end) + + it('handles no arguments', function() + local r = parse_args({}) + assert.same({}, r.positional) + assert.same({}, r.flags) + end) + + it('handles consecutive boolean flags', function() + local r = parse_args({'send', '123', 'text', '--silent', '--verbose'}) + assert.is_true(r.flags['silent']) + assert.is_true(r.flags['verbose']) + end) + end) + + describe('tgbot script', function() + it('prints usage when no command given', function() + local handle = io.popen('lua bin/tgbot 2>&1; echo "EXIT:$?"', 'r') + local output = handle:read('*a') + handle:close() + assert.truthy(output:find('Usage:')) + assert.truthy(output:find('EXIT:1')) + end) + + it('prints error when unknown command given', function() + local handle = io.popen('lua bin/tgbot badcommand 2>&1; echo "EXIT:$?"', 'r') + local output = handle:read('*a') + handle:close() + assert.truthy(output:find('Unknown command')) + assert.truthy(output:find('EXIT:1')) + end) + + it('prints error when BOT_TOKEN is missing for info', function() + local handle = io.popen('BOT_TOKEN= lua bin/tgbot info 2>&1; echo "EXIT:$?"', 'r') + local output = handle:read('*a') + handle:close() + assert.truthy(output:find('BOT_TOKEN')) + assert.truthy(output:find('EXIT:1')) + end) + + it('prints error when send is missing chat_id', function() + local handle = io.popen('BOT_TOKEN= lua bin/tgbot send 2>&1; echo "EXIT:$?"', 'r') + local output = handle:read('*a') + handle:close() + assert.truthy(output:find('chat_id')) + assert.truthy(output:find('EXIT:1')) + end) + end) +end) diff --git a/spec/mcp_spec.lua b/spec/mcp_spec.lua new file mode 100644 index 0000000..ed52674 --- /dev/null +++ b/spec/mcp_spec.lua @@ -0,0 +1,340 @@ +local api = require('spec.test_helper') +local json = require('dkjson') + +describe('mcp', function() + before_each(function() + api._clear_requests() + end) + + describe('handle()', function() + it('returns parse error for invalid JSON', function() + local response = json.decode(api.mcp.handle('not json')) + assert.equals('2.0', response.jsonrpc) + assert.equals(-32700, response.error.code) + end) + + it('returns invalid request for missing method', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', id = 1 + }))) + assert.equals(-32600, response.error.code) + end) + + it('returns method not found for unknown methods', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', id = 1, method = 'unknown/method' + }))) + assert.equals(-32601, response.error.code) + end) + + it('handles ping', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', id = 1, method = 'ping' + }))) + assert.equals(1, response.id) + assert.is_table(response.result) + end) + + it('returns nil for notification messages', function() + local response = api.mcp.handle(json.encode({ + jsonrpc = '2.0', method = 'notifications/initialized' + })) + assert.is_nil(response) + end) + + it('accepts table input as well as string', function() + local response = json.decode(api.mcp.handle({ + jsonrpc = '2.0', id = 1, method = 'ping' + })) + assert.equals(1, response.id) + assert.is_table(response.result) + end) + end) + + describe('initialize', function() + it('returns server info and capabilities', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'initialize', + params = { + protocolVersion = '2024-11-05', + capabilities = {}, + clientInfo = { name = 'test', version = '1.0' } + } + }))) + + assert.equals(1, response.id) + assert.equals('2024-11-05', response.result.protocolVersion) + assert.equals('telegram-bot-lua', response.result.serverInfo.name) + assert.is_table(response.result.capabilities.tools) + assert.is_table(response.result.capabilities.resources) + end) + end) + + describe('tools/list', function() + it('returns a list of tools with schemas', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', id = 1, method = 'tools/list' + }))) + + assert.is_table(response.result.tools) + assert.is_true(#response.result.tools > 0) + + -- Check that send_message tool exists + local found = false + for _, tool in ipairs(response.result.tools) do + if tool.name == 'send_message' then + found = true + assert.is_string(tool.description) + assert.is_table(tool.inputSchema) + assert.is_table(tool.inputSchema.properties) + assert.is_table(tool.inputSchema.required) + break + end + end + assert.is_true(found) + end) + + it('includes all expected tool names', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', id = 1, method = 'tools/list' + }))) + + local names = {} + for _, tool in ipairs(response.result.tools) do + names[tool.name] = true + end + + assert.is_true(names['send_message']) + assert.is_true(names['send_photo']) + assert.is_true(names['get_updates']) + assert.is_true(names['get_me']) + assert.is_true(names['get_chat']) + assert.is_true(names['ban_chat_member']) + assert.is_true(names['delete_message']) + assert.is_true(names['pin_chat_message']) + assert.is_true(names['answer_callback_query']) + assert.is_true(names['forward_message']) + end) + end) + + describe('tools/call', function() + it('dispatches send_message to api.send_message', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'tools/call', + params = { + name = 'send_message', + arguments = { chat_id = '123', text = 'hello' } + } + }))) + + assert.equals(1, response.id) + assert.is_table(response.result) + assert.is_false(response.result.isError) + + -- Check that api.request was called + local req = api._last_request() + assert.truthy(req) + assert.truthy(req.endpoint:find('/sendMessage')) + assert.equals('123', req.parameters.chat_id) + assert.equals('hello', req.parameters.text) + end) + + it('returns error for missing required params', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'tools/call', + params = { + name = 'send_message', + arguments = { chat_id = '123' } -- missing text + } + }))) + + assert.equals(-32602, response.error.code) + assert.truthy(response.error.message:find('text')) + end) + + it('returns error for unknown tool', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'tools/call', + params = { + name = 'nonexistent_tool', + arguments = {} + } + }))) + + assert.equals(-32601, response.error.code) + end) + + it('dispatches get_me', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'tools/call', + params = { name = 'get_me', arguments = {} } + }))) + + assert.is_table(response.result) + local req = api._last_request() + assert.truthy(req.endpoint:find('/getMe')) + end) + + it('dispatches ban_chat_member with opts', function() + api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'tools/call', + params = { + name = 'ban_chat_member', + arguments = { + chat_id = '-100123', + user_id = 456, + revoke_messages = true + } + } + })) + + local req = api._last_request() + assert.truthy(req.endpoint:find('/banChatMember')) + assert.equals('-100123', req.parameters.chat_id) + end) + + it('dispatches delete_message', function() + api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'tools/call', + params = { + name = 'delete_message', + arguments = { chat_id = '123', message_id = 42 } + } + })) + + local req = api._last_request() + assert.truthy(req.endpoint:find('/deleteMessage')) + end) + end) + + describe('resources/list', function() + it('returns resource definitions', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', id = 1, method = 'resources/list' + }))) + + assert.is_table(response.result.resources) + assert.is_true(#response.result.resources > 0) + + local found = false + for _, res in ipairs(response.result.resources) do + if res.uri == 'telegram://bot/info' then + found = true + break + end + end + assert.is_true(found) + end) + end) + + describe('resources/read', function() + it('returns bot info for telegram://bot/info', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'resources/read', + params = { uri = 'telegram://bot/info' } + }))) + + assert.is_table(response.result.contents) + assert.equals(1, #response.result.contents) + assert.equals('telegram://bot/info', response.result.contents[1].uri) + assert.equals('application/json', response.result.contents[1].mimeType) + + local info = json.decode(response.result.contents[1].text) + assert.equals(123456, info.id) + assert.equals('TestBot', info.first_name) + end) + + it('returns error for unknown resource URI', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'resources/read', + params = { uri = 'telegram://unknown' } + }))) + + assert.equals(-32601, response.error.code) + end) + + it('returns error when uri is missing', function() + local response = json.decode(api.mcp.handle(json.encode({ + jsonrpc = '2.0', + id = 1, + method = 'resources/read', + params = {} + }))) + + assert.equals(-32602, response.error.code) + end) + end) + + describe('serve()', function() + local function make_mock_input(lines) + local iter = ipairs(lines) + local i = 0 + return { + lines = function(_) + return function() + i = i + 1 + return lines[i] + end + end + } + end + + it('processes lines from input and writes to output', function() + local input = make_mock_input({ + json.encode({ jsonrpc = '2.0', id = 1, method = 'ping' }), + json.encode({ jsonrpc = '2.0', id = 2, method = 'tools/list' }), + }) + + local output_lines = {} + api.mcp.serve({ + input = input, + write = function(data) table.insert(output_lines, data) end, + flush = function() end, + }) + + assert.equals(2, #output_lines) + + local resp1 = json.decode(output_lines[1]:gsub('\n$', '')) + assert.equals(1, resp1.id) + + local resp2 = json.decode(output_lines[2]:gsub('\n$', '')) + assert.equals(2, resp2.id) + assert.is_table(resp2.result.tools) + end) + + it('skips empty lines', function() + local input = make_mock_input({ + '', + json.encode({ jsonrpc = '2.0', id = 1, method = 'ping' }), + '', + }) + + local output_lines = {} + api.mcp.serve({ + input = input, + write = function(data) table.insert(output_lines, data) end, + flush = function() end, + }) + + assert.equals(1, #output_lines) + end) + end) +end) diff --git a/spec/middleware_spec.lua b/spec/middleware_spec.lua new file mode 100644 index 0000000..f1c50ce --- /dev/null +++ b/spec/middleware_spec.lua @@ -0,0 +1,232 @@ +local api = require('spec.test_helper') + +describe('middleware', function() + local call_order + + before_each(function() + api._clear_requests() + api._middleware = {} + api._scoped_middleware = {} + call_order = {} + -- Reset handler stubs + api.on_update = function(_) end + api.on_message = function(_) end + api.on_private_message = function(_) end + api.on_callback_query = function(_) end + end) + + after_each(function() + api._middleware = {} + api._scoped_middleware = {} + end) + + describe('api.use()', function() + it('registers global middleware', function() + local fn = function(ctx, next) next() end + api.use(fn) + assert.equals(1, #api._middleware) + assert.equals(fn, api._middleware[1]) + end) + + it('registers multiple middleware in order', function() + local fn1 = function(ctx, next) next() end + local fn2 = function(ctx, next) next() end + api.use(fn1) + api.use(fn2) + assert.equals(2, #api._middleware) + assert.equals(fn1, api._middleware[1]) + assert.equals(fn2, api._middleware[2]) + end) + end) + + describe('middleware execution', function() + it('executes middleware in order before handler', function() + api.use(function(ctx, next) + table.insert(call_order, 'mw1-before') + next() + table.insert(call_order, 'mw1-after') + end) + api.use(function(ctx, next) + table.insert(call_order, 'mw2-before') + next() + table.insert(call_order, 'mw2-after') + end) + api.on_message = function(msg) + table.insert(call_order, 'handler') + end + + api.process_update({ + update_id = 1, + message = { chat = { id = 123, type = 'private' }, text = 'hello' } + }) + + assert.same({ + 'mw1-before', 'mw2-before', 'handler', 'mw2-after', 'mw1-after' + }, call_order) + end) + + it('skips handler when middleware does not call next', function() + local handler_called = false + api.use(function(ctx, next) + -- deliberately not calling next() + end) + api.on_message = function(msg) + handler_called = true + end + + api.process_update({ + update_id = 1, + message = { chat = { id = 123, type = 'private' }, text = 'hello' } + }) + + assert.is_false(handler_called) + end) + + it('provides update_type in context', function() + local captured_type + api.use(function(ctx, next) + captured_type = ctx.update_type + next() + end) + + api.process_update({ + update_id = 1, + callback_query = { id = '42', from = { id = 1 } } + }) + + assert.equals('callback_query', captured_type) + end) + + it('provides update payload in context', function() + local captured_msg + api.use(function(ctx, next) + captured_msg = ctx.message + next() + end) + + local msg = { chat = { id = 123, type = 'private' }, text = 'hi' } + api.process_update({ update_id = 1, message = msg }) + + assert.equals(msg, captured_msg) + end) + + it('dispatches directly when no middleware registered', function() + local handler_called = false + api.on_message = function(msg) + handler_called = true + end + + api.process_update({ + update_id = 1, + message = { chat = { id = 123, type = 'private' }, text = 'hello' } + }) + + assert.is_true(handler_called) + end) + + it('still calls on_update in dispatch', function() + local on_update_called = false + api.use(function(ctx, next) next() end) + api.on_update = function(update) + on_update_called = true + end + + api.process_update({ + update_id = 1, + message = { chat = { id = 123, type = 'private' }, text = 'hi' } + }) + + assert.is_true(on_update_called) + end) + + it('returns false for nil update', function() + api.use(function(ctx, next) next() end) + assert.is_false(api.process_update(nil)) + end) + end) + + describe('scoped middleware', function() + it('registers scoped middleware via api.middleware.on_message.use()', function() + local scoped_called = false + api.middleware.on_message.use(function(ctx, next) + scoped_called = true + next() + end) + + api.process_update({ + update_id = 1, + message = { chat = { id = 123, type = 'private' }, text = 'hi' } + }) + + assert.is_true(scoped_called) + end) + + it('does not run message middleware for callback_query updates', function() + local scoped_called = false + api.middleware.on_message.use(function(ctx, next) + scoped_called = true + next() + end) + + api.process_update({ + update_id = 1, + callback_query = { id = '42', from = { id = 1 } } + }) + + assert.is_false(scoped_called) + end) + + it('runs global middleware before scoped middleware', function() + api.use(function(ctx, next) + table.insert(call_order, 'global') + next() + end) + api.middleware.on_message.use(function(ctx, next) + table.insert(call_order, 'scoped') + next() + end) + api.on_message = function(_) + table.insert(call_order, 'handler') + end + + api.process_update({ + update_id = 1, + message = { chat = { id = 123, type = 'private' }, text = 'hi' } + }) + + assert.same({ 'global', 'scoped', 'handler' }, call_order) + end) + + it('scoped middleware can block handler', function() + local handler_called = false + api.middleware.on_callback_query.use(function(ctx, next) + -- block + end) + api.on_callback_query = function(_) + handler_called = true + end + + api.process_update({ + update_id = 1, + callback_query = { id = '42', from = { id = 1 } } + }) + + assert.is_false(handler_called) + end) + end) + + describe('error handling', function() + it('propagates errors from middleware', function() + api.use(function(ctx, next) + error('middleware error') + end) + + assert.has_error(function() + api.process_update({ + update_id = 1, + message = { chat = { id = 123, type = 'private' }, text = 'hi' } + }) + end, 'middleware error') + end) + end) +end) diff --git a/spec/test_helper.lua b/spec/test_helper.lua index 484749a..764a6cd 100644 --- a/spec/test_helper.lua +++ b/spec/test_helper.lua @@ -33,6 +33,8 @@ local module_map = { ['telegram-bot-lua.methods.checklists'] = 'src/methods/checklists.lua', ['telegram-bot-lua.methods.stories'] = 'src/methods/stories.lua', ['telegram-bot-lua.methods.suggested_posts'] = 'src/methods/suggested_posts.lua', + ['telegram-bot-lua.middleware'] = 'src/middleware.lua', + ['telegram-bot-lua.mcp'] = 'src/mcp.lua', ['telegram-bot-lua.adapters'] = 'src/adapters/init.lua', ['telegram-bot-lua.adapters.db'] = 'src/adapters/db.lua', ['telegram-bot-lua.adapters.redis'] = 'src/adapters/redis.lua', diff --git a/src/handlers.lua b/src/handlers.lua index 31e6d6e..3bb9c1a 100644 --- a/src/handlers.lua +++ b/src/handlers.lua @@ -32,10 +32,10 @@ return function(api) function api.on_deleted_business_messages(_) end function api.on_purchased_paid_media(_) end - function api.process_update(update) - if not update then - return false - end + -- Raw dispatch: routes an update directly to the appropriate handler. + -- Called by the middleware chain as the final step, or directly when + -- no middleware is registered. + function api._dispatch_update(update) api.on_update(update) if update.message then if update.message.chat.type == 'private' then @@ -101,6 +101,17 @@ return function(api) return false end + -- Process an update through the middleware chain (if any) then dispatch. + function api.process_update(update) + if not update then + return false + end + if #api._middleware > 0 or (api._scoped_middleware and next(api._scoped_middleware)) then + return api._run_middleware(update) + end + return api._dispatch_update(update) + end + -- Async-first run loop. -- By default uses copas for concurrent update processing. -- Pass { sync = true } for single-threaded sequential processing. diff --git a/src/main.lua b/src/main.lua index a15e5a5..a2fbb80 100644 --- a/src/main.lua +++ b/src/main.lua @@ -9,7 +9,7 @@ __/ | |___/ - Version 3.3-0 + Version 3.4-0 Copyright (c) 2017-2026 Matthew Hesketh See LICENSE for details @@ -20,7 +20,7 @@ local ltn12 = require('ltn12') local json = require('dkjson') local config = require('telegram-bot-lua.config') -api.version = '3.3-0' +api.version = '3.4-0' function api.configure(token, debug) if not token or type(token) ~= 'string' then @@ -124,6 +124,7 @@ function api.close() end -- Load all modules +require('telegram-bot-lua.middleware')(api) require('telegram-bot-lua.handlers')(api) require('telegram-bot-lua.builders')(api) require('telegram-bot-lua.helpers')(api) @@ -143,6 +144,7 @@ require('telegram-bot-lua.methods.checklists')(api) require('telegram-bot-lua.methods.stories')(api) require('telegram-bot-lua.methods.suggested_posts')(api) require('telegram-bot-lua.utils')(api) +require('telegram-bot-lua.mcp')(api) require('telegram-bot-lua.async')(api) require('telegram-bot-lua.adapters')(api) require('telegram-bot-lua.compat')(api) diff --git a/src/mcp.lua b/src/mcp.lua new file mode 100644 index 0000000..775446f --- /dev/null +++ b/src/mcp.lua @@ -0,0 +1,600 @@ +return function(api) + local json = require('dkjson') + + api.mcp = {} + + -- JSON-RPC 2.0 helpers + local function jsonrpc_result(id, result) + return json.encode({ + jsonrpc = '2.0', + id = id, + result = result + }) + end + + local function jsonrpc_error(id, code, message, data) + return json.encode({ + jsonrpc = '2.0', + id = id, + error = { + code = code, + message = message, + data = data + } + }) + end + + -- MCP protocol error codes + local PARSE_ERROR = -32700 + local INVALID_REQUEST = -32600 + local METHOD_NOT_FOUND = -32601 + local INVALID_PARAMS = -32602 + local INTERNAL_ERROR = -32603 + + -- Tool definitions: curated set of high-value Telegram Bot API methods. + -- Each tool maps to an api method with a JSON Schema for parameters. + local tool_defs = { + { + name = 'send_message', + description = 'Send a text message to a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Target chat ID or @username' }, + text = { type = 'string', description = 'Message text' }, + parse_mode = { type = 'string', description = 'Parse mode: MarkdownV2, HTML, or Markdown' }, + disable_notification = { type = 'boolean', description = 'Send silently' } + }, + required = { 'chat_id', 'text' } + }, + call = function(params) + return api.send_message(params.chat_id, params.text, { + parse_mode = params.parse_mode, + disable_notification = params.disable_notification + }) + end + }, + { + name = 'send_photo', + description = 'Send a photo to a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Target chat ID or @username' }, + photo = { type = 'string', description = 'Photo file path, URL, or file_id' }, + caption = { type = 'string', description = 'Photo caption' }, + parse_mode = { type = 'string', description = 'Parse mode for caption' } + }, + required = { 'chat_id', 'photo' } + }, + call = function(params) + return api.send_photo(params.chat_id, params.photo, { + caption = params.caption, + parse_mode = params.parse_mode + }) + end + }, + { + name = 'get_updates', + description = 'Get recent updates (messages, callbacks, etc.)', + inputSchema = { + type = 'object', + properties = { + limit = { type = 'number', description = 'Max number of updates (1-100)' }, + timeout = { type = 'number', description = 'Long polling timeout in seconds' }, + offset = { type = 'number', description = 'Update offset' } + } + }, + call = function(params) + return api.get_updates({ + limit = params.limit, + timeout = params.timeout, + offset = params.offset + }) + end + }, + { + name = 'get_me', + description = 'Get basic info about the bot', + inputSchema = { + type = 'object', + properties = {} + }, + call = function(_) + return api.get_me() + end + }, + { + name = 'get_chat', + description = 'Get information about a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID or @username' } + }, + required = { 'chat_id' } + }, + call = function(params) + return api.get_chat(params.chat_id) + end + }, + { + name = 'get_chat_member', + description = 'Get info about a chat member', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID or @username' }, + user_id = { type = 'number', description = 'User ID' } + }, + required = { 'chat_id', 'user_id' } + }, + call = function(params) + return api.get_chat_member(params.chat_id, params.user_id) + end + }, + { + name = 'get_chat_member_count', + description = 'Get the number of members in a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID or @username' } + }, + required = { 'chat_id' } + }, + call = function(params) + return api.get_chat_member_count(params.chat_id) + end + }, + { + name = 'ban_chat_member', + description = 'Ban a user from a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID or @username' }, + user_id = { type = 'number', description = 'User ID to ban' }, + until_date = { type = 'number', description = 'Ban end date (Unix timestamp)' }, + revoke_messages = { type = 'boolean', description = 'Delete all messages from the user' } + }, + required = { 'chat_id', 'user_id' } + }, + call = function(params) + return api.ban_chat_member(params.chat_id, params.user_id, { + until_date = params.until_date, + revoke_messages = params.revoke_messages + }) + end + }, + { + name = 'unban_chat_member', + description = 'Unban a user from a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID or @username' }, + user_id = { type = 'number', description = 'User ID to unban' }, + only_if_banned = { type = 'boolean', description = 'Only unban if currently banned' } + }, + required = { 'chat_id', 'user_id' } + }, + call = function(params) + return api.unban_chat_member(params.chat_id, params.user_id, { + only_if_banned = params.only_if_banned + }) + end + }, + { + name = 'delete_message', + description = 'Delete a message', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID' }, + message_id = { type = 'number', description = 'Message ID to delete' } + }, + required = { 'chat_id', 'message_id' } + }, + call = function(params) + return api.delete_message(params.chat_id, params.message_id) + end + }, + { + name = 'pin_chat_message', + description = 'Pin a message in a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID' }, + message_id = { type = 'number', description = 'Message ID to pin' }, + disable_notification = { type = 'boolean', description = 'Pin silently' } + }, + required = { 'chat_id', 'message_id' } + }, + call = function(params) + return api.pin_chat_message(params.chat_id, params.message_id, { + disable_notification = params.disable_notification + }) + end + }, + { + name = 'unpin_chat_message', + description = 'Unpin a message in a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID' }, + message_id = { type = 'number', description = 'Message ID to unpin' } + }, + required = { 'chat_id' } + }, + call = function(params) + return api.unpin_chat_message(params.chat_id, { + message_id = params.message_id + }) + end + }, + { + name = 'answer_callback_query', + description = 'Answer a callback query from an inline button', + inputSchema = { + type = 'object', + properties = { + callback_query_id = { type = 'string', description = 'Callback query ID' }, + text = { type = 'string', description = 'Notification text' }, + show_alert = { type = 'boolean', description = 'Show alert instead of notification' } + }, + required = { 'callback_query_id' } + }, + call = function(params) + return api.answer_callback_query(params.callback_query_id, { + text = params.text, + show_alert = params.show_alert + }) + end + }, + { + name = 'edit_message_text', + description = 'Edit the text of a message', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID' }, + message_id = { type = 'number', description = 'Message ID to edit' }, + text = { type = 'string', description = 'New text' }, + parse_mode = { type = 'string', description = 'Parse mode' } + }, + required = { 'chat_id', 'message_id', 'text' } + }, + call = function(params) + return api.edit_message_text(params.chat_id, params.message_id, params.text, { + parse_mode = params.parse_mode + }) + end + }, + { + name = 'forward_message', + description = 'Forward a message from one chat to another', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Target chat ID' }, + from_chat_id = { type = 'string', description = 'Source chat ID' }, + message_id = { type = 'number', description = 'Message ID to forward' }, + disable_notification = { type = 'boolean', description = 'Forward silently' } + }, + required = { 'chat_id', 'from_chat_id', 'message_id' } + }, + call = function(params) + return api.forward_message(params.chat_id, params.from_chat_id, params.message_id, { + disable_notification = params.disable_notification + }) + end + }, + { + name = 'send_document', + description = 'Send a document/file to a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Target chat ID' }, + document = { type = 'string', description = 'Document file path, URL, or file_id' }, + caption = { type = 'string', description = 'Document caption' }, + parse_mode = { type = 'string', description = 'Parse mode for caption' } + }, + required = { 'chat_id', 'document' } + }, + call = function(params) + return api.send_document(params.chat_id, params.document, { + caption = params.caption, + parse_mode = params.parse_mode + }) + end + }, + { + name = 'set_chat_title', + description = 'Set the title of a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID' }, + title = { type = 'string', description = 'New chat title' } + }, + required = { 'chat_id', 'title' } + }, + call = function(params) + return api.set_chat_title(params.chat_id, params.title) + end + }, + { + name = 'set_chat_description', + description = 'Set the description of a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID' }, + description = { type = 'string', description = 'New chat description' } + }, + required = { 'chat_id' } + }, + call = function(params) + return api.set_chat_description(params.chat_id, { + description = params.description + }) + end + }, + { + name = 'leave_chat', + description = 'Leave a chat', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID to leave' } + }, + required = { 'chat_id' } + }, + call = function(params) + return api.leave_chat(params.chat_id) + end + }, + { + name = 'get_chat_administrators', + description = 'Get a list of chat administrators', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID or @username' } + }, + required = { 'chat_id' } + }, + call = function(params) + return api.get_chat_administrators(params.chat_id) + end + }, + { + name = 'send_location', + description = 'Send a location point on the map', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Target chat ID' }, + latitude = { type = 'number', description = 'Latitude' }, + longitude = { type = 'number', description = 'Longitude' } + }, + required = { 'chat_id', 'latitude', 'longitude' } + }, + call = function(params) + return api.send_location(params.chat_id, params.latitude, params.longitude) + end + }, + { + name = 'send_contact', + description = 'Send a phone contact', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Target chat ID' }, + phone_number = { type = 'string', description = 'Phone number' }, + first_name = { type = 'string', description = 'Contact first name' }, + last_name = { type = 'string', description = 'Contact last name' } + }, + required = { 'chat_id', 'phone_number', 'first_name' } + }, + call = function(params) + return api.send_contact(params.chat_id, params.phone_number, params.first_name, { + last_name = params.last_name + }) + end + }, + { + name = 'restrict_chat_member', + description = 'Restrict a chat member permissions', + inputSchema = { + type = 'object', + properties = { + chat_id = { type = 'string', description = 'Chat ID' }, + user_id = { type = 'number', description = 'User ID to restrict' }, + permissions = { type = 'object', description = 'New permissions object' }, + until_date = { type = 'number', description = 'Restriction end date (Unix timestamp)' } + }, + required = { 'chat_id', 'user_id', 'permissions' } + }, + call = function(params) + return api.restrict_chat_member(params.chat_id, params.user_id, params.permissions, { + until_date = params.until_date + }) + end + } + } + + -- Build tool lookup by name + local tool_lookup = {} + for _, tool in ipairs(tool_defs) do + tool_lookup[tool.name] = tool + end + + -- Resource definitions + local resource_defs = { + { + uri = 'telegram://bot/info', + name = 'Bot Info', + description = 'Basic information about the bot (username, ID, capabilities)', + mimeType = 'application/json' + } + } + + -- MCP protocol version + local PROTOCOL_VERSION = '2024-11-05' + + -- Handle a single JSON-RPC request and return the response string. + function api.mcp.handle(request) + if type(request) == 'string' then + local ok, parsed = pcall(json.decode, request) + if not ok or not parsed then + return jsonrpc_error(nil, PARSE_ERROR, 'Parse error') + end + request = parsed + end + + if type(request) ~= 'table' then + return jsonrpc_error(nil, INVALID_REQUEST, 'Invalid request') + end + + local id = request.id + local method = request.method + + if not method or type(method) ~= 'string' then + return jsonrpc_error(id, INVALID_REQUEST, 'Invalid request: missing method') + end + + -- Notifications (no id) — acknowledge silently + if id == nil and (method == 'notifications/initialized' or method:match('^notifications/')) then + return nil + end + + local params = request.params or {} + + if method == 'initialize' then + return jsonrpc_result(id, { + protocolVersion = PROTOCOL_VERSION, + capabilities = { + tools = {}, + resources = {} + }, + serverInfo = { + name = 'telegram-bot-lua', + version = api.version or 'unknown' + } + }) + + elseif method == 'tools/list' then + local tools = {} + for _, tool in ipairs(tool_defs) do + tools[#tools + 1] = { + name = tool.name, + description = tool.description, + inputSchema = tool.inputSchema + } + end + return jsonrpc_result(id, { tools = tools }) + + elseif method == 'tools/call' then + local tool_name = params.name + local tool_args = params.arguments or {} + + if not tool_name or not tool_lookup[tool_name] then + return jsonrpc_error(id, METHOD_NOT_FOUND, 'Tool not found: ' .. tostring(tool_name)) + end + + local tool = tool_lookup[tool_name] + + -- Validate required params + local schema = tool.inputSchema + if schema and schema.required then + for _, req_param in ipairs(schema.required) do + if tool_args[req_param] == nil then + return jsonrpc_error(id, INVALID_PARAMS, + 'Missing required parameter: ' .. req_param) + end + end + end + + local ok, result = pcall(tool.call, tool_args) + if not ok then + return jsonrpc_error(id, INTERNAL_ERROR, 'Tool execution error: ' .. tostring(result)) + end + + -- Format result as MCP content + local content + if result and type(result) == 'table' then + content = { { type = 'text', text = json.encode(result) } } + else + content = { { type = 'text', text = tostring(result) } } + end + + return jsonrpc_result(id, { + content = content, + isError = not result + }) + + elseif method == 'resources/list' then + local resources = {} + for _, res in ipairs(resource_defs) do + resources[#resources + 1] = { + uri = res.uri, + name = res.name, + description = res.description, + mimeType = res.mimeType + } + end + return jsonrpc_result(id, { resources = resources }) + + elseif method == 'resources/read' then + local uri = params.uri + if not uri then + return jsonrpc_error(id, INVALID_PARAMS, 'Missing required parameter: uri') + end + + if uri == 'telegram://bot/info' then + local info = api.info or {} + return jsonrpc_result(id, { + contents = { + { + uri = uri, + mimeType = 'application/json', + text = json.encode(info) + } + } + }) + end + + return jsonrpc_error(id, METHOD_NOT_FOUND, 'Resource not found: ' .. uri) + + elseif method == 'ping' then + return jsonrpc_result(id, {}) + + else + return jsonrpc_error(id, METHOD_NOT_FOUND, 'Method not found: ' .. method) + end + end + + -- Run the MCP server: read JSON-RPC messages from stdin, write responses to stdout. + -- Each message is a single line of JSON. + function api.mcp.serve(opts) + opts = opts or {} + local input = opts.input or io.stdin + local write = opts.write or io.write + local flush = opts.flush or io.flush + + for line in input:lines() do + if line ~= '' then + local response = api.mcp.handle(line) + if response then + write(response .. '\n') + flush() + end + end + end + end +end diff --git a/src/middleware.lua b/src/middleware.lua new file mode 100644 index 0000000..23c5a6d --- /dev/null +++ b/src/middleware.lua @@ -0,0 +1,118 @@ +return function(api) + + api._middleware = {} + api._scoped_middleware = {} + + function api.use(fn) + table.insert(api._middleware, fn) + end + + -- Compose a list of middleware functions into a single function. + -- Each middleware receives (ctx, next) where next() continues the chain. + local function compose(middleware, ctx, final) + local index = 0 + local function dispatch(i) + if i <= index then + error('next() called multiple times') + end + index = i + local fn = middleware[i] + if not fn then + if final then final(ctx) end + return + end + fn(ctx, function() + dispatch(i + 1) + end) + end + dispatch(1) + end + + -- Build the context object from a raw update. + local function build_context(update) + local ctx = { update = update } + -- Determine update type and payload + local update_types = { + 'message', 'edited_message', 'callback_query', 'inline_query', + 'channel_post', 'edited_channel_post', 'chosen_inline_result', + 'shipping_query', 'pre_checkout_query', 'poll', 'poll_answer', + 'message_reaction', 'message_reaction_count', 'my_chat_member', + 'chat_member', 'chat_join_request', 'chat_boost', 'removed_chat_boost', + 'business_connection', 'business_message', 'edited_business_message', + 'deleted_business_messages', 'purchased_paid_media' + } + for _, utype in ipairs(update_types) do + if update[utype] then + ctx.update_type = utype + ctx[utype] = update[utype] + break + end + end + return ctx + end + + -- Execute middleware chain then dispatch to handlers. + function api._run_middleware(update) + local ctx = build_context(update) + + -- Collect applicable middleware: global + scoped + local chain = {} + for i = 1, #api._middleware do + chain[#chain + 1] = api._middleware[i] + end + local scoped = ctx.update_type and api._scoped_middleware[ctx.update_type] + if scoped then + for i = 1, #scoped do + chain[#chain + 1] = scoped[i] + end + end + + -- Final function: dispatch to the original handlers + local function dispatch(c) + api._dispatch_update(c.update) + end + + compose(chain, ctx, dispatch) + end + + -- Scoped middleware registration. + -- Returns a callable table so api.on_message(...) still works as a handler setter, + -- and api.on_message.use(fn) registers scoped middleware. + function api._register_scoped_middleware(update_type) + return { + use = function(fn) + if not api._scoped_middleware[update_type] then + api._scoped_middleware[update_type] = {} + end + table.insert(api._scoped_middleware[update_type], fn) + end + } + end + + -- Expose scoped middleware tables + api.middleware = { + on_message = api._register_scoped_middleware('message'), + on_callback_query = api._register_scoped_middleware('callback_query'), + on_inline_query = api._register_scoped_middleware('inline_query'), + on_channel_post = api._register_scoped_middleware('channel_post'), + on_edited_message = api._register_scoped_middleware('edited_message'), + on_edited_channel_post = api._register_scoped_middleware('edited_channel_post'), + on_chosen_inline_result = api._register_scoped_middleware('chosen_inline_result'), + on_shipping_query = api._register_scoped_middleware('shipping_query'), + on_pre_checkout_query = api._register_scoped_middleware('pre_checkout_query'), + on_poll = api._register_scoped_middleware('poll'), + on_poll_answer = api._register_scoped_middleware('poll_answer'), + on_message_reaction = api._register_scoped_middleware('message_reaction'), + on_message_reaction_count = api._register_scoped_middleware('message_reaction_count'), + on_my_chat_member = api._register_scoped_middleware('my_chat_member'), + on_chat_member = api._register_scoped_middleware('chat_member'), + on_chat_join_request = api._register_scoped_middleware('chat_join_request'), + on_chat_boost = api._register_scoped_middleware('chat_boost'), + on_removed_chat_boost = api._register_scoped_middleware('removed_chat_boost'), + on_business_connection = api._register_scoped_middleware('business_connection'), + on_business_message = api._register_scoped_middleware('business_message'), + on_edited_business_message = api._register_scoped_middleware('edited_business_message'), + on_deleted_business_messages = api._register_scoped_middleware('deleted_business_messages'), + on_purchased_paid_media = api._register_scoped_middleware('purchased_paid_media'), + } +end diff --git a/telegram-bot-lua-3.4-0.rockspec b/telegram-bot-lua-3.4-0.rockspec new file mode 100644 index 0000000..9c5c4fb --- /dev/null +++ b/telegram-bot-lua-3.4-0.rockspec @@ -0,0 +1,72 @@ +package = "telegram-bot-lua" +version = "3.4-0" +source = { + url = "https://github.com/wrxck/telegram-bot-lua/archive/refs/tags/v3.4.tar.gz", + dir = "telegram-bot-lua-3.4" +} +description = { + summary = "A feature-filled Telegram bot API library", + detailed = "A feature-filled Telegram bot API library written in Lua, with Bot API 9.4 support.", + homepage = "https://github.com/wrxck/telegram-bot-lua", + maintainer = "Matthew Hesketh ", + license = "GPL-3" +} +supported_platforms = { + "linux", + "macosx", + "unix", + "bsd" +} +dependencies = { + "lua >= 5.1", + "dkjson >= 2.5-2", + "luasec >= 0.6-1", + "luasocket >= 3.0rc1-2", + "multipart-post >= 1.1-1", + "luautf8 >= 0.1.1-1", + "copas >= 4.0" +} +build = { + type = "builtin", + modules = { + ["telegram-bot-lua"] = "src/main.lua", + ["telegram-bot-lua.config"] = "src/config.lua", + ["telegram-bot-lua.middleware"] = "src/middleware.lua", + ["telegram-bot-lua.handlers"] = "src/handlers.lua", + ["telegram-bot-lua.builders"] = "src/builders.lua", + ["telegram-bot-lua.helpers"] = "src/helpers.lua", + ["telegram-bot-lua.tools"] = "src/tools.lua", + ["telegram-bot-lua.utils"] = "src/utils.lua", + ["telegram-bot-lua.mcp"] = "src/mcp.lua", + ["telegram-bot-lua.compat"] = "src/compat.lua", + ["telegram-bot-lua.core"] = "src/core.lua", + ["telegram-bot-lua.polyfill"] = "src/polyfill.lua", + ["telegram-bot-lua.async"] = "src/async.lua", + ["telegram-bot-lua.b64url"] = "src/b64url.lua", + ["telegram-bot-lua.methods.updates"] = "src/methods/updates.lua", + ["telegram-bot-lua.methods.messages"] = "src/methods/messages.lua", + ["telegram-bot-lua.methods.chat"] = "src/methods/chat.lua", + ["telegram-bot-lua.methods.members"] = "src/methods/members.lua", + ["telegram-bot-lua.methods.forum"] = "src/methods/forum.lua", + ["telegram-bot-lua.methods.stickers"] = "src/methods/stickers.lua", + ["telegram-bot-lua.methods.inline"] = "src/methods/inline.lua", + ["telegram-bot-lua.methods.payments"] = "src/methods/payments.lua", + ["telegram-bot-lua.methods.games"] = "src/methods/games.lua", + ["telegram-bot-lua.methods.passport"] = "src/methods/passport.lua", + ["telegram-bot-lua.methods.bot"] = "src/methods/bot.lua", + ["telegram-bot-lua.methods.gifts"] = "src/methods/gifts.lua", + ["telegram-bot-lua.methods.checklists"] = "src/methods/checklists.lua", + ["telegram-bot-lua.methods.stories"] = "src/methods/stories.lua", + ["telegram-bot-lua.methods.suggested_posts"] = "src/methods/suggested_posts.lua", + ["telegram-bot-lua.adapters"] = "src/adapters/init.lua", + ["telegram-bot-lua.adapters.db"] = "src/adapters/db.lua", + ["telegram-bot-lua.adapters.redis"] = "src/adapters/redis.lua", + ["telegram-bot-lua.adapters.llm"] = "src/adapters/llm.lua", + ["telegram-bot-lua.adapters.email"] = "src/adapters/email.lua" + }, + install = { + bin = { + tgbot = "bin/tgbot" + } + } +}