From 62b50a342d4e77ad9bb0b62b8ce4f6b8aa511901 Mon Sep 17 00:00:00 2001 From: Matt Hesketh Date: Thu, 12 Feb 2026 00:24:43 +0000 Subject: [PATCH 1/4] ci: add GitHub Actions workflow and fix all luacheck warnings Add CI pipeline with test matrix (Lua 5.3, 5.4, LuaJIT) and lint job. Fix 16 luacheck warnings: unused imports, variable shadowing, style. --- .github/workflows/ci.yml | 83 ++++++++++++++++++++++++++++++++++++++++ src/adapters/db.lua | 12 +++--- src/adapters/email.lua | 8 +--- src/adapters/redis.lua | 26 ++++++------- src/async.lua | 1 - src/handlers.lua | 2 - src/tools.lua | 2 +- src/utils.lua | 1 - 8 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c029be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + test: + name: Lua ${{ matrix.lua-version }} + runs-on: ubuntu-latest + + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + strategy: + fail-fast: false + matrix: + lua-version: + - "5.3" + - "5.4" + - "luajit-2.1" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Lua ${{ matrix.lua-version }} + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: ${{ matrix.lua-version }} + + - name: Install LuaRocks + uses: leafo/gh-actions-luarocks@v4 + + - name: Install system libraries + run: | + sudo apt-get update + sudo apt-get install -y libsqlite3-dev libssl-dev + + - name: Install dependencies + run: | + luarocks install dkjson + luarocks install luasec + luarocks install luasocket + luarocks install multipart-post + luarocks install luautf8 + luarocks install copas + luarocks install lsqlite3 + luarocks install busted + + - name: Run tests + run: busted --no-coverage -o utfTerminal + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Lua + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.3" + + - name: Install LuaRocks + uses: leafo/gh-actions-luarocks@v4 + + - name: Install luacheck + run: luarocks install luacheck + + - name: Run luacheck + run: luacheck src/ --no-unused-args --no-max-line-length --globals _G _TEST diff --git a/src/adapters/db.lua b/src/adapters/db.lua index 03b700f..fd7cbd6 100644 --- a/src/adapters/db.lua +++ b/src/adapters/db.lua @@ -128,13 +128,13 @@ return function(api) function conn:transaction(fn) self:begin() - local ok, err = pcall(fn, self) - if ok then + local tx_ok, tx_err = pcall(fn, self) + if tx_ok then self:commit() return true else self:rollback() - return false, err + return false, tx_err end end @@ -248,13 +248,13 @@ return function(api) function conn:transaction(fn) self:begin() - local ok, err = pcall(fn, self) - if ok then + local tx_ok, tx_err = pcall(fn, self) + if tx_ok then self:commit() return true else self:rollback() - return false, err + return false, tx_err end end diff --git a/src/adapters/email.lua b/src/adapters/email.lua index 468560a..3bfc9fa 100644 --- a/src/adapters/email.lua +++ b/src/adapters/email.lua @@ -60,7 +60,6 @@ return function(api) assert(msg.body or msg.html, 'Email requires a body or html content') local smtp = require('socket.smtp') - local mime = require('mime') local ltn12 = require('ltn12') -- Normalize recipients to table @@ -131,11 +130,6 @@ return function(api) message_source = ltn12.source.string(header_str .. '\r\n' .. msg.body) end - local mesgt = { - headers = headers, - body = message_source, - } - -- Build the send parameters local send_params = { from = '<' .. msg.from .. '>', @@ -155,7 +149,7 @@ return function(api) -- Use STARTTLS if configured if self._tls then -- For STARTTLS on port 587, we need to use the create function - local ok_ssl, ssl = pcall(require, 'ssl') + local ok_ssl = pcall(require, 'ssl') if ok_ssl then send_params.create = function() local socket_lib = require('socket') diff --git a/src/adapters/redis.lua b/src/adapters/redis.lua index 30e7a2c..7acca07 100644 --- a/src/adapters/redis.lua +++ b/src/adapters/redis.lua @@ -64,9 +64,9 @@ return function(api) -- RESP protocol: read a response local function read_response(sock_handle) - local line, err = sock_handle:receive('*l') + local line, recv_err = sock_handle:receive('*l') if not line then - return nil, 'Redis read error: ' .. tostring(err) + return nil, 'Redis read error: ' .. tostring(recv_err) end local prefix = line:sub(1, 1) @@ -110,28 +110,28 @@ return function(api) -- Execute a raw Redis command and return the response function conn:command(...) - local ok, err = send_command(self._sock, ...) - if not ok then - return nil, 'Redis send error: ' .. tostring(err) + local send_ok, send_err = send_command(self._sock, ...) + if not send_ok then + return nil, 'Redis send error: ' .. tostring(send_err) end return read_response(self._sock) end -- Authenticate if password provided if opts.password then - local res, err = conn:command('AUTH', opts.password) - if not res then + local auth_res, auth_err = conn:command('AUTH', opts.password) + if not auth_res then sock:close() - error('Redis AUTH failed: ' .. tostring(err)) + error('Redis AUTH failed: ' .. tostring(auth_err)) end end -- Select database if specified if opts.db and opts.db ~= 0 then - local res, err = conn:command('SELECT', opts.db) - if not res then + local sel_res, sel_err = conn:command('SELECT', opts.db) + if not sel_res then sock:close() - error('Redis SELECT failed: ' .. tostring(err)) + error('Redis SELECT failed: ' .. tostring(sel_err)) end end @@ -393,11 +393,11 @@ return function(api) function conn:is_connected() if not self._sock then return false end - local ok = pcall(function() + local ping_ok = pcall(function() send_command(self._sock, 'PING') read_response(self._sock) end) - return ok + return ping_ok end return conn diff --git a/src/async.lua b/src/async.lua index 6ac0b7e..1339c70 100644 --- a/src/async.lua +++ b/src/async.lua @@ -30,7 +30,6 @@ return function(api) local ltn12 = require('ltn12') local multipart = require('multipart-post') local json = require('dkjson') - local config = require('telegram-bot-lua.config') api.async = {} api.async._running = false diff --git a/src/handlers.lua b/src/handlers.lua index 2689077..31e6d6e 100644 --- a/src/handlers.lua +++ b/src/handlers.lua @@ -1,6 +1,4 @@ return function(api) - local json = require('dkjson') - local config = require('telegram-bot-lua.config') -- Update handler stubs function api.on_update(_) end diff --git a/src/tools.lua b/src/tools.lua index 8697e74..92a017d 100644 --- a/src/tools.lua +++ b/src/tools.lua @@ -750,7 +750,7 @@ function tools.unpack_file_id(file_id, media_type) local subversion = (version == 4) and string.byte(decoded:sub(-2, -1)) or 0 decoded = decoded:sub(9, -1) local file_reference_flag = lshift(1, 25) - if not (band(file_flags, file_reference_flag) == 0) then + if band(file_flags, file_reference_flag) ~= 0 then local file_reference_length = string.byte(decoded:sub(1, 1)) local padding decoded = string.char(0) .. decoded:sub(2, -1) diff --git a/src/utils.lua b/src/utils.lua index 2836026..4705384 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -1,5 +1,4 @@ return function(api) - local json = require('dkjson') local tools = require('telegram-bot-lua.tools') -- Text formatting helpers for different parse modes. From 93d9f9a5968759372075cae46cd053cb8e03c9a7 Mon Sep 17 00:00:00 2001 From: Matt Hesketh Date: Thu, 12 Feb 2026 00:37:16 +0000 Subject: [PATCH 2/4] ci: sync CI config - remove LuaJIT, add workflow_dispatch --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c029be..3ea3adf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main, develop] + workflow_dispatch: jobs: test: @@ -27,7 +28,6 @@ jobs: lua-version: - "5.3" - "5.4" - - "luajit-2.1" steps: - name: Checkout From 55ef16b7a873782f3a6039f2dbf92ad15d2021b4 Mon Sep 17 00:00:00 2001 From: Matt Hesketh Date: Mon, 16 Feb 2026 22:34:47 +0000 Subject: [PATCH 3/4] fix(api): handle multiple file params and fix parse_mode defaults Fixes #30 - api.request only processed one file entry via next(file), causing thumbnails to be lost and potentially dropping the primary media when both a file and thumbnail were provided. Changed to iterate all entries with a for loop. Also fixes: - send_reply, input_text_message_content, and send_inline_article using deprecated 'markdown' instead of 'MarkdownV2' for boolean parse_mode - send_poll missing question_parse_mode and question_entities params (added in Bot API 7.3) Adds comprehensive unit tests for all media methods, e2e test suite (busted --run e2e), .gitignore, and bumps version to 3.1-0. --- .busted | 6 + .gitignore | 2 + spec/builders_spec.lua | 4 +- spec/e2e_test.lua | 269 ++++++++++++++++++++ spec/methods_spec.lua | 422 +++++++++++++++++++++++++++++++- spec/test_helper.lua | 1 + src/builders.lua | 2 +- src/init.lua | 27 +- src/methods/inline.lua | 2 +- src/methods/messages.lua | 6 +- telegram-bot-lua-3.1-0.rockspec | 66 +++++ 11 files changed, 786 insertions(+), 21 deletions(-) create mode 100644 .gitignore create mode 100644 spec/e2e_test.lua create mode 100644 telegram-bot-lua-3.1-0.rockspec diff --git a/.busted b/.busted index 466287d..6fc1494 100644 --- a/.busted +++ b/.busted @@ -5,5 +5,11 @@ return { helper = "spec/test_helper.lua", lpath = "./src/?.lua;./src/?/init.lua", verbose = true + }, + e2e = { + ROOT = {"spec"}, + pattern = "e2e_test", + lpath = "./src/?.lua;./src/?/init.lua", + verbose = true } } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78b3df5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +*.env diff --git a/spec/builders_spec.lua b/spec/builders_spec.lua index dca3728..705bbc0 100644 --- a/spec/builders_spec.lua +++ b/spec/builders_spec.lua @@ -213,9 +213,9 @@ describe('builders', function() assert.equals('HTML', c.parse_mode) end) - it('converts boolean true to markdown', function() + it('converts boolean true to MarkdownV2', function() local c = api.input_text_message_content('Hello', true) - assert.equals('markdown', c.parse_mode) + assert.equals('MarkdownV2', c.parse_mode) end) end) diff --git a/spec/e2e_test.lua b/spec/e2e_test.lua new file mode 100644 index 0000000..1514e29 --- /dev/null +++ b/spec/e2e_test.lua @@ -0,0 +1,269 @@ +-- End-to-end integration tests using the real Telegram Bot API +-- Requires a .env file with BOT_TOKEN set +-- Run with: busted --run e2e + +-- Load .env file +local function load_env() + local f = io.open('.env', 'r') + if not f then return nil end + local env = {} + for line in f:lines() do + local key, value = line:match('^([%w_]+)%s*=%s*(.+)$') + if key and value then + env[key] = value + end + end + f:close() + return env +end + +local env = load_env() +if not env or not env.BOT_TOKEN then + print('Skipping e2e tests: no .env file or BOT_TOKEN not set') + return +end + +_G._TEST = true +package.path = './src/?.lua;./src/?/init.lua;' .. package.path + +local module_map = { + ['telegram-bot-lua'] = 'src/init.lua', + ['telegram-bot-lua.config'] = 'src/config.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.async'] = 'src/async.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.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', +} +for mod_name, file_path in pairs(module_map) do + if not package.preload[mod_name] then + package.preload[mod_name] = function() + return dofile(file_path) + end + end +end + +local api = require('telegram-bot-lua') +api.token = env.BOT_TOKEN + +describe('e2e', function() + describe('bot identity', function() + it('get_me returns bot info', function() + local result = api.get_me() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.is_true(result.result.is_bot) + assert.is_string(result.result.first_name) + assert.is_number(result.result.id) + assert.is_string(result.result.username) + end) + end) + + describe('bot commands', function() + it('set_my_commands succeeds', function() + local commands = { + api.bot_command('start', 'Start the bot'), + api.bot_command('help', 'Show help') + } + local result = api.set_my_commands(commands) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_my_commands returns the commands', function() + local result = api.get_my_commands() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.equals(2, #result.result) + assert.equals('start', result.result[1].command) + assert.equals('help', result.result[2].command) + end) + + it('delete_my_commands succeeds', function() + local result = api.delete_my_commands() + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_my_commands returns empty after delete', function() + local result = api.get_my_commands() + assert.is_table(result) + assert.is_true(result.ok) + assert.equals(0, #result.result) + end) + end) + + describe('bot profile', function() + local original_description + local original_short_description + + it('get_my_name returns name', function() + local result = api.get_my_name() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + + it('get_my_description returns description', function() + local result = api.get_my_description() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + original_description = result.result.description or '' + end) + + it('get_my_short_description returns short description', function() + local result = api.get_my_short_description() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + original_short_description = result.result.short_description or '' + end) + + it('set_my_description succeeds', function() + local result = api.set_my_description('E2E test bot description') + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_my_description confirms change', function() + local result = api.get_my_description() + assert.is_table(result) + assert.is_true(result.ok) + assert.equals('E2E test bot description', result.result.description) + end) + + it('restores original description', function() + local result = api.set_my_description(original_description) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('set_my_short_description succeeds', function() + local result = api.set_my_short_description('E2E short desc') + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('restores original short description', function() + local result = api.set_my_short_description(original_short_description) + assert.is_table(result) + assert.is_true(result.ok) + end) + end) + + describe('webhook management', function() + it('delete_webhook succeeds', function() + local result = api.delete_webhook() + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_webhook_info shows no webhook', function() + local result = api.get_webhook_info() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.equals('', result.result.url) + end) + + it('set_webhook succeeds', function() + local result = api.set_webhook('https://example.com/e2e-test-webhook') + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_webhook_info confirms webhook', function() + local result = api.get_webhook_info() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.equals('https://example.com/e2e-test-webhook', result.result.url) + end) + + it('cleans up webhook', function() + local result = api.delete_webhook() + assert.is_table(result) + assert.is_true(result.ok) + end) + end) + + describe('error handling', function() + it('returns error for invalid chat_id', function() + local result, err = api.send_message(0, 'test') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('returns error for invalid file_id', function() + local result, err = api.get_file('invalid_file_id_that_does_not_exist') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + end) + + describe('get_updates', function() + it('returns updates array', function() + local result = api.get_updates({ timeout = 0, limit = 1 }) + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + end) + + describe('menu button', function() + it('get_chat_menu_button returns default', function() + local result = api.get_chat_menu_button() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + end) + + describe('default administrator rights', function() + it('get_my_default_administrator_rights returns rights', function() + local result = api.get_my_default_administrator_rights() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + end) + + describe('star transactions', function() + it('get_star_transactions returns transactions', function() + local result = api.get_star_transactions() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + end) +end) diff --git a/spec/methods_spec.lua b/spec/methods_spec.lua index f289f99..818e669 100644 --- a/spec/methods_spec.lua +++ b/spec/methods_spec.lua @@ -31,7 +31,7 @@ describe('methods', function() assert.is_true(req.parameters.disable_notification) end) - it('send_message converts boolean parse_mode', function() + it('send_message converts boolean parse_mode to MarkdownV2', function() api.send_message(123, 'Hello', { parse_mode = true }) local req = api._last_request() assert.equals('MarkdownV2', req.parameters.parse_mode) @@ -45,11 +45,59 @@ describe('methods', function() assert.is_table(decoded.inline_keyboard) end) + it('send_message JSON-encodes link_preview_options', function() + api.send_message(123, 'Hello', { + link_preview_options = { is_disabled = true } + }) + local req = api._last_request() + local decoded = json.decode(req.parameters.link_preview_options) + assert.is_true(decoded.is_disabled) + end) + + it('send_message JSON-encodes reply_parameters', function() + api.send_message(123, 'Hello', { + reply_parameters = api.reply_parameters(42, 123, true) + }) + local req = api._last_request() + local decoded = json.decode(req.parameters.reply_parameters) + assert.equals(42, decoded.message_id) + end) + + it('send_message JSON-encodes entities', function() + local entities = {{ type = 'bold', offset = 0, length = 5 }} + api.send_message(123, 'Hello', { entities = entities }) + local req = api._last_request() + local decoded = json.decode(req.parameters.entities) + assert.equals('bold', decoded[1].type) + end) + + it('send_message passes business_connection_id', function() + api.send_message(123, 'Hello', { business_connection_id = 'biz_123' }) + local req = api._last_request() + assert.equals('biz_123', req.parameters.business_connection_id) + end) + + it('send_message passes message_effect_id', function() + api.send_message(123, 'Hello', { message_effect_id = 'effect_1' }) + local req = api._last_request() + assert.equals('effect_1', req.parameters.message_effect_id) + end) + it('send_reply validates message table', function() local result = api.send_reply('not a table', 'text') assert.is_false(result) end) + it('send_reply rejects table without chat', function() + local result = api.send_reply({ message_id = 42 }, 'text') + assert.is_false(result) + end) + + it('send_reply rejects table without message_id', function() + local result = api.send_reply({ chat = { id = 123 } }, 'text') + assert.is_false(result) + end) + it('send_reply creates reply_parameters', function() api.send_reply({ chat = { id = 123 }, message_id = 42 }, 'Hello') local req = api._last_request() @@ -58,6 +106,28 @@ describe('methods', function() assert.equals(42, rp.message_id) end) + it('send_reply uses MarkdownV2 for boolean parse_mode', function() + api.send_reply( + { chat = { id = 123 }, message_id = 42 }, + 'Hello', + { parse_mode = true } + ) + local req = api._last_request() + assert.equals('MarkdownV2', req.parameters.parse_mode) + end) + + it('send_reply preserves custom reply_parameters', function() + local custom_rp = api.reply_parameters(99, 456, false) + api.send_reply( + { chat = { id = 123 }, message_id = 42 }, + 'Hello', + { reply_parameters = custom_rp } + ) + local req = api._last_request() + local rp = json.decode(req.parameters.reply_parameters) + assert.equals(99, rp.message_id) + end) + it('forward_message works', function() api.forward_message(123, 456, 789) local req = api._last_request() @@ -65,6 +135,14 @@ describe('methods', function() assert.equals(789, req.parameters.message_id) end) + it('forward_messages encodes message_ids', function() + api.forward_messages(123, 456, {1, 2, 3}) + local req = api._last_request() + assert.truthy(req.endpoint:find('/forwardMessages')) + local ids = json.decode(req.parameters.message_ids) + assert.equals(3, #ids) + end) + it('copy_message works', function() api.copy_message(123, 456, 789, { caption = 'New caption' }) local req = api._last_request() @@ -72,6 +150,14 @@ describe('methods', function() assert.equals('New caption', req.parameters.caption) end) + it('copy_messages works', function() + api.copy_messages(123, 456, {1, 2}) + local req = api._last_request() + assert.truthy(req.endpoint:find('/copyMessages')) + end) + + -- Media methods: file and opts handling + it('send_photo passes file param', function() api.send_photo(123, 'photo_file_id') local req = api._last_request() @@ -80,12 +166,150 @@ describe('methods', function() assert.equals('photo_file_id', req.file.photo) end) - it('send_document passes file param', function() - api.send_document(123, 'doc_id') + it('send_photo passes caption and opts', function() + api.send_photo(123, 'photo_file_id', { + caption = 'My photo', + parse_mode = 'HTML', + has_spoiler = true, + show_caption_above_media = true + }) + local req = api._last_request() + assert.equals('My photo', req.parameters.caption) + assert.equals('HTML', req.parameters.parse_mode) + assert.is_true(req.parameters.has_spoiler) + assert.is_true(req.parameters.show_caption_above_media) + end) + + it('send_audio passes file and thumbnail', function() + api.send_audio(123, 'audio_file_id', { thumbnail = 'thumb_id' }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendAudio')) + assert.equals('audio_file_id', req.file.audio) + assert.equals('thumb_id', req.file.thumbnail) + end) + + it('send_audio passes caption and metadata', function() + api.send_audio(123, 'audio_file_id', { + caption = 'My audio', + duration = 120, + performer = 'Artist', + title = 'Song' + }) + local req = api._last_request() + assert.equals('My audio', req.parameters.caption) + assert.equals(120, req.parameters.duration) + assert.equals('Artist', req.parameters.performer) + assert.equals('Song', req.parameters.title) + end) + + it('send_document passes file and thumbnail', function() + api.send_document(123, 'doc_file_id', { thumbnail = 'thumb_id' }) local req = api._last_request() assert.truthy(req.endpoint:find('/sendDocument')) + assert.equals('doc_file_id', req.file.document) + assert.equals('thumb_id', req.file.thumbnail) + end) + + it('send_document passes caption', function() + api.send_document(123, 'doc_file_id', { caption = 'My doc' }) + local req = api._last_request() + assert.equals('My doc', req.parameters.caption) + end) + + it('send_video passes file and thumbnail', function() + api.send_video(123, 'video_file_id', { thumbnail = 'thumb_id' }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendVideo')) + assert.equals('video_file_id', req.file.video) + assert.equals('thumb_id', req.file.thumbnail) + end) + + it('send_video passes caption and all opts', function() + api.send_video(123, 'video_file_id', { + caption = 'My video', + parse_mode = 'HTML', + duration = 60, + width = 1920, + height = 1080, + has_spoiler = true, + supports_streaming = true, + show_caption_above_media = true + }) + local req = api._last_request() + assert.equals('My video', req.parameters.caption) + assert.equals('HTML', req.parameters.parse_mode) + assert.equals(60, req.parameters.duration) + assert.equals(1920, req.parameters.width) + assert.equals(1080, req.parameters.height) + assert.is_true(req.parameters.has_spoiler) + assert.is_true(req.parameters.supports_streaming) + assert.is_true(req.parameters.show_caption_above_media) + end) + + it('send_video JSON-encodes caption_entities', function() + local entities = {{ type = 'bold', offset = 0, length = 5 }} + api.send_video(123, 'video_file_id', { caption_entities = entities }) + local req = api._last_request() + local decoded = json.decode(req.parameters.caption_entities) + assert.equals('bold', decoded[1].type) + end) + + it('send_animation passes file and thumbnail', function() + api.send_animation(123, 'anim_file_id', { thumbnail = 'thumb_id' }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendAnimation')) + assert.equals('anim_file_id', req.file.animation) + assert.equals('thumb_id', req.file.thumbnail) + end) + + it('send_animation passes caption', function() + api.send_animation(123, 'anim_file_id', { caption = 'My GIF' }) + local req = api._last_request() + assert.equals('My GIF', req.parameters.caption) + end) + + it('send_voice passes file param', function() + api.send_voice(123, 'voice_file_id') + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendVoice')) + assert.equals('voice_file_id', req.file.voice) + end) + + it('send_voice passes caption', function() + api.send_voice(123, 'voice_file_id', { caption = 'My voice' }) + local req = api._last_request() + assert.equals('My voice', req.parameters.caption) + end) + + it('send_video_note passes file and thumbnail', function() + api.send_video_note(123, 'vnote_file_id', { thumbnail = 'thumb_id' }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendVideoNote')) + assert.equals('vnote_file_id', req.file.video_note) + assert.equals('thumb_id', req.file.thumbnail) end) + it('send_video_note passes duration and length', function() + api.send_video_note(123, 'vnote_file_id', { duration = 30, length = 240 }) + local req = api._last_request() + assert.equals(30, req.parameters.duration) + assert.equals(240, req.parameters.length) + end) + + it('send_media_group encodes media', function() + local media = { + { type = 'photo', media = 'photo1' }, + { type = 'photo', media = 'photo2' } + } + api.send_media_group(123, media) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendMediaGroup')) + local decoded = json.decode(req.parameters.media) + assert.equals(2, #decoded) + end) + + -- Poll tests + it('send_poll encodes options', function() api.send_poll(123, 'Question?', { { text = 'Yes' }, { text = 'No' } @@ -96,6 +320,51 @@ describe('methods', function() assert.equals(2, #opts) end) + it('send_poll passes question_parse_mode', function() + api.send_poll(123, '*Bold question*', { + { text = 'Yes' }, { text = 'No' } + }, { question_parse_mode = 'MarkdownV2' }) + local req = api._last_request() + assert.equals('MarkdownV2', req.parameters.question_parse_mode) + end) + + it('send_poll JSON-encodes question_entities', function() + local entities = {{ type = 'bold', offset = 0, length = 4 }} + api.send_poll(123, 'Question?', { + { text = 'Yes' }, { text = 'No' } + }, { question_entities = entities }) + local req = api._last_request() + local decoded = json.decode(req.parameters.question_entities) + assert.equals('bold', decoded[1].type) + end) + + it('send_poll passes quiz options', function() + api.send_poll(123, 'Capital of France?', { + { text = 'Berlin' }, { text = 'Paris' }, { text = 'London' } + }, { + poll_type = 'quiz', + correct_option_id = 1, + explanation = 'Paris is the capital', + explanation_parse_mode = 'HTML' + }) + local req = api._last_request() + assert.equals('quiz', req.parameters.type) + assert.equals(1, req.parameters.correct_option_id) + assert.equals('Paris is the capital', req.parameters.explanation) + end) + + it('send_poll JSON-encodes explanation_entities', function() + local entities = {{ type = 'italic', offset = 0, length = 5 }} + api.send_poll(123, 'Q?', { + { text = 'A' }, { text = 'B' } + }, { explanation_entities = entities }) + local req = api._last_request() + local decoded = json.decode(req.parameters.explanation_entities) + assert.equals('italic', decoded[1].type) + end) + + -- Other send methods + it('send_dice works', function() api.send_dice(123, { emoji = '🎲' }) local req = api._last_request() @@ -109,6 +378,60 @@ describe('methods', function() assert.equals('typing', req.parameters.action) end) + it('send_location passes coordinates', function() + api.send_location(123, 51.5074, -0.1278) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendLocation')) + assert.equals(51.5074, req.parameters.latitude) + assert.equals(-0.1278, req.parameters.longitude) + end) + + it('send_location passes live period and opts', function() + api.send_location(123, 51.5074, -0.1278, { + live_period = 3600, + heading = 90, + proximity_alert_radius = 100 + }) + local req = api._last_request() + assert.equals(3600, req.parameters.live_period) + assert.equals(90, req.parameters.heading) + assert.equals(100, req.parameters.proximity_alert_radius) + end) + + it('send_venue passes all fields', function() + api.send_venue(123, 51.5074, -0.1278, 'Big Ben', 'Westminster, London', { + foursquare_id = 'fsq_123', + google_place_id = 'gp_123' + }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendVenue')) + assert.equals('Big Ben', req.parameters.title) + assert.equals('Westminster, London', req.parameters.address) + assert.equals('fsq_123', req.parameters.foursquare_id) + end) + + it('send_contact passes phone and name', function() + api.send_contact(123, '+447911123456', 'John', { + last_name = 'Doe', + vcard = 'BEGIN:VCARD' + }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendContact')) + assert.equals('+447911123456', req.parameters.phone_number) + assert.equals('John', req.parameters.first_name) + assert.equals('Doe', req.parameters.last_name) + end) + + it('set_message_reaction works', function() + api.set_message_reaction(123, 42, { + reaction = {{ type = 'emoji', emoji = '👍' }} + }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/setMessageReaction')) + local decoded = json.decode(req.parameters.reaction) + assert.equals('👍', decoded[1].emoji) + end) + it('send_paid_media works', function() api.send_paid_media(123, 100, {}) local req = api._last_request() @@ -116,6 +439,8 @@ describe('methods', function() assert.equals(100, req.parameters.star_count) end) + -- Edit methods + it('edit_message_text works', function() api.edit_message_text(123, 42, 'Updated', { parse_mode = 'HTML' }) local req = api._last_request() @@ -124,6 +449,67 @@ describe('methods', function() assert.equals('HTML', req.parameters.parse_mode) end) + it('edit_message_text converts boolean parse_mode', function() + api.edit_message_text(123, 42, 'Updated', { parse_mode = true }) + local req = api._last_request() + assert.equals('MarkdownV2', req.parameters.parse_mode) + end) + + it('edit_message_text passes inline_message_id', function() + api.edit_message_text(nil, nil, 'Updated', { inline_message_id = 'inline_123' }) + local req = api._last_request() + assert.equals('inline_123', req.parameters.inline_message_id) + end) + + it('edit_message_caption works', function() + api.edit_message_caption(123, 42, { + caption = 'New caption', + parse_mode = true + }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/editMessageCaption')) + assert.equals('New caption', req.parameters.caption) + assert.equals('MarkdownV2', req.parameters.parse_mode) + end) + + it('edit_message_media works', function() + local media = { type = 'photo', media = 'photo_id' } + api.edit_message_media(123, 42, media) + local req = api._last_request() + assert.truthy(req.endpoint:find('/editMessageMedia')) + local decoded = json.decode(req.parameters.media) + assert.equals('photo', decoded.type) + end) + + it('edit_message_reply_markup works', function() + local kb = api.inline_keyboard():row(api.row():callback_data_button('OK', 'ok')) + api.edit_message_reply_markup(123, 42, { reply_markup = kb }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/editMessageReplyMarkup')) + local decoded = json.decode(req.parameters.reply_markup) + assert.is_table(decoded.inline_keyboard) + end) + + it('edit_message_live_location works', function() + api.edit_message_live_location(123, 42, 52.0, 13.0, { heading = 180 }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/editMessageLiveLocation')) + assert.equals(52.0, req.parameters.latitude) + assert.equals(180, req.parameters.heading) + end) + + it('stop_message_live_location works', function() + api.stop_message_live_location(123, 42) + local req = api._last_request() + assert.truthy(req.endpoint:find('/stopMessageLiveLocation')) + end) + + it('stop_poll works', function() + api.stop_poll(123, 42) + local req = api._last_request() + assert.truthy(req.endpoint:find('/stopPoll')) + end) + it('delete_message works', function() api.delete_message(123, 42) local req = api._last_request() @@ -180,6 +566,36 @@ describe('methods', function() end) end) + describe('stickers', function() + it('send_sticker passes file param', function() + api.send_sticker(123, 'sticker_file_id') + local req = api._last_request() + assert.truthy(req.endpoint:find('/sendSticker')) + assert.equals('sticker_file_id', req.file.sticker) + end) + + it('send_sticker passes opts', function() + api.send_sticker(123, 'sticker_file_id', { emoji = '😀' }) + local req = api._last_request() + assert.equals('😀', req.parameters.emoji) + end) + + it('upload_sticker_file works', function() + api.upload_sticker_file(456, 'sticker_data', 'static') + local req = api._last_request() + assert.truthy(req.endpoint:find('/uploadStickerFile')) + assert.equals(456, req.parameters.user_id) + assert.equals('static', req.parameters.sticker_format) + end) + + it('set_sticker_set_thumbnail passes file', function() + api.set_sticker_set_thumbnail('my_set', 456, { thumbnail = 'thumb_data' }) + local req = api._last_request() + assert.truthy(req.endpoint:find('/setStickerSetThumbnail')) + assert.equals('thumb_data', req.file.thumbnail) + end) + end) + describe('inline', function() it('answer_callback_query works', function() api.answer_callback_query('123', { text = 'Hello!', show_alert = true }) diff --git a/spec/test_helper.lua b/spec/test_helper.lua index 5f75c5e..aa691b1 100644 --- a/spec/test_helper.lua +++ b/spec/test_helper.lua @@ -57,6 +57,7 @@ api.info.name = api.info.first_name -- Store request calls for assertions api._requests = {} local real_request = api.request +api._real_request = real_request api.request = function(endpoint, parameters, file) table.insert(api._requests, { endpoint = endpoint, diff --git a/src/builders.lua b/src/builders.lua index d7e725b..59fb9f4 100644 --- a/src/builders.lua +++ b/src/builders.lua @@ -345,7 +345,7 @@ return function(api) -- Input message content constructors function api.input_text_message_content(message_text, parse_mode, link_preview_options, encoded) - parse_mode = (type(parse_mode) == 'boolean' and parse_mode == true) and 'markdown' or parse_mode + parse_mode = (type(parse_mode) == 'boolean' and parse_mode == true) and 'MarkdownV2' or parse_mode local input_message_content = { ['message_text'] = tostring(message_text), ['parse_mode'] = parse_mode, diff --git a/src/init.lua b/src/init.lua index 8705020..4af71b6 100644 --- a/src/init.lua +++ b/src/init.lua @@ -20,7 +20,7 @@ local ltn12 = require('ltn12') local json = require('dkjson') local config = require('telegram-bot-lua.config') -api.version = '3.0-0' +api.version = '3.1-0' function api.configure(token, debug) if not token or type(token) ~= 'string' then @@ -59,21 +59,22 @@ function api.request(endpoint, parameters, file) local output = json.encode(safe, { ['indent'] = true }) print(output) end - if file and next(file) ~= nil then - local file_type, file_name = next(file) - if type(file_name) == 'string' then - local file_res = io.open(file_name, 'rb') - if file_res then - parameters[file_type] = { - filename = file_name, - data = file_res:read('*a') - } - file_res:close() + if file then + for file_type, file_name in pairs(file) do + if type(file_name) == 'string' then + local file_res = io.open(file_name, 'rb') + if file_res then + parameters[file_type] = { + filename = file_name, + data = file_res:read('*a') + } + file_res:close() + else + parameters[file_type] = file_name + end else parameters[file_type] = file_name end - else - parameters[file_type] = file_name end end parameters = next(parameters) == nil and {''} or parameters diff --git a/src/methods/inline.lua b/src/methods/inline.lua index 2cd6261..188d7d3 100644 --- a/src/methods/inline.lua +++ b/src/methods/inline.lua @@ -49,7 +49,7 @@ return function(api) function api.send_inline_article(inline_query_id, title, description, message_text, parse_mode, reply_markup) description = description or title message_text = message_text or description - parse_mode = (type(parse_mode) == 'boolean' and parse_mode == true) and 'markdown' or parse_mode + parse_mode = (type(parse_mode) == 'boolean' and parse_mode == true) and 'MarkdownV2' or parse_mode return api.answer_inline_query(inline_query_id, json.encode({{ ['type'] = 'article', ['id'] = '1', diff --git a/src/methods/messages.lua b/src/methods/messages.lua index ba0311f..ce1984a 100644 --- a/src/methods/messages.lua +++ b/src/methods/messages.lua @@ -41,7 +41,7 @@ return function(api) local reply_markup = opts.reply_markup reply_markup = type(reply_markup) == 'table' and json.encode(reply_markup) or reply_markup local parse_mode = opts.parse_mode - parse_mode = (type(parse_mode) == 'boolean' and parse_mode == true) and 'markdown' or parse_mode + parse_mode = (type(parse_mode) == 'boolean' and parse_mode == true) and 'MarkdownV2' or parse_mode local reply_parameters = opts.reply_parameters if not reply_parameters then reply_parameters = api.reply_parameters(message.message_id, message.chat.id, true) @@ -442,6 +442,8 @@ return function(api) function api.send_poll(chat_id, question, options, opts) opts = opts or {} options = type(options) == 'table' and json.encode(options) or options + local question_entities = opts.question_entities + question_entities = type(question_entities) == 'table' and json.encode(question_entities) or question_entities local explanation_entities = opts.explanation_entities explanation_entities = type(explanation_entities) == 'table' and json.encode(explanation_entities) or explanation_entities local reply_parameters = opts.reply_parameters @@ -452,6 +454,8 @@ return function(api) ['chat_id'] = chat_id, ['message_thread_id'] = opts.message_thread_id, ['question'] = question, + ['question_parse_mode'] = opts.question_parse_mode, + ['question_entities'] = question_entities, ['options'] = options, ['is_anonymous'] = opts.is_anonymous, ['type'] = opts.poll_type, diff --git a/telegram-bot-lua-3.1-0.rockspec b/telegram-bot-lua-3.1-0.rockspec new file mode 100644 index 0000000..3ed7721 --- /dev/null +++ b/telegram-bot-lua-3.1-0.rockspec @@ -0,0 +1,66 @@ +package = "telegram-bot-lua" +version = "3.1-0" +source = { + url = "git://github.com/wrxck/telegram-bot-lua.git", + dir = "telegram-bot-lua", + tag = "v3.1" +} +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/init.lua", + ["telegram-bot-lua.config"] = "src/config.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.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" + } +} From f2f2ed16c4238777d91757f87a05e565d403f55d Mon Sep 17 00:00:00 2001 From: Matt Hesketh Date: Mon, 16 Feb 2026 23:32:41 +0000 Subject: [PATCH 4/4] test(e2e): expand end-to-end test coverage from 24 to 97 tests Add comprehensive e2e tests for bot profile (name/description set/get/restore with language_code), scoped commands, admin rights, menu button, webhook opts, forum topic icon stickers, user profile photos, available gifts, sticker sets, invoice links, custom emoji stickers, and error-case coverage for all send methods, chat management, inline queries, games, and passport. --- spec/e2e_test.lua | 749 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 714 insertions(+), 35 deletions(-) diff --git a/spec/e2e_test.lua b/spec/e2e_test.lua index 1514e29..aaec111 100644 --- a/spec/e2e_test.lua +++ b/spec/e2e_test.lua @@ -71,7 +71,15 @@ end local api = require('telegram-bot-lua') api.token = env.BOT_TOKEN +-- Get bot info for use in tests +local bot_info = api.get_me() +local bot_id = bot_info and bot_info.result and bot_info.result.id + describe('e2e', function() + + -- ========================================================================= + -- Bot identity + -- ========================================================================= describe('bot identity', function() it('get_me returns bot info', function() local result = api.get_me() @@ -85,7 +93,10 @@ describe('e2e', function() end) end) - describe('bot commands', function() + -- ========================================================================= + -- Bot commands (default scope) + -- ========================================================================= + describe('bot commands (default scope)', function() it('set_my_commands succeeds', function() local commands = { api.bot_command('start', 'Start the bot'), @@ -120,31 +131,125 @@ describe('e2e', function() end) end) - describe('bot profile', function() - local original_description - local original_short_description + -- ========================================================================= + -- Bot commands (with scope and language_code) + -- ========================================================================= + describe('bot commands (scoped)', function() + it('set_my_commands with all_private_chats scope', function() + local commands = { + api.bot_command('settings', 'Bot settings'), + } + local result = api.set_my_commands(commands, { + scope = { type = 'all_private_chats' } + }) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_my_commands with all_private_chats scope', function() + local result = api.get_my_commands({ + scope = { type = 'all_private_chats' } + }) + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.equals(1, #result.result) + assert.equals('settings', result.result[1].command) + end) + + it('set_my_commands with language_code', function() + local commands = { + api.bot_command('inicio', 'Iniciar el bot'), + } + local result = api.set_my_commands(commands, { + language_code = 'es' + }) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_my_commands with language_code', function() + local result = api.get_my_commands({ language_code = 'es' }) + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.equals(1, #result.result) + assert.equals('inicio', result.result[1].command) + end) + + it('cleanup: delete scoped commands', function() + local r1 = api.delete_my_commands({ scope = { type = 'all_private_chats' } }) + assert.is_true(r1.ok) + local r2 = api.delete_my_commands({ language_code = 'es' }) + assert.is_true(r2.ok) + end) + end) + + -- ========================================================================= + -- Bot name + -- ========================================================================= + describe('bot name', function() + local original_name it('get_my_name returns name', function() local result = api.get_my_name() assert.is_table(result) assert.is_true(result.ok) assert.is_table(result.result) + original_name = result.result.name or '' end) - it('get_my_description returns description', function() - local result = api.get_my_description() + it('set_my_name succeeds', function() + local result = api.set_my_name('E2E Test Bot Name') assert.is_table(result) assert.is_true(result.ok) - assert.is_table(result.result) - original_description = result.result.description or '' end) - it('get_my_short_description returns short description', function() - local result = api.get_my_short_description() + it('get_my_name confirms change', function() + local result = api.get_my_name() + assert.is_table(result) + assert.is_true(result.ok) + assert.equals('E2E Test Bot Name', result.result.name) + end) + + it('set_my_name with language_code', function() + local result = api.set_my_name('Bot de prueba', { language_code = 'es' }) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_my_name with language_code', function() + local result = api.get_my_name({ language_code = 'es' }) + assert.is_table(result) + assert.is_true(result.ok) + assert.equals('Bot de prueba', result.result.name) + end) + + it('cleanup: reset name for es', function() + local result = api.set_my_name('', { language_code = 'es' }) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('restores original name', function() + local result = api.set_my_name(original_name) + assert.is_table(result) + assert.is_true(result.ok) + end) + end) + + -- ========================================================================= + -- Bot description + -- ========================================================================= + describe('bot description', function() + local original_description + + it('get_my_description returns description', function() + local result = api.get_my_description() assert.is_table(result) assert.is_true(result.ok) assert.is_table(result.result) - original_short_description = result.result.short_description or '' + original_description = result.result.description or '' end) it('set_my_description succeeds', function() @@ -165,6 +270,21 @@ describe('e2e', function() assert.is_table(result) assert.is_true(result.ok) end) + end) + + -- ========================================================================= + -- Bot short description + -- ========================================================================= + describe('bot short description', function() + local original_short_description + + it('get_my_short_description returns short description', function() + local result = api.get_my_short_description() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + original_short_description = result.result.short_description or '' + end) it('set_my_short_description succeeds', function() local result = api.set_my_short_description('E2E short desc') @@ -172,6 +292,13 @@ describe('e2e', function() assert.is_true(result.ok) end) + it('get_my_short_description confirms change', function() + local result = api.get_my_short_description() + assert.is_table(result) + assert.is_true(result.ok) + assert.equals('E2E short desc', result.result.short_description) + end) + it('restores original short description', function() local result = api.set_my_short_description(original_short_description) assert.is_table(result) @@ -179,6 +306,108 @@ describe('e2e', function() end) end) + -- ========================================================================= + -- Default administrator rights + -- ========================================================================= + describe('default administrator rights', function() + local original_rights + + it('get_my_default_administrator_rights returns rights', function() + local result = api.get_my_default_administrator_rights() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + original_rights = result.result + end) + + it('set_my_default_administrator_rights succeeds', function() + local result = api.set_my_default_administrator_rights({ + rights = { + can_manage_chat = true, + can_delete_messages = true, + can_manage_video_chats = false, + can_restrict_members = false, + can_promote_members = false, + can_change_info = true, + can_invite_users = true, + can_post_stories = false, + can_edit_stories = false, + can_delete_stories = false, + can_pin_messages = true, + can_manage_topics = false, + is_anonymous = false + } + }) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_my_default_administrator_rights confirms change', function() + local result = api.get_my_default_administrator_rights() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_true(result.result.can_manage_chat) + assert.is_true(result.result.can_delete_messages) + assert.is_true(result.result.can_change_info) + assert.is_true(result.result.can_invite_users) + assert.is_true(result.result.can_pin_messages) + end) + + it('get_my_default_administrator_rights for channels', function() + local result = api.get_my_default_administrator_rights({ for_channels = true }) + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + + it('restores original rights', function() + local result = api.set_my_default_administrator_rights({ + rights = original_rights + }) + assert.is_table(result) + assert.is_true(result.ok) + end) + end) + + -- ========================================================================= + -- Chat menu button + -- ========================================================================= + describe('chat menu button', function() + it('get_chat_menu_button returns default', function() + local result = api.get_chat_menu_button() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.is_string(result.result.type) + end) + + it('set_chat_menu_button to commands type', function() + local result = api.set_chat_menu_button({ + menu_button = { type = 'commands' } + }) + assert.is_table(result) + assert.is_true(result.ok) + end) + + it('get_chat_menu_button confirms commands type', function() + local result = api.get_chat_menu_button() + assert.is_table(result) + assert.is_true(result.ok) + assert.equals('commands', result.result.type) + end) + + it('set_chat_menu_button to default type', function() + local result = api.set_chat_menu_button({ + menu_button = { type = 'default' } + }) + assert.is_table(result) + assert.is_true(result.ok) + end) + end) + + -- ========================================================================= + -- Webhook management + -- ========================================================================= describe('webhook management', function() it('delete_webhook succeeds', function() local result = api.delete_webhook() @@ -209,28 +438,22 @@ describe('e2e', function() end) it('cleans up webhook', function() - local result = api.delete_webhook() + local result = api.delete_webhook({ drop_pending_updates = true }) assert.is_table(result) assert.is_true(result.ok) end) - end) - describe('error handling', function() - it('returns error for invalid chat_id', function() - local result, err = api.send_message(0, 'test') - assert.is_false(result) - assert.is_table(err) - assert.is_number(err.error_code) - end) - - it('returns error for invalid file_id', function() - local result, err = api.get_file('invalid_file_id_that_does_not_exist') - assert.is_false(result) - assert.is_table(err) - assert.is_number(err.error_code) + it('get_webhook_info confirms cleanup', function() + local result = api.get_webhook_info() + assert.is_table(result) + assert.is_true(result.ok) + assert.equals('', result.result.url) end) end) + -- ========================================================================= + -- Updates + -- ========================================================================= describe('get_updates', function() it('returns updates array', function() local result = api.get_updates({ timeout = 0, limit = 1 }) @@ -238,32 +461,488 @@ describe('e2e', function() assert.is_true(result.ok) assert.is_table(result.result) end) + + it('returns updates with allowed_updates filter', function() + local result = api.get_updates({ + timeout = 0, + limit = 1, + allowed_updates = { 'message' } + }) + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) end) - describe('menu button', function() - it('get_chat_menu_button returns default', function() - local result = api.get_chat_menu_button() + -- ========================================================================= + -- Star transactions + -- ========================================================================= + describe('star transactions', function() + it('get_star_transactions returns transactions', function() + local result = api.get_star_transactions() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + + it('get_star_transactions with opts', function() + local result = api.get_star_transactions({ offset = 0, limit = 10 }) assert.is_table(result) assert.is_true(result.ok) assert.is_table(result.result) end) end) - describe('default administrator rights', function() - it('get_my_default_administrator_rights returns rights', function() - local result = api.get_my_default_administrator_rights() + -- ========================================================================= + -- Forum topic icon stickers + -- ========================================================================= + describe('forum topic icon stickers', function() + it('get_forum_topic_icon_stickers returns sticker list', function() + local result = api.get_forum_topic_icon_stickers() assert.is_table(result) assert.is_true(result.ok) assert.is_table(result.result) + -- Should return a non-empty list of stickers + assert.is_true(#result.result > 0) + -- Each sticker should have basic fields + local sticker = result.result[1] + assert.is_string(sticker.file_id) + assert.is_string(sticker.file_unique_id) + assert.is_string(sticker.type) end) end) - describe('star transactions', function() - it('get_star_transactions returns transactions', function() - local result = api.get_star_transactions() + -- ========================================================================= + -- User profile photos (using bot's own ID) + -- ========================================================================= + describe('user profile photos', function() + it('get_user_profile_photos returns photos', function() + if not bot_id then pending('no bot_id') end + local result = api.get_user_profile_photos(bot_id) assert.is_table(result) assert.is_true(result.ok) assert.is_table(result.result) + assert.is_number(result.result.total_count) + assert.is_table(result.result.photos) + end) + + it('get_user_profile_photos with offset and limit', function() + if not bot_id then pending('no bot_id') end + local result = api.get_user_profile_photos(bot_id, { offset = 0, limit = 1 }) + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + assert.is_number(result.result.total_count) + end) + end) + + -- ========================================================================= + -- Available gifts + -- ========================================================================= + describe('available gifts', function() + it('get_available_gifts returns gift list', function() + local result = api.get_available_gifts() + assert.is_table(result) + assert.is_true(result.ok) + assert.is_table(result.result) + end) + end) + + -- ========================================================================= + -- Sticker sets (using known public sets) + -- ========================================================================= + describe('sticker sets', function() + it('get_sticker_set returns set info for known set', function() + local result = api.get_sticker_set('AnimatedEmojies') + if result and result.ok then + assert.is_table(result.result) + assert.equals('AnimatedEmojies', result.result.name) + assert.is_string(result.result.title) + assert.is_table(result.result.stickers) + assert.is_true(#result.result.stickers > 0) + else + -- Set may not exist, that's fine - verify error structure + assert.is_false(result) + end + end) + + it('get_sticker_set returns error for invalid name', function() + local result, err = api.get_sticker_set('totally_nonexistent_sticker_set_12345') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + end) + + -- ========================================================================= + -- Create invoice link (Telegram Stars / XTR currency) + -- ========================================================================= + describe('create invoice link', function() + it('create_invoice_link returns a URL', function() + local prices = {{ label = 'Test Item', amount = 1 }} + local result = api.create_invoice_link( + 'E2E Test Invoice', + 'Test description for e2e', + 'e2e_test_payload_' .. os.time(), + 'XTR', + prices + ) + if result and result.ok then + assert.is_string(result.result) + -- Telegram invoice links start with https:// + assert.truthy(result.result:match('^https://')) + else + -- If bot doesn't have payments enabled, verify error structure + assert.is_false(result) + end + end) + end) + + -- ========================================================================= + -- Error handling + -- ========================================================================= + describe('error handling', function() + it('send_message returns error for invalid chat_id', function() + local result, err = api.send_message(0, 'test') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('get_file returns error for invalid file_id', function() + local result, err = api.get_file('invalid_file_id_that_does_not_exist') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('get_chat returns error for invalid chat_id', function() + local result, err = api.get_chat(0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('get_chat_member returns error for invalid params', function() + local result, err = api.get_chat_member(0, 0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_photo returns error for invalid chat_id', function() + local result, err = api.send_photo(0, 'invalid_photo') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_video returns error for invalid chat_id', function() + local result, err = api.send_video(0, 'invalid_video') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_audio returns error for invalid chat_id', function() + local result, err = api.send_audio(0, 'invalid_audio') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_document returns error for invalid chat_id', function() + local result, err = api.send_document(0, 'invalid_doc') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_animation returns error for invalid chat_id', function() + local result, err = api.send_animation(0, 'invalid_anim') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_voice returns error for invalid chat_id', function() + local result, err = api.send_voice(0, 'invalid_voice') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_video_note returns error for invalid chat_id', function() + local result, err = api.send_video_note(0, 'invalid_videonote') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_sticker returns error for invalid chat_id', function() + local result, err = api.send_sticker(0, 'invalid_sticker') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_location returns error for invalid chat_id', function() + local result, err = api.send_location(0, 51.5074, -0.1278) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_venue returns error for invalid chat_id', function() + local result, err = api.send_venue(0, 51.5074, -0.1278, 'Test Venue', '123 Test St') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_contact returns error for invalid chat_id', function() + local result, err = api.send_contact(0, '+1234567890', 'Test') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_poll returns error for invalid chat_id', function() + local options = { + { text = 'Option A' }, + { text = 'Option B' } + } + local result, err = api.send_poll(0, 'Test question?', options) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_dice returns error for invalid chat_id', function() + local result, err = api.send_dice(0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_chat_action returns error for invalid chat_id', function() + local result, err = api.send_chat_action(0, 'typing') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('forward_message returns error for invalid params', function() + local result, err = api.forward_message(0, 0, 0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('copy_message returns error for invalid params', function() + local result, err = api.copy_message(0, 0, 0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('edit_message_text returns error for invalid params', function() + local result, err = api.edit_message_text(0, 0, 'test') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('delete_message returns error for invalid params', function() + local result, err = api.delete_message(0, 0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('ban_chat_member returns error for invalid params', function() + local result, err = api.ban_chat_member(0, 0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('pin_chat_message returns error for invalid params', function() + local result, err = api.pin_chat_message(0, 0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('create_forum_topic returns error for invalid chat', function() + local result, err = api.create_forum_topic(0, 'Test Topic') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_invoice returns error for invalid chat_id', function() + local prices = {{ label = 'Item', amount = 100 }} + local result, err = api.send_invoice(0, 'Title', 'Desc', 'payload', 'XTR', prices) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('get_user_gifts returns error for invalid user', function() + local result, err = api.get_user_gifts(0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('answer_inline_query returns error for invalid query_id', function() + local result, err = api.answer_inline_query('invalid_id', {}) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('answer_callback_query returns error for invalid query_id', function() + local result, err = api.answer_callback_query('invalid_id') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('leave_chat returns error for invalid chat_id', function() + local result, err = api.leave_chat(0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('export_chat_invite_link returns error for invalid chat', function() + local result, err = api.export_chat_invite_link(0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('get_chat_member_count returns error for invalid chat', function() + local result, err = api.get_chat_member_count(0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('set_chat_title returns error for invalid chat', function() + local result, err = api.set_chat_title(0, 'Test') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('set_chat_description returns error for invalid chat', function() + local result, err = api.set_chat_description(0, 'Test') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('get_chat_administrators returns error for invalid chat', function() + local result, err = api.get_chat_administrators(0) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('set_chat_permissions returns error for invalid chat', function() + local result, err = api.set_chat_permissions(0, { can_send_messages = true }) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('send_game returns error for invalid chat_id', function() + local result, err = api.send_game(0, 'testgame') + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + + it('set_passport_data_errors returns error for invalid user', function() + local result, err = api.set_passport_data_errors(0, {}) + assert.is_false(result) + assert.is_table(err) + assert.is_number(err.error_code) + end) + end) + + -- ========================================================================= + -- Custom emoji stickers + -- ========================================================================= + describe('custom emoji stickers', function() + it('get_custom_emoji_stickers returns error for invalid IDs', function() + local result, err = api.get_custom_emoji_stickers({'invalid_emoji_id'}) + -- Should either succeed with empty array or fail gracefully + if result then + assert.is_table(result) + assert.is_true(result.ok) + else + assert.is_false(result) + assert.is_table(err) + end + end) + end) + + -- ========================================================================= + -- Helpers (no-network, validate logic only) + -- ========================================================================= + describe('helpers', function() + it('send_reply returns false for invalid message', function() + local result = api.send_reply(nil, 'test') + assert.is_false(result) + end) + + it('send_reply returns false for message missing chat', function() + local result = api.send_reply({ message_id = 1 }, 'test') + assert.is_false(result) + end) + + it('send_reply returns false for message missing message_id', function() + local result = api.send_reply({ chat = { id = 1 } }, 'test') + assert.is_false(result) + end) + end) + + -- ========================================================================= + -- Builder utilities (no-network, validate constructors) + -- ========================================================================= + describe('builders', function() + it('bot_command builds correct structure', function() + local cmd = api.bot_command('test', 'Test command') + assert.is_table(cmd) + assert.equals('test', cmd.command) + assert.equals('Test command', cmd.description) + end) + + it('keyboard builder works', function() + local kb = api.keyboard(true, false, false) + assert.is_table(kb) + assert.is_table(kb.keyboard) + assert.is_true(kb.resize_keyboard) + end) + + it('inline_keyboard builder works', function() + local ikb = api.inline_keyboard() + assert.is_table(ikb) + assert.is_table(ikb.inline_keyboard) + end) + + it('row builder supports chaining', function() + local r = api.row() + assert.is_table(r) + local chained = r:callback_data_button('btn', 'data') + assert.equals(r, chained) + assert.equals(1, #r) + assert.equals('btn', r[1].text) + assert.equals('data', r[1].callback_data) end) end) end)