Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
121 KB
Referenced Files
None
Subscribers
None
diff --git a/src/core/database.lua b/src/core/database.lua
index e357678..56a3ae8 100644
--- a/src/core/database.lua
+++ b/src/core/database.lua
@@ -1,409 +1,409 @@
--[[
mattata v2.1 - PostgreSQL Database Module
Uses pgmoon for async-compatible PostgreSQL connections.
Implements connection pooling with copas semaphore guards,
automatic reconnection, and transaction helpers.
]]
local database = {}
local pgmoon = require('pgmoon')
local config = require('src.core.config')
local logger = require('src.core.logger')
local copas_sem = require('copas.semaphore')
local pool = {}
local pool_size = 10
local pool_timeout = 30000
local pool_semaphore = nil
local db_config = nil
-- Initialise pool configuration
local function get_config()
if not db_config then
db_config = config.database()
end
return db_config
end
-- Create a new pgmoon connection
local function create_connection()
local cfg = get_config()
local pg = pgmoon.new({
host = cfg.host,
port = cfg.port,
database = cfg.database,
user = cfg.user,
password = cfg.password
})
local ok, err = pg:connect()
if not ok then
return nil, err
end
pg:settimeout(pool_timeout)
return pg
end
function database.connect()
local cfg = get_config()
pool_size = config.get_number('DATABASE_POOL_SIZE', 10)
pool_timeout = config.get_number('DATABASE_TIMEOUT', 30000)
-- Create initial connection to validate credentials
local pg, err = create_connection()
if not pg then
logger.error('Failed to connect to PostgreSQL: %s', tostring(err))
return false, err
end
table.insert(pool, pg)
-- Create semaphore to guard concurrent pool access
-- max = pool_size, start = pool_size (all permits available), timeout = 30s
pool_semaphore = copas_sem.new(pool_size, pool_size, 30)
logger.info('Connected to PostgreSQL at %s:%d/%s (pool size: %d)', cfg.host, cfg.port, cfg.database, pool_size)
return true
end
-- Acquire a connection from the pool
function database.acquire()
-- Take a semaphore permit (blocks coroutine if pool exhausted, 30s timeout)
if pool_semaphore then
local ok, err = pool_semaphore:take(1, 30)
if not ok then
logger.error('Failed to acquire pool permit: %s', tostring(err))
return nil, 'Pool exhausted (semaphore timeout)'
end
end
if #pool > 0 then
return table.remove(pool)
end
-- Pool exhausted — create a new connection
local pg, err = create_connection()
if not pg then
logger.error('Failed to create new connection: %s', tostring(err))
-- Return the permit since we failed to use it
if pool_semaphore then pool_semaphore:give(1) end
return nil, err
end
return pg
end
-- Release a connection back to the pool
function database.release(pg)
if not pg then return end
if #pool < pool_size then
table.insert(pool, pg)
else
pcall(function() pg:disconnect() end)
end
-- Return the semaphore permit
if pool_semaphore then pool_semaphore:give(1) end
end
-- Execute a raw SQL query with automatic connection management
function database.query(sql, ...)
local pg, err = database.acquire()
if not pg then
logger.error('Database not connected')
return nil, 'Database not connected'
end
local result, query_err, _, _ = pg:query(sql)
if not result then
-- Check for connection loss and attempt reconnect
if query_err and (query_err:match('closed') or query_err:match('broken') or query_err:match('timeout')) then
logger.warn('Connection lost, attempting reconnect...')
pcall(function() pg:disconnect() end)
-- Release the dead connection's permit before reconnect
if pool_semaphore then pool_semaphore:give(1) end
pg, err = create_connection()
if pg then
-- Re-acquire a permit for the new connection
if pool_semaphore then
local ok, sem_err = pool_semaphore:take(1, 30)
if not ok then
pcall(function() pg:disconnect() end)
logger.error('Reconnect semaphore acquire failed: %s', tostring(sem_err))
return nil, 'Pool exhausted during reconnect'
end
end
result, query_err = pg:query(sql)
if result then
database.release(pg)
return result
end
database.release(pg)
end
logger.error('Reconnect failed for query: %s', tostring(query_err or err))
return nil, query_err or err
end
logger.error('Query failed: %s\nSQL: %s', tostring(query_err), sql)
database.release(pg)
return nil, query_err
end
database.release(pg)
return result
end
-- Execute a parameterized query (manually escape values)
function database.execute(sql, params)
local pg, _ = database.acquire()
if not pg then
return nil, 'Database not connected'
end
if params then
local escaped = {}
for i, v in ipairs(params) do
if v == nil then
escaped[i] = 'NULL'
elseif type(v) == 'number' then
escaped[i] = tostring(v)
elseif type(v) == 'boolean' then
escaped[i] = v and 'TRUE' or 'FALSE'
else
escaped[i] = pg:escape_literal(tostring(v))
end
end
-- Replace $1, $2, etc. with escaped values
sql = sql:gsub('%$(%d+)', function(n)
return escaped[tonumber(n)] or '$' .. n
end)
end
local result, query_err = pg:query(sql)
if not result then
-- Attempt reconnect on connection failure
if query_err and (query_err:match('closed') or query_err:match('broken') or query_err:match('timeout')) then
logger.warn('Connection lost during execute, reconnecting...')
pcall(function() pg:disconnect() end)
-- Release the dead connection's permit before reconnect
if pool_semaphore then pool_semaphore:give(1) end
local new_pg
- new_pg, _ = create_connection()
+ new_pg = create_connection()
if new_pg then
-- Re-acquire a permit for the new connection
if pool_semaphore then
local ok, sem_err = pool_semaphore:take(1, 30)
if not ok then
pcall(function() new_pg:disconnect() end)
logger.error('Reconnect semaphore acquire failed: %s', tostring(sem_err))
return nil, 'Pool exhausted during reconnect'
end
end
result, query_err = new_pg:query(sql)
if result then
database.release(new_pg)
return result
end
database.release(new_pg)
end
else
database.release(pg)
end
logger.error('Query failed: %s\nSQL: %s', tostring(query_err), sql)
return nil, query_err
end
database.release(pg)
return result
end
-- Run a function inside a transaction (BEGIN / COMMIT / ROLLBACK)
function database.transaction(fn)
local pg, _ = database.acquire()
if not pg then
return nil, 'Database not connected'
end
local ok, begin_err = pg:query('BEGIN')
if not ok then
database.release(pg)
return nil, begin_err
end
-- Build a scoped query function for this connection
local function scoped_query(sql)
return pg:query(sql)
end
local function scoped_execute(sql, params)
if params then
local escaped = {}
for i, v in ipairs(params) do
if v == nil then
escaped[i] = 'NULL'
elseif type(v) == 'number' then
escaped[i] = tostring(v)
elseif type(v) == 'boolean' then
escaped[i] = v and 'TRUE' or 'FALSE'
else
escaped[i] = pg:escape_literal(tostring(v))
end
end
sql = sql:gsub('%$(%d+)', function(n)
return escaped[tonumber(n)] or '$' .. n
end)
end
return pg:query(sql)
end
local success, result = pcall(fn, scoped_query, scoped_execute)
if success then
pg:query('COMMIT')
database.release(pg)
return result
else
pg:query('ROLLBACK')
database.release(pg)
logger.error('Transaction failed: %s', tostring(result))
return nil, result
end
end
-- Convenience: insert and return the row
function database.insert(table_name, data)
local columns = {}
local values = {}
local params = {}
local i = 1
for k, v in pairs(data) do
table.insert(columns, k)
table.insert(values, '$' .. i)
table.insert(params, v)
i = i + 1
end
local sql = string.format(
'INSERT INTO %s (%s) VALUES (%s) RETURNING *',
table_name,
table.concat(columns, ', '),
table.concat(values, ', ')
)
return database.execute(sql, params)
end
-- Convenience: upsert (INSERT ON CONFLICT UPDATE)
function database.upsert(table_name, data, conflict_keys, update_keys)
local columns = {}
local values = {}
local params = {}
local i = 1
for k, v in pairs(data) do
table.insert(columns, k)
table.insert(values, '$' .. i)
table.insert(params, v)
i = i + 1
end
local updates = {}
for _, k in ipairs(update_keys) do
table.insert(updates, k .. ' = EXCLUDED.' .. k)
end
local sql = string.format(
'INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s RETURNING *',
table_name,
table.concat(columns, ', '),
table.concat(values, ', '),
table.concat(conflict_keys, ', '),
table.concat(updates, ', ')
)
return database.execute(sql, params)
end
-- call a stored procedure: SELECT * FROM func_name(arg1, arg2, ...)
-- func_name is validated to contain only safe characters (alphanumeric + underscore)
-- nil values are inlined as NULL; non-nil values are escaped inline
function database.call(func_name, params, nparams)
if not func_name:match('^[%w_]+$') then
logger.error('Invalid stored procedure name: %s', func_name)
return nil, 'Invalid stored procedure name'
end
params = params or {}
nparams = nparams or params.n or #params
local pg, acquire_err = database.acquire()
if not pg then
return nil, acquire_err or 'Database not connected'
end
local args = {}
for i = 1, nparams do
local v = params[i]
if v == nil then
args[i] = 'NULL'
elseif type(v) == 'number' then
args[i] = tostring(v)
elseif type(v) == 'boolean' then
args[i] = v and 'TRUE' or 'FALSE'
else
args[i] = pg:escape_literal(tostring(v))
end
end
local sql = string.format(
'SELECT * FROM %s(%s)',
func_name,
table.concat(args, ', ')
)
local result, query_err = pg:query(sql)
if not result then
-- Attempt reconnect on connection failure
if query_err and (query_err:match('closed') or query_err:match('broken') or query_err:match('timeout')) then
logger.warn('Connection lost during call to %s, reconnecting...', func_name)
pcall(function() pg:disconnect() end)
if pool_semaphore then pool_semaphore:give(1) end
local new_pg
- new_pg, _ = create_connection()
+ new_pg = create_connection()
if new_pg then
if pool_semaphore then
local ok, sem_err = pool_semaphore:take(1, 30)
if not ok then
pcall(function() new_pg:disconnect() end)
logger.error('Reconnect semaphore acquire failed: %s', tostring(sem_err))
return nil, 'Pool exhausted during reconnect'
end
end
-- Re-escape params with new connection
local new_args = {}
for i = 1, nparams do
local v = params[i]
if v == nil then
new_args[i] = 'NULL'
elseif type(v) == 'number' then
new_args[i] = tostring(v)
elseif type(v) == 'boolean' then
new_args[i] = v and 'TRUE' or 'FALSE'
else
new_args[i] = new_pg:escape_literal(tostring(v))
end
end
local new_sql = string.format('SELECT * FROM %s(%s)', func_name, table.concat(new_args, ', '))
result, query_err = new_pg:query(new_sql)
if result then
database.release(new_pg)
return result
end
database.release(new_pg)
end
else
database.release(pg)
end
logger.error('Query failed: %s\nSQL: %s', tostring(query_err), sql)
return nil, query_err
end
database.release(pg)
return result
end
-- get the raw pgmoon connection for advanced usage
function database.connection()
return database.acquire()
end
-- Get current pool stats
function database.pool_stats()
return {
available = #pool,
max_size = pool_size
}
end
function database.disconnect()
for _, pg in ipairs(pool) do
pcall(function() pg:disconnect() end)
end
pool = {}
pool_semaphore = nil
logger.info('Disconnected from PostgreSQL (pool drained)')
end
return database
diff --git a/src/core/loader.lua b/src/core/loader.lua
index a29f13a..a674f7b 100644
--- a/src/core/loader.lua
+++ b/src/core/loader.lua
@@ -1,196 +1,195 @@
--[[
mattata v2.0 - Plugin Loader
Discovers, validates, and manages plugins from category directories.
Supports hot-reload and per-chat enable/disable.
]]
local loader = {}
local logger = require('src.core.logger')
local plugins = {} -- ordered list of all loaded plugins
local by_command = {} -- command -> plugin lookup
local by_name = {} -- name -> plugin lookup
local categories = {} -- category -> list of plugin names
local by_event = {} -- event_name -> list of plugins with that handler
-local PERMANENT_PLUGINS = { 'help', 'about', 'plugins' }
local PERMANENT_SET = { help = true, about = true, plugins = true }
-- Event handler names to index for fast dispatch
local INDEXED_EVENTS = {
'on_new_message', 'on_member_join', 'on_callback_query', 'on_inline_query',
'on_chat_join_request', 'on_chat_member_update', 'on_my_chat_member',
'on_reaction', 'on_reaction_count', 'on_chat_boost', 'on_removed_chat_boost',
'on_poll', 'on_poll_answer', 'cron'
}
local CATEGORIES = { 'admin', 'utility', 'fun', 'media', 'ai' }
-- Build event index from current plugin list
local function rebuild_event_index()
by_event = {}
for _, event in ipairs(INDEXED_EVENTS) do
by_event[event] = {}
end
for _, plugin in ipairs(plugins) do
for _, event in ipairs(INDEXED_EVENTS) do
if plugin[event] then
table.insert(by_event[event], plugin)
end
end
end
end
function loader.init(_, _, _)
plugins = {}
by_command = {}
by_name = {}
categories = {}
by_event = {}
for _, category in ipairs(CATEGORIES) do
categories[category] = {}
local manifest_path = 'src.plugins.' .. category .. '.init'
local ok, manifest = pcall(require, manifest_path)
if ok and type(manifest) == 'table' and manifest.plugins then
for _, plugin_name in ipairs(manifest.plugins) do
local plugin_path = 'src.plugins.' .. category .. '.' .. plugin_name
local load_ok, plugin = pcall(require, plugin_path)
if load_ok and type(plugin) == 'table' then
plugin.name = plugin.name or plugin_name
plugin.category = plugin.category or category
plugin.commands = plugin.commands or {}
plugin.help = plugin.help or ''
plugin.description = plugin.description or ''
table.insert(plugins, plugin)
by_name[plugin.name] = plugin
table.insert(categories[category], plugin.name)
-- Index commands for fast lookup
for _, cmd in ipairs(plugin.commands) do
by_command[cmd:lower()] = plugin
end
logger.debug('Loaded plugin: %s/%s (%d commands)', category, plugin.name, #plugin.commands)
else
logger.warn('Failed to load plugin %s/%s: %s', category, plugin_name, tostring(plugin))
end
end
else
logger.debug('No manifest for category: %s (%s)', category, tostring(manifest))
end
end
rebuild_event_index()
logger.info('Loaded %d plugins across %d categories', #plugins, #CATEGORIES)
end
-- Get all loaded plugins (ordered)
function loader.get_plugins()
return plugins
end
-- Look up a plugin by command name
function loader.get_by_command(command)
return by_command[command:lower()]
end
-- Look up a plugin by name
function loader.get_by_name(name)
return by_name[name]
end
-- Get all plugins in a category
function loader.get_category(category)
local result = {}
for _, name in ipairs(categories[category] or {}) do
table.insert(result, by_name[name])
end
return result
end
-- Count loaded plugins
function loader.count()
return #plugins
end
-- Check if a plugin is permanent (cannot be disabled)
function loader.is_permanent(name)
return PERMANENT_SET[name] or false
end
-- Get plugins that implement a specific event handler
function loader.get_by_event(event_name)
return by_event[event_name] or {}
end
-- Hot-reload a plugin by name
function loader.reload(name)
local plugin = by_name[name]
if not plugin then
return false, 'Plugin not found: ' .. name
end
local path = 'src.plugins.' .. plugin.category .. '.' .. name
package.loaded[path] = nil
local ok, new_plugin = pcall(require, path)
if not ok then
return false, 'Reload failed: ' .. tostring(new_plugin)
end
-- Preserve metadata
new_plugin.name = name
new_plugin.category = plugin.category
new_plugin.commands = new_plugin.commands or {}
-- Replace in ordered list
for i, p in ipairs(plugins) do
if p.name == name then
plugins[i] = new_plugin
break
end
end
-- Re-index commands (remove old, add new)
for cmd, p in pairs(by_command) do
if p.name == name then
by_command[cmd] = nil
end
end
for _, cmd in ipairs(new_plugin.commands) do
by_command[cmd:lower()] = new_plugin
end
by_name[name] = new_plugin
rebuild_event_index()
logger.info('Hot-reloaded plugin: %s', name)
return true
end
-- Get help text for all plugins or a specific category
function loader.get_help(category, chat_id)
local help = {}
local source = category and loader.get_category(category) or plugins
for _, plugin in ipairs(source) do
if plugin.help and plugin.help ~= '' then
table.insert(help, {
name = plugin.name,
category = plugin.category,
commands = plugin.commands,
help = plugin.help,
description = plugin.description
})
end
end
return help
end
-- Get list of categories
function loader.get_categories()
return CATEGORIES
end
return loader
diff --git a/src/core/router.lua b/src/core/router.lua
index 1843bdf..cb80f5f 100644
--- a/src/core/router.lua
+++ b/src/core/router.lua
@@ -1,678 +1,622 @@
--[[
mattata v2.1 - Event Router
Dispatches Telegram updates through middleware pipeline to plugins.
Uses copas coroutines via telegram-bot-lua's async system for concurrent
update processing — each update runs in its own coroutine.
]]
local router = {}
local copas = require('copas')
local config = require('src.core.config')
local logger = require('src.core.logger')
local middleware_pipeline = require('src.core.middleware')
local session = require('src.core.session')
local permissions = require('src.core.permissions')
local i18n = require('src.core.i18n')
local tools
local api, loader, ctx_base
-- Import middleware modules
local mw_blocklist = require('src.middleware.blocklist')
local mw_rate_limit = require('src.middleware.rate_limit')
local mw_user_tracker = require('src.middleware.user_tracker')
local mw_language = require('src.middleware.language')
local mw_federation = require('src.middleware.federation')
local mw_captcha = require('src.middleware.captcha')
local mw_stats = require('src.middleware.stats')
function router.init(api_ref, tools_ref, loader_ref, ctx_base_ref)
api = api_ref
tools = tools_ref
loader = loader_ref
ctx_base = ctx_base_ref
-- Register middleware in order
middleware_pipeline.use(mw_blocklist)
middleware_pipeline.use(mw_rate_limit)
middleware_pipeline.use(mw_federation)
middleware_pipeline.use(mw_captcha)
middleware_pipeline.use(mw_user_tracker)
middleware_pipeline.use(mw_language)
middleware_pipeline.use(mw_stats)
end
-- Build a fresh context for each update
-- Uses metatable __index to inherit ctx_base without copying.
-- Admin check is lazy — only resolved when ctx:check_admin() is called.
local function build_ctx(message)
local ctx = setmetatable({}, { __index = ctx_base })
ctx.is_group = message.chat and message.chat.type ~= 'private'
ctx.is_supergroup = message.chat and message.chat.type == 'supergroup'
ctx.is_private = message.chat and message.chat.type == 'private'
ctx.is_global_admin = message.from and permissions.is_global_admin(message.from.id) or false
-- Lazy admin check: only makes API call when first accessed
-- Caches result for the lifetime of this context
local admin_resolved = false
local admin_value = false
ctx.is_admin = false -- default for non-admin reads
function ctx:check_admin()
if admin_resolved then
return admin_value
end
admin_resolved = true
if ctx.is_global_admin then
admin_value = true
elseif ctx.is_group and message.from then
admin_value = permissions.is_group_admin(api, message.chat.id, message.from.id)
end
ctx.is_admin = admin_value
return admin_value
end
ctx.is_mod = false
return ctx
end
-- Generic event dispatcher: iterates pre-indexed plugins for a given event
local function dispatch_event(event_name, update, ctx)
for _, plugin in ipairs(loader.get_by_event(event_name)) do
local ok, err = pcall(plugin[event_name], api, update, ctx)
if not ok then
logger.error('Plugin %s.%s error: %s', plugin.name, event_name, tostring(err))
end
end
end
-- Sort/normalise a message object (ported from v1 mattata.sort_message)
local function sort_message(message)
message.text = message.text or message.caption or ''
-- Normalise /command_arg to /command arg
message.text = message.text:gsub('^(/[%a]+)_', '%1 ')
-- Deep-link support
if message.text:match('^[/!#]start .-$') then
message.text = '/' .. message.text:match('^[/!#]start (.-)$')
end
-- Shorthand reply alias
if message.reply_to_message then
message.reply = message.reply_to_message
message.reply_to_message = nil
end
-- Normalise language code
if message.from and message.from.language_code then
local lc = message.from.language_code:lower():gsub('%-', '_')
if #lc == 2 and lc ~= 'en' then
lc = lc .. '_' .. lc
elseif #lc == 2 or lc == 'root' then
lc = 'en_us'
end
message.from.language_code = lc
end
-- Detect media
message.is_media = message.photo or message.video or message.audio or message.voice
or message.document or message.sticker or message.animation or message.video_note or false
-- Detect service messages
message.is_service_message = (message.new_chat_members or message.left_chat_member
or message.new_chat_title or message.new_chat_photo or message.pinned_message
or message.group_chat_created or message.supergroup_chat_created
or message.forum_topic_created or message.forum_topic_closed
or message.forum_topic_reopened or message.forum_topic_edited
or message.video_chat_started or message.video_chat_ended
or message.video_chat_participants_invited
or message.message_auto_delete_timer_changed
or message.write_access_allowed) and true or false
-- Detect forum topics
message.is_topic = message.is_topic_message or false
message.thread_id = message.message_thread_id
-- Entity-based text mentions -> ID substitution
if message.entities then
for _, entity in ipairs(message.entities) do
if entity.type == 'text_mention' and entity.user then
local name = message.text:sub(entity.offset + 1, entity.offset + entity.length)
-- Escape Lua pattern special characters in the display name
local escaped = name:gsub('([%(%)%.%%%+%-%*%?%[%^%$%]])', '%%%1')
message.text = message.text:gsub(escaped, tostring(entity.user.id), 1)
end
end
end
-- Process caption entities as entities
if message.caption_entities then
message.entities = message.caption_entities
message.caption_entities = nil
end
-- Sort reply recursively
if message.reply then
message.reply = sort_message(message.reply)
end
return message
end
-- Extract command from message text
local function extract_command(text, bot_username)
if not text then return nil, nil end
local cmd, args = text:match('^[/!#]([%w_]+)@?' .. (bot_username or '') .. '%s*(.*)')
if not cmd then
cmd, args = text:match('^[/!#]([%w_]+)%s*(.*)')
end
if cmd then
cmd = cmd:lower()
args = args ~= '' and args or nil
end
return cmd, args
end
-- Resolve aliases for a chat (single HGET lookup per command)
local function resolve_alias(message, redis_mod)
if not message.text:match('^[/!#][%w_]+') then return message end
if not message.chat or message.chat.type == 'private' then return message end
local command, rest = message.text:lower():match('^[/!#]([%w_]+)(.*)')
if not command then return message end
-- Direct lookup: O(1) hash field access instead of decode-all + iterate
local original = redis_mod.hget('chat:' .. message.chat.id .. ':aliases', command)
if original then
message.text = '/' .. original .. (rest or '')
message.is_alias = true
end
return message
end
-- Process action state (multi-step commands)
-- Fixed: save message_id before nil'ing message.reply
local function process_action(message, ctx)
if message.text and message.chat and message.reply
and message.reply.from and message.reply.from.id == api.info.id then
local reply_message_id = message.reply.message_id
local action = session.get_action(message.chat.id, reply_message_id)
if action then
message.text = action .. ' ' .. message.text
message.reply = nil
session.del_action(message.chat.id, reply_message_id)
end
end
return message
end
-- Build a lightweight context for non-message updates (no full middleware pipeline)
local function build_lightweight_ctx(chat, user)
local ctx = {}
for k, v in pairs(ctx_base) do
ctx[k] = v
end
if chat then
ctx.is_group = chat.type ~= 'private'
ctx.is_supergroup = chat.type == 'supergroup'
ctx.is_private = chat.type == 'private'
else
ctx.is_group = false
ctx.is_supergroup = false
ctx.is_private = true
end
ctx.is_global_admin = user and permissions.is_global_admin(user.id) or false
ctx.is_admin = false
ctx.is_mod = false
if user then
local lang_code = session.get_setting(user.id, 'language') or 'en_gb'
ctx.lang = i18n.get(lang_code)
end
return ctx
end
-- Handle a message update
local function on_message(message)
-- Validate
if not message or not message.from then return end
if message.date and message.date < os.time() - 10 then return end
-- Sort/normalise
message = sort_message(message)
message = process_action(message, ctx_base)
message = resolve_alias(message, ctx_base.redis)
-- Build context and run middleware
local ctx = build_ctx(message)
local should_continue
ctx, should_continue = middleware_pipeline.run(ctx, message)
if not should_continue then return end
-- Dispatch command to matching plugin
local cmd, args = extract_command(message.text, api.info.username)
if cmd then
local plugin = loader.get_by_command(cmd)
if plugin and plugin.on_message then
if not session.is_plugin_disabled(message.chat.id, plugin.name) or loader.is_permanent(plugin.name) then
-- Check permission requirements
if plugin.global_admin_only and not ctx.is_global_admin then
return
end
-- Resolve admin status only for admin_only plugins (lazy check)
if plugin.admin_only then
ctx:check_admin()
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, ctx.lang and ctx.lang.errors and ctx.lang.errors.admin or 'You need to be an admin to use this command.')
end
end
if plugin.group_only and ctx.is_private then
return api.send_message(message.chat.id, ctx.lang and ctx.lang.errors and ctx.lang.errors.supergroup or 'This command can only be used in groups.')
end
message.command = cmd
message.args = args
local ok, err = pcall(plugin.on_message, api, message, ctx)
if not ok then
logger.error('Plugin %s.on_message error: %s', plugin.name, tostring(err))
if config.log_chat() then
api.send_message(config.log_chat(), string.format(
'<pre>[%s] %s error:\n%s\nFrom: %s\nText: %s</pre>',
os.date('%X'), plugin.name,
tools.escape_html(tostring(err)),
message.from.id,
tools.escape_html(message.text or '')
), { parse_mode = 'html' })
end
end
end
end
end
-- Build disabled set once for this chat (1 SMEMBERS vs N SISMEMBER calls)
local disabled_set = {}
local disabled_list = session.get_disabled_plugins(message.chat.id)
for _, name in ipairs(disabled_list) do
disabled_set[name] = true
end
-- Run passive handlers using pre-built event index (only plugins with on_new_message)
for _, plugin in ipairs(loader.get_by_event('on_new_message')) do
if not disabled_set[plugin.name] then
local ok, err = pcall(plugin.on_new_message, api, message, ctx)
if not ok then
logger.error('Plugin %s.on_new_message error: %s', plugin.name, tostring(err))
end
end
end
-- Handle member join events (only check if message has new_chat_members)
if message.new_chat_members then
for _, plugin in ipairs(loader.get_by_event('on_member_join')) do
local ok, err = pcall(plugin.on_member_join, api, message, ctx)
if not ok then
logger.error('Plugin %s.on_member_join error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle callback query (routed through middleware for blocklist + rate limit)
local function on_callback_query(callback_query)
if not callback_query or not callback_query.from then return end
if not callback_query.data then return end
local message = callback_query.message
if not message then
message = {
chat = { id = callback_query.from.id, type = 'private' },
message_id = callback_query.inline_message_id,
from = callback_query.from
}
callback_query.is_inline = true
end
-- Parse plugin_name:data format
local plugin_name, cb_data = callback_query.data:match('^(.-):(.*)$')
if not plugin_name then return end
local plugin = loader.get_by_name(plugin_name)
if not plugin or not plugin.on_callback_query then return end
callback_query.data = cb_data
-- Build context and run basic middleware (blocklist + rate limit)
local ctx = build_ctx(message)
-- Check blocklist for callback user
if session.is_globally_blocklisted(callback_query.from.id) then
return
end
-- Load language for callback user
local lang_code = session.get_setting(callback_query.from.id, 'language') or 'en_gb'
ctx.lang = i18n.get(lang_code)
local ok, err = pcall(plugin.on_callback_query, api, callback_query, message, ctx)
if not ok then
logger.error('Plugin %s.on_callback_query error: %s', plugin_name, tostring(err))
end
end
-- Handle inline query
local function on_inline_query(inline_query)
if not inline_query or not inline_query.from then return end
if session.is_globally_blocklisted(inline_query.from.id) then return end
local ctx = build_ctx({ from = inline_query.from, chat = { type = 'private' } })
ctx.lang = i18n.get(session.get_setting(inline_query.from.id, 'language') or 'en_gb')
dispatch_event('on_inline_query', inline_query, ctx)
end
--- Handle chat join request
-local function on_chat_join_request(request)
- if not request or not request.from then return end
- if session.is_globally_blocklisted(request.from.id) then return end
- dispatch_event('on_chat_join_request', request, build_ctx({ from = request.from, chat = request.chat }))
-end
-
--- Handle chat member status change (not the bot itself)
-local function on_chat_member(update)
- if not update or not update.from then return end
- dispatch_event('on_chat_member_update', update, build_ctx({ from = update.from, chat = update.chat }))
-end
-
--- Handle bot's own chat member status change
-local function on_my_chat_member(update)
- if not update or not update.from then return end
- dispatch_event('on_my_chat_member', update, build_ctx({ from = update.from, chat = update.chat }))
-end
-
--- Handle message reaction updates
-local function on_message_reaction(update)
- if not update then return end
- dispatch_event('on_reaction', update, build_ctx({ from = update.user or update.actor_chat, chat = update.chat }))
-end
-
--- Handle anonymous reaction count updates (no user info)
-local function on_message_reaction_count(update)
- if not update then return end
- dispatch_event('on_reaction_count', update, build_ctx({ from = nil, chat = update.chat }))
-end
-
--- Handle chat boost updates
-local function on_chat_boost(update)
- if not update or not update.chat then return end
- dispatch_event('on_chat_boost', update, build_ctx({ from = nil, chat = update.chat }))
-end
-
--- Handle removed chat boost updates
-local function on_removed_chat_boost(update)
- if not update or not update.chat then return end
- dispatch_event('on_removed_chat_boost', update, build_ctx({ from = nil, chat = update.chat }))
-end
-
--- Handle poll state updates
-local function on_poll(poll)
- if not poll then return end
- dispatch_event('on_poll', poll, build_ctx({ from = nil, chat = { type = 'private' } }))
-end
-
--- Handle poll answer updates
-local function on_poll_answer(poll_answer)
- if not poll_answer then return end
- dispatch_event('on_poll_answer', poll_answer, build_ctx({ from = poll_answer.user, chat = { type = 'private' } }))
-end
-
-- Handle chat join request updates
local function on_chat_join_request(request)
if not request or not request.from or not request.chat then return end
if session.is_globally_blocklisted(request.from.id) then return end
local ctx = build_lightweight_ctx(request.chat, request.from)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_chat_join_request then
local ok, err = pcall(plugin.on_chat_join_request, api, request, ctx)
if not ok then
logger.error('Plugin %s.on_chat_join_request error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle chat member status changes
local function on_chat_member(update)
if not update or not update.chat then return end
local user = update.from or (update.new_chat_member and update.new_chat_member.user)
if not user then return end
local ctx = build_lightweight_ctx(update.chat, user)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_chat_member_update then
local ok, err = pcall(plugin.on_chat_member_update, api, update, ctx)
if not ok then
logger.error('Plugin %s.on_chat_member_update error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle bot's own chat member status changes (added/removed/promoted)
local function on_my_chat_member(update)
if not update or not update.chat then return end
local ctx = build_lightweight_ctx(update.chat, update.from)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_my_chat_member then
local ok, err = pcall(plugin.on_my_chat_member, api, update, ctx)
if not ok then
logger.error('Plugin %s.on_my_chat_member error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle message reaction changes
local function on_message_reaction(reaction)
if not reaction or not reaction.chat then return end
local user = reaction.user or reaction.actor_chat
if not user then return end
local ctx = build_lightweight_ctx(reaction.chat, user)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_reaction then
local ok, err = pcall(plugin.on_reaction, api, reaction, ctx)
if not ok then
logger.error('Plugin %s.on_reaction error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle poll state changes
local function on_poll(poll)
if not poll then return end
-- Polls have no chat context, use a minimal ctx
local ctx = build_lightweight_ctx(nil, nil)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_poll then
local ok, err = pcall(plugin.on_poll, api, poll, ctx)
if not ok then
logger.error('Plugin %s.on_poll error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle poll answer (user votes)
local function on_poll_answer(answer)
if not answer or not answer.user then return end
local ctx = build_lightweight_ctx(nil, answer.user)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_poll_answer then
local ok, err = pcall(plugin.on_poll_answer, api, answer, ctx)
if not ok then
logger.error('Plugin %s.on_poll_answer error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle chat boost events
local function on_chat_boost(boost)
if not boost or not boost.chat then return end
local ctx = build_lightweight_ctx(boost.chat, nil)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_chat_boost then
local ok, err = pcall(plugin.on_chat_boost, api, boost, ctx)
if not ok then
logger.error('Plugin %s.on_chat_boost error: %s', plugin.name, tostring(err))
end
end
end
end
-- Handle removed chat boost events
local function on_removed_chat_boost(boost)
if not boost or not boost.chat then return end
local ctx = build_lightweight_ctx(boost.chat, nil)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_removed_chat_boost then
local ok, err = pcall(plugin.on_removed_chat_boost, api, boost, ctx)
if not ok then
logger.error('Plugin %s.on_removed_chat_boost error: %s', plugin.name, tostring(err))
end
end
end
end
-- Concurrent polling loop using telegram-bot-lua's async system
function router.run()
local polling = config.polling()
-- Register telegram-bot-lua handler callbacks
-- api.process_update() dispatches to these inside per-update copas coroutines
api.on_message = function(msg)
local ok, err = pcall(on_message, msg)
if not ok then logger.error('on_message error: %s', tostring(err)) end
end
api.on_edited_message = function(msg)
msg.is_edited = true
local ok, err = pcall(on_message, msg)
if not ok then logger.error('on_edited_message error: %s', tostring(err)) end
end
-- Table-driven registration for simple event handlers
local event_handlers = {
on_callback_query = on_callback_query,
on_inline_query = on_inline_query,
on_chat_join_request = on_chat_join_request,
on_chat_member = on_chat_member,
on_my_chat_member = on_my_chat_member,
on_message_reaction = on_message_reaction,
- on_message_reaction_count = on_message_reaction_count,
on_chat_boost = on_chat_boost,
on_removed_chat_boost = on_removed_chat_boost,
on_poll = on_poll,
on_poll_answer = on_poll_answer,
}
for event_name, handler in pairs(event_handlers) do
api[event_name] = function(data)
local ok, err = pcall(handler, data)
if not ok then logger.error('%s error: %s', event_name, tostring(err)) end
end
end
api.on_inline_query = function(iq)
local ok, err = pcall(on_inline_query, iq)
if not ok then logger.error('on_inline_query error: %s', tostring(err)) end
end
api.on_chat_join_request = function(req)
local ok, err = pcall(on_chat_join_request, req)
if not ok then logger.error('on_chat_join_request error: %s', tostring(err)) end
end
api.on_chat_member = function(update)
local ok, err = pcall(on_chat_member, update)
if not ok then logger.error('on_chat_member error: %s', tostring(err)) end
end
api.on_my_chat_member = function(update)
local ok, err = pcall(on_my_chat_member, update)
if not ok then logger.error('on_my_chat_member error: %s', tostring(err)) end
end
api.on_message_reaction = function(reaction)
local ok, err = pcall(on_message_reaction, reaction)
if not ok then logger.error('on_message_reaction error: %s', tostring(err)) end
end
api.on_poll = function(poll)
local ok, err = pcall(on_poll, poll)
if not ok then logger.error('on_poll error: %s', tostring(err)) end
end
api.on_poll_answer = function(answer)
local ok, err = pcall(on_poll_answer, answer)
if not ok then logger.error('on_poll_answer error: %s', tostring(err)) end
end
api.on_chat_boost = function(boost)
local ok, err = pcall(on_chat_boost, boost)
if not ok then logger.error('on_chat_boost error: %s', tostring(err)) end
end
api.on_removed_chat_boost = function(boost)
local ok, err = pcall(on_removed_chat_boost, boost)
if not ok then logger.error('on_removed_chat_boost error: %s', tostring(err)) end
end
-- Cron: copas background thread, runs every 60s (uses event index)
copas.addthread(function()
while true do
copas.pause(60)
for _, plugin in ipairs(loader.get_by_event('cron')) do
copas.addthread(function()
local ok, err = pcall(plugin.cron, api, ctx_base)
if not ok then
logger.error('Plugin %s cron error: %s', plugin.name, tostring(err))
end
end)
end
end
end)
-- Stats flush: copas background thread, runs every 300s
copas.addthread(function()
while true do
copas.pause(300)
local ok, err = pcall(mw_stats.flush, ctx_base.db, ctx_base.redis)
if not ok then logger.error('Stats flush error: %s', tostring(err)) end
end
end)
-- Start concurrent polling loop
-- api.run() -> api.async.run() which:
-- 1. Swaps api.request to copas-based api.async.request
-- 2. Spawns polling coroutine calling get_updates in a loop
-- 3. For each update, spawns NEW coroutine -> api.process_update -> handlers above
-- 4. Calls copas.loop()
api.run({
timeout = polling.timeout,
limit = polling.limit,
allowed_updates = {
'message', 'edited_message', 'callback_query', 'inline_query',
'chat_join_request', 'chat_member', 'my_chat_member',
'message_reaction', 'message_reaction_count',
'poll', 'poll_answer',
'chat_boost', 'removed_chat_boost'
}
})
end
return router
diff --git a/src/plugins/admin/customcaptcha.lua b/src/plugins/admin/customcaptcha.lua
index 63e02e5..b88aa3b 100644
--- a/src/plugins/admin/customcaptcha.lua
+++ b/src/plugins/admin/customcaptcha.lua
@@ -1,203 +1,205 @@
--[[
mattata v2.0 - Custom Captcha Plugin
Allows admins to set a custom question and answer for the join captcha.
When configured, new members must type the correct answer instead of solving
a math problem with buttons.
Integration note:
join_captcha.lua should check redis.get('ccaptcha:q:' .. chat_id) to determine
if a custom captcha is active. If set, join_captcha should skip its own
on_member_join handling and let this plugin handle the verification flow.
This plugin sets 'ccaptcha:active:<chat_id>:<user_id>' when it handles a join
so join_captcha can check that flag to avoid duplicate handling.
]]
local plugin = {}
plugin.name = 'customcaptcha'
plugin.category = 'admin'
plugin.description = 'Set a custom captcha question and answer for new members'
plugin.commands = { 'customcaptcha', 'ccaptcha' }
plugin.help = '/customcaptcha set <question> | <answer> - Set a custom captcha.\n'
.. '/customcaptcha clear - Remove custom captcha, revert to default.\n'
.. '/customcaptcha - Show current custom captcha status.'
plugin.group_only = true
plugin.admin_only = true
local tools = require('telegram-bot-lua.tools')
local session = require('src.core.session')
local permissions = require('src.core.permissions')
local MUTE_PERMS = {
can_send_messages = false, can_send_audios = false, can_send_documents = false,
can_send_photos = false, can_send_videos = false, can_send_video_notes = false,
can_send_voice_notes = false, can_send_polls = false, can_send_other_messages = false,
can_add_web_page_previews = false, can_invite_users = false, can_change_info = false,
can_pin_messages = false, can_manage_topics = false
}
local UNMUTE_PERMS = {
can_send_messages = true, can_send_audios = true, can_send_documents = true,
can_send_photos = true, can_send_videos = true, can_send_video_notes = true,
can_send_voice_notes = true, can_send_polls = true, can_send_other_messages = true,
can_add_web_page_previews = true, can_invite_users = true, can_change_info = true,
can_pin_messages = true, can_manage_topics = true
}
function plugin.on_message(api, message, ctx)
local chat_id = message.chat.id
if not message.args then
-- Show current status
local question = ctx.redis.get('ccaptcha:q:' .. chat_id)
local answer = ctx.redis.get('ccaptcha:a:' .. chat_id)
if question and answer then
return api.send_message(chat_id, string.format(
'<b>Custom captcha is active.</b>\n\nQuestion: <i>%s</i>\nExpected answer: <i>%s</i>',
tools.escape_html(question), tools.escape_html(answer)
), { parse_mode = 'html' })
end
return api.send_message(chat_id, string.format(
'<b>No custom captcha configured.</b> The default math captcha will be used.\n\n'
.. 'Usage:\n'
.. '<code>/customcaptcha set &lt;question&gt; | &lt;answer&gt;</code> - Set a custom captcha\n'
.. '<code>/customcaptcha clear</code> - Remove custom captcha'
), { parse_mode = 'html' })
end
local args = message.args
local sub_command = args:match('^(%S+)')
if not sub_command then
return api.send_message(chat_id, 'Usage: /customcaptcha set <question> | <answer>')
end
sub_command = sub_command:lower()
if sub_command == 'set' then
local rest = args:match('^%S+%s+(.+)$')
if not rest then
return api.send_message(chat_id, 'Usage: <code>/customcaptcha set &lt;question&gt; | &lt;answer&gt;</code>', { parse_mode = 'html' })
end
local question, answer = rest:match('^(.-)%s*|%s*(.+)$')
if not question or question == '' or not answer or answer == '' then
- return api.send_message(chat_id, 'Please separate the question and answer with a pipe character (|).\nExample: <code>/customcaptcha set What colour is the sky? | blue</code>', { parse_mode = 'html' })
+ local err_text = 'Please separate the question and answer with a pipe character (|).\n'
+ .. 'Example: <code>/customcaptcha set What colour is the sky? | blue</code>'
+ return api.send_message(chat_id, err_text, { parse_mode = 'html' })
end
question = question:match('^%s*(.-)%s*$')
answer = answer:match('^%s*(.-)%s*$')
if #question > 300 then
return api.send_message(chat_id, 'The question must be 300 characters or fewer.')
end
if #answer > 100 then
return api.send_message(chat_id, 'The answer must be 100 characters or fewer.')
end
ctx.redis.set('ccaptcha:q:' .. chat_id, question)
ctx.redis.set('ccaptcha:a:' .. chat_id, answer:lower())
return api.send_message(chat_id, string.format(
'Custom captcha set!\nQuestion: <i>%s</i>\nExpected answer: <i>%s</i>',
tools.escape_html(question), tools.escape_html(answer)
), { parse_mode = 'html' })
elseif sub_command == 'clear' then
ctx.redis.del('ccaptcha:q:' .. chat_id)
ctx.redis.del('ccaptcha:a:' .. chat_id)
return api.send_message(chat_id, 'Custom captcha removed. Default math captcha will be used.')
else
return api.send_message(chat_id, 'Usage: <code>/customcaptcha set &lt;question&gt; | &lt;answer&gt;</code> or <code>/customcaptcha clear</code>', { parse_mode = 'html' })
end
end
function plugin.on_member_join(api, message, ctx)
if not ctx.is_group then return end
local chat_id = message.chat.id
-- Check if a custom captcha is configured for this chat
local question = ctx.redis.get('ccaptcha:q:' .. chat_id)
if not question then return end
-- Check if captcha is enabled
local enabled = session.get_cached_setting(chat_id, 'captcha_enabled', function()
local ok, result = pcall(ctx.db.call, 'sp_get_chat_setting', { chat_id, 'captcha_enabled' })
if ok and result and #result > 0 then return result[1].value end
return nil
end, 300)
if enabled ~= 'true' then return end
if not permissions.can_restrict(api, chat_id) then return end
local ok_timeout, timeout_result = pcall(ctx.db.call, 'sp_get_chat_setting', { chat_id, 'captcha_timeout' })
local timeout = (ok_timeout and timeout_result and #timeout_result > 0) and tonumber(timeout_result[1].value) or 300
local expected_answer = ctx.redis.get('ccaptcha:a:' .. chat_id)
if not expected_answer then return end
for _, new_member in ipairs(message.new_chat_members) do
if new_member.is_bot then goto continue end
-- Set flag so join_captcha knows this user is handled by custom captcha
ctx.redis.setex('ccaptcha:active:' .. chat_id .. ':' .. new_member.id, timeout, '1')
-- Restrict the new member
api.restrict_chat_member(chat_id, new_member.id, MUTE_PERMS, {
until_date = os.time() + timeout
})
-- Send the custom question
local text = string.format(
'Welcome, <a href="tg://user?id=%d">%s</a>! To verify you\'re human, please answer the following question:\n\n<b>%s</b>\n\nType your answer in the chat. You have %d seconds.',
new_member.id,
tools.escape_html(new_member.first_name),
tools.escape_html(question),
timeout
)
local sent = api.send_message(chat_id, text, { parse_mode = 'html' })
-- Store captcha state using session
if sent and sent.result then
session.set_captcha(chat_id, new_member.id, expected_answer, sent.result.message_id, timeout)
end
::continue::
end
end
function plugin.on_new_message(api, message, ctx)
if not ctx.is_group then return end
if not message.text then return end
if not message.from then return end
local chat_id = message.chat.id
local user_id = message.from.id
-- Check if this user has a pending custom captcha
local active = ctx.redis.get('ccaptcha:active:' .. chat_id .. ':' .. user_id)
if not active then return end
local captcha = session.get_captcha(chat_id, user_id)
if not captcha then
-- Captcha expired, clean up the active flag
ctx.redis.del('ccaptcha:active:' .. chat_id .. ':' .. user_id)
return
end
local user_answer = message.text:lower():match('^%s*(.-)%s*$')
if user_answer == captcha.text then
-- Correct answer - unrestrict user
api.restrict_chat_member(chat_id, user_id, UNMUTE_PERMS)
session.clear_captcha(chat_id, user_id)
ctx.redis.del('ccaptcha:active:' .. chat_id .. ':' .. user_id)
-- Delete the question message
if captcha.message_id then
api.delete_message(chat_id, captcha.message_id)
end
-- Delete the user's answer message
api.delete_message(chat_id, message.message_id)
api.send_message(chat_id, string.format(
'<a href="tg://user?id=%d">%s</a> has been verified. Welcome!',
user_id, tools.escape_html(message.from.first_name)
), { parse_mode = 'html' })
else
-- Wrong answer - delete their message and prompt to try again
api.delete_message(chat_id, message.message_id)
end
end
return plugin
diff --git a/src/plugins/fun/catfact.lua b/src/plugins/fun/catfact.lua
index b3ffa15..0c5eb3a 100644
--- a/src/plugins/fun/catfact.lua
+++ b/src/plugins/fun/catfact.lua
@@ -1,29 +1,29 @@
--[[
mattata v2.0 - Cat Fact Plugin
Fetches a real cat fact from the catfact.ninja API.
]]
local plugin = {}
plugin.name = 'catfact'
plugin.category = 'fun'
plugin.description = 'Get a random real cat fact'
plugin.commands = { 'catfact', 'cfact' }
plugin.help = '/catfact - Get a random cat fact from catfact.ninja.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
- local data, code = http.get_json('https://catfact.ninja/fact')
+ local data, _ = http.get_json('https://catfact.ninja/fact')
if not data then
return api.send_message(message.chat.id, 'Failed to fetch a cat fact. Try again later.')
end
if not data or not data.fact then
return api.send_message(message.chat.id, 'Failed to parse cat fact response. Try again later.')
end
local output = string.format('\xF0\x9F\x90\xB1 <b>Cat Fact:</b> %s', data.fact)
return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/fun/reactions.lua b/src/plugins/fun/reactions.lua
index ef47cba..b723753 100644
--- a/src/plugins/fun/reactions.lua
+++ b/src/plugins/fun/reactions.lua
@@ -1,148 +1,148 @@
--[[
mattata v2.0 - Reactions Plugin
Tracks karma via message reactions (thumbs up/down).
Records message authorship so reaction events can attribute karma.
]]
local session = require('src.core.session')
local plugin = {}
plugin.name = 'reactions'
plugin.category = 'fun'
plugin.description = 'Reaction-based karma tracking'
plugin.commands = { 'reactions' }
plugin.help = '/reactions <on|off> - Toggle reaction karma tracking for this group.'
plugin.group_only = true
plugin.admin_only = true
local THUMBS_UP = '\xF0\x9F\x91\x8D'
local THUMBS_DOWN = '\xF0\x9F\x91\x8E'
function plugin.on_message(api, message, ctx)
if not message.args or message.args == '' then
-- Show current status
local enabled = session.get_cached_setting(message.chat.id, 'reactions_enabled', function()
local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'reactions_enabled' })
if result and #result > 0 then
return result[1].value
end
return nil
end)
local status = (enabled == 'true') and 'enabled' or 'disabled'
return api.send_message(message.chat.id,
string.format('Reaction karma is currently <b>%s</b> for this group.\nUse <code>/reactions on</code> or <code>/reactions off</code> to toggle.', status),
{ parse_mode = 'html' }
)
end
local arg = message.args:lower()
if arg == 'on' or arg == 'enable' then
- local ok, err = pcall(ctx.db.call, 'sp_upsert_chat_setting', { message.chat.id, 'reactions_enabled', 'true' })
+ local ok, _ = pcall(ctx.db.call, 'sp_upsert_chat_setting', { message.chat.id, 'reactions_enabled', 'true' })
if not ok then
return api.send_message(message.chat.id, 'Failed to update setting. Please try again.')
end
session.invalidate_setting(message.chat.id, 'reactions_enabled')
return api.send_message(message.chat.id, 'Reaction karma has been enabled for this group.')
elseif arg == 'off' or arg == 'disable' then
- local ok, err = pcall(ctx.db.call, 'sp_upsert_chat_setting', { message.chat.id, 'reactions_enabled', 'false' })
+ local ok, _ = pcall(ctx.db.call, 'sp_upsert_chat_setting', { message.chat.id, 'reactions_enabled', 'false' })
if not ok then
return api.send_message(message.chat.id, 'Failed to update setting. Please try again.')
end
session.invalidate_setting(message.chat.id, 'reactions_enabled')
else
return api.send_message(message.chat.id, 'Usage: /reactions <on|off>')
end
end
-- Record message authorship for karma attribution
function plugin.on_new_message(api, message, ctx)
if not ctx.is_group or not message.from then return end
-- Only track authorship if reactions feature is enabled for this chat
local enabled = session.get_cached_setting(message.chat.id, 'reactions_enabled', function()
local ok, result = pcall(ctx.db.call, 'sp_get_chat_setting', { message.chat.id, 'reactions_enabled' })
if ok and result and #result > 0 then
return result[1].value
end
return nil
end)
if enabled ~= 'true' then return end
ctx.redis.setex(
string.format('msg_author:%s:%s', message.chat.id, message.message_id),
172800,
tostring(message.from.id)
)
end
-- Build a set of emoji strings from a reaction array
local function reaction_set(reactions)
local set = {}
if not reactions then return set end
for _, r in ipairs(reactions) do
if r.type == 'emoji' and r.emoji then
set[r.emoji] = true
end
end
return set
end
function plugin.on_reaction(api, update, ctx)
-- Anonymous reactions cannot be tracked
if not update.user then return end
if not update.chat then return end
local chat_id = update.chat.id
-- Check if reactions_enabled for this chat
local enabled = session.get_cached_setting(chat_id, 'reactions_enabled', function()
local ok, result = pcall(ctx.db.call, 'sp_get_chat_setting', { chat_id, 'reactions_enabled' })
if ok and result and #result > 0 then
return result[1].value
end
return nil
end)
if enabled ~= 'true' then return end
-- Look up the author of the reacted message
local author_id = ctx.redis.get(
string.format('msg_author:%s:%s', chat_id, update.message_id)
)
if not author_id then return end
author_id = tonumber(author_id)
-- Prevent self-karma
if update.user.id == author_id then return end
local new_set = reaction_set(update.new_reaction)
local old_set = reaction_set(update.old_reaction)
local karma_key = 'karma:' .. author_id
local delta = 0
-- New reactions that weren't in old (added)
for emoji, _ in pairs(new_set) do
if not old_set[emoji] then
if emoji == THUMBS_UP then
delta = delta + 1
elseif emoji == THUMBS_DOWN then
delta = delta - 1
end
end
end
-- Old reactions that aren't in new (removed)
for emoji, _ in pairs(old_set) do
if not new_set[emoji] then
if emoji == THUMBS_UP then
delta = delta - 1
elseif emoji == THUMBS_DOWN then
delta = delta + 1
end
end
end
if delta ~= 0 then
ctx.redis.incrby(karma_key, delta)
end
end
return plugin
diff --git a/src/plugins/media/cats.lua b/src/plugins/media/cats.lua
index cb3edc4..0e29875 100644
--- a/src/plugins/media/cats.lua
+++ b/src/plugins/media/cats.lua
@@ -1,37 +1,37 @@
--[[
mattata v2.0 - Cats Plugin
Sends a random cat image from TheCatAPI.
]]
local plugin = {}
plugin.name = 'cats'
plugin.category = 'media'
plugin.description = 'Get a random cat image'
plugin.commands = { 'cat', 'cats' }
plugin.help = '/cat - Sends a random cat image.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
- local data, code = http.get_json('https://api.thecatapi.com/v1/images/search')
+ local data, _ = http.get_json('https://api.thecatapi.com/v1/images/search')
if not data then
return api.send_message(message.chat.id, 'Failed to fetch a cat image. Please try again later.')
end
if not data or #data == 0 then
return api.send_message(message.chat.id, 'No cat images found. Please try again later.')
end
local image_url = data[1].url
if not image_url then
return api.send_message(message.chat.id, 'Failed to parse the cat image response.')
end
-- Send as animation if it's a gif, otherwise as photo
if image_url:lower():match('%.gif$') then
return api.send_animation(message.chat.id, image_url)
end
return api.send_photo(message.chat.id, image_url)
end
return plugin
diff --git a/src/plugins/media/gif.lua b/src/plugins/media/gif.lua
index 54e8559..00e4dbc 100644
--- a/src/plugins/media/gif.lua
+++ b/src/plugins/media/gif.lua
@@ -1,53 +1,53 @@
--[[
mattata v2.0 - GIF Plugin
Searches for GIFs using the Tenor API and sends them as animations.
]]
local plugin = {}
plugin.name = 'gif'
plugin.category = 'media'
plugin.description = 'Search for GIFs using Tenor'
plugin.commands = { 'gif', 'tenor' }
plugin.help = '/gif <query> - Search for a GIF and send it.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local url = require('socket.url')
local tenor_key = ctx.config.get('TENOR_API_KEY')
if not tenor_key or tenor_key == '' then
return api.send_message(message.chat.id, 'The Tenor API key is not configured. Please set <code>TENOR_API_KEY</code> in the bot configuration.', 'html')
end
if not message.args or message.args == '' then
return api.send_message(message.chat.id, 'Please specify a search query, e.g. <code>/gif funny cats</code>.', { parse_mode = 'html' })
end
local query = url.escape(message.args)
local api_url = string.format(
'https://tenor.googleapis.com/v2/search?q=%s&key=%s&limit=1&media_filter=gif',
query, tenor_key
)
- local data, code = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data then
return api.send_message(message.chat.id, 'Failed to search Tenor. Please try again later.')
end
if not data or not data.results or #data.results == 0 then
return api.send_message(message.chat.id, 'No GIFs found for that query.')
end
local result = data.results[1]
local gif_url = result.media_formats
and result.media_formats.gif
and result.media_formats.gif.url
if not gif_url then
return api.send_message(message.chat.id, 'Failed to retrieve the GIF URL.')
end
return api.send_animation(message.chat.id, gif_url)
end
return plugin
diff --git a/src/plugins/media/itunes.lua b/src/plugins/media/itunes.lua
index 0df1b99..73c69fa 100644
--- a/src/plugins/media/itunes.lua
+++ b/src/plugins/media/itunes.lua
@@ -1,80 +1,80 @@
--[[
mattata v2.0 - iTunes Plugin
Searches the iTunes Store for tracks.
]]
local plugin = {}
plugin.name = 'itunes'
plugin.category = 'media'
plugin.description = 'Search the iTunes Store for tracks'
plugin.commands = { 'itunes' }
plugin.help = '/itunes <query> - Search iTunes for a track and return song info with pricing.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
if not message.args or message.args == '' then
return api.send_message(message.chat.id, 'Please specify a search query, e.g. <code>/itunes imagine dragons believer</code>.', { parse_mode = 'html' })
end
local query = url.escape(message.args)
local api_url = string.format(
'https://itunes.apple.com/search?term=%s&media=music&entity=song&limit=1',
query
)
- local data, code = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data then
return api.send_message(message.chat.id, 'Failed to search iTunes. Please try again later.')
end
if not data or not data.results or #data.results == 0 then
return api.send_message(message.chat.id, 'No results found for that query.')
end
local track = data.results[1]
local track_name = track.trackName or 'Unknown'
local artist_name = track.artistName or 'Unknown'
local album_name = track.collectionName or 'Unknown'
local track_url = track.trackViewUrl or ''
local artwork_url = track.artworkUrl100 or ''
-- Format price
local price = 'N/A'
if track.trackPrice and track.currency then
if track.trackPrice < 0 then
price = 'Not available for individual sale'
else
price = string.format('%s %.2f', track.currency, track.trackPrice)
end
end
local output = string.format(
'<b>%s</b>\nArtist: %s\nAlbum: %s\nPrice: %s',
tools.escape_html(track_name),
tools.escape_html(artist_name),
tools.escape_html(album_name),
tools.escape_html(price)
)
if track_url ~= '' then
output = output .. string.format('\n<a href="%s">View on iTunes</a>', tools.escape_html(track_url))
end
-- Send artwork as photo with caption if available
if artwork_url ~= '' then
-- Use higher resolution artwork
local hires_url = artwork_url:gsub('100x100', '600x600')
local success = api.send_photo(message.chat.id, hires_url, { caption = output, parse_mode = 'html' })
if success then
return success
end
end
-- Fallback to text-only
return api.send_message(message.chat.id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true } })
end
return plugin
diff --git a/src/plugins/media/youtube.lua b/src/plugins/media/youtube.lua
index d7116c1..57c126c 100644
--- a/src/plugins/media/youtube.lua
+++ b/src/plugins/media/youtube.lua
@@ -1,83 +1,83 @@
--[[
mattata v2.0 - YouTube Plugin
Searches YouTube using the Data API v3 and returns the top result.
]]
local plugin = {}
plugin.name = 'youtube'
plugin.category = 'media'
plugin.description = 'Search YouTube for videos'
plugin.commands = { 'youtube', 'yt' }
plugin.help = '/youtube <query> - Search YouTube and return the top result with title, channel, and views.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
local api_key = ctx.config.get('YOUTUBE_API_KEY')
if not api_key then
return api.send_message(message.chat.id, 'The YouTube API key has not been configured.')
end
if not message.args or message.args == '' then
return api.send_message(message.chat.id, 'Please specify a search query, e.g. <code>/yt never gonna give you up</code>.', { parse_mode = 'html' })
end
-- Step 1: Search for videos
local query = url.escape(message.args)
local search_url = string.format(
'https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&maxResults=1&q=%s&key=%s',
query, api_key
)
- local data, code = http.get_json(search_url)
+ local data, _ = http.get_json(search_url)
if not data then
return api.send_message(message.chat.id, 'Failed to search YouTube. Please try again later.')
end
if not data or not data.items or #data.items == 0 then
return api.send_message(message.chat.id, 'No results found for that query.')
end
local item = data.items[1]
local video_id = item.id and item.id.videoId
local title = item.snippet and item.snippet.title or 'Unknown'
local channel = item.snippet and item.snippet.channelTitle or 'Unknown'
if not video_id then
return api.send_message(message.chat.id, 'Failed to parse the YouTube search results.')
end
-- Step 2: Fetch video statistics
local stats_url = string.format(
'https://www.googleapis.com/youtube/v3/videos?part=statistics&id=%s&key=%s',
video_id, api_key
)
- local stats_data, stats_code = http.get_json(stats_url)
+ local stats_data, _ = http.get_json(stats_url)
local views = 'N/A'
if stats_data then
if stats_data and stats_data.items and #stats_data.items > 0 then
local stats = stats_data.items[1].statistics
if stats and stats.viewCount then
-- Format view count with commas
views = tostring(stats.viewCount):reverse():gsub('(%d%d%d)', '%1,'):reverse():gsub('^,', '')
end
end
end
local video_url = 'https://youtu.be/' .. video_id
local output = string.format(
'<a href="%s">%s</a>\nChannel: %s\nViews: %s',
tools.escape_html(video_url),
tools.escape_html(title),
tools.escape_html(channel),
views
)
return api.send_message(message.chat.id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true } })
end
return plugin
diff --git a/src/plugins/utility/currency.lua b/src/plugins/utility/currency.lua
index 62beccf..e27e19c 100644
--- a/src/plugins/utility/currency.lua
+++ b/src/plugins/utility/currency.lua
@@ -1,138 +1,138 @@
--[[
mattata v2.0 - Currency Plugin
Currency conversion using the frankfurter.app API (free, no key needed).
Frankfurter uses ECB (European Central Bank) rates.
]]
local plugin = {}
plugin.name = 'currency'
plugin.category = 'utility'
plugin.description = 'Convert between currencies'
plugin.commands = { 'currency', 'convert', 'cash' }
plugin.help = '/currency <amount> <from> to <to> - Convert between currencies.\nExample: /currency 10 USD to EUR'
local http = require('src.core.http')
local tools = require('telegram-bot-lua.tools')
local function convert(amount, from, to)
local request_url = string.format(
'https://api.frankfurter.app/latest?amount=%.2f&from=%s&to=%s',
amount, from:upper(), to:upper()
)
- local data, code = http.get_json(request_url)
+ local data, _ = http.get_json(request_url)
if not data then
return nil, 'Currency conversion request failed. Check that the currency codes are valid.'
end
if data.message then
return nil, 'API error: ' .. tostring(data.message)
end
if not data.rates then
return nil, 'No conversion rates returned. Check your currency codes.'
end
local target_key = to:upper()
if not data.rates[target_key] then
return nil, 'Currency "' .. target_key .. '" is not supported.'
end
return {
amount = data.amount,
from = data.base,
to = target_key,
result = data.rates[target_key],
date = data.date
}
end
local function format_number(n)
if n >= 1 then
return string.format('%.2f', n)
elseif n >= 0.01 then
return string.format('%.4f', n)
else
return string.format('%.6f', n)
end
end
function plugin.on_message(api, message, ctx)
local input = message.args
if not input or input == '' then
return api.send_message(
message.chat.id,
'Please provide a conversion query.\nUsage: <code>/currency 10 USD to EUR</code>',
{ parse_mode = 'html' }
)
end
-- Parse: <amount> <from> to <to>
-- Also support: <amount> <from> <to>, <from> to <to> (assume amount=1)
local amount, from, to
-- Try: 10 USD to EUR / 10 USD in EUR
amount, from, to = input:match('^([%d%.]+)%s*(%a+)%s+[tT][oO]%s+(%a+)$')
if not amount then
amount, from, to = input:match('^([%d%.]+)%s*(%a+)%s+[iI][nN]%s+(%a+)$')
end
-- Try: 10 USD EUR
if not amount then
amount, from, to = input:match('^([%d%.]+)%s*(%a+)%s+(%a+)$')
end
-- Try: USD to EUR (amount=1)
if not amount then
from, to = input:match('^(%a+)%s+[tT][oO]%s+(%a+)$')
if from then
amount = '1'
end
end
-- Try: USD EUR (amount=1)
if not amount then
from, to = input:match('^(%a+)%s+(%a+)$')
if from then
amount = '1'
end
end
if not amount or not from or not to then
return api.send_message(
message.chat.id,
'Invalid format. Please use:\n<code>/currency 10 USD to EUR</code>\n<code>/currency USD EUR</code>',
{ parse_mode = 'html' }
)
end
amount = tonumber(amount)
if not amount or amount <= 0 then
return api.send_message(message.chat.id, 'Please enter a valid positive number for the amount.')
end
if amount > 999999999 then
return api.send_message(message.chat.id, 'Amount is too large.')
end
from = from:upper()
to = to:upper()
if from == to then
return api.send_message(
message.chat.id,
string.format('<b>%s %s</b> = <b>%s %s</b>', format_number(amount), tools.escape_html(from), format_number(amount), tools.escape_html(to)),
{ parse_mode = 'html' }
)
end
local result, err = convert(amount, from, to)
if not result then
return api.send_message(message.chat.id, err)
end
local output = string.format(
'<b>%s %s</b> = <b>%s %s</b>\n<i>Rate as of %s (ECB)</i>',
format_number(result.amount),
tools.escape_html(result.from),
format_number(result.result),
tools.escape_html(result.to),
tools.escape_html(result.date)
)
return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/github.lua b/src/plugins/utility/github.lua
index e7fc520..7795938 100644
--- a/src/plugins/utility/github.lua
+++ b/src/plugins/utility/github.lua
@@ -1,75 +1,75 @@
--[[
mattata v2.0 - GitHub Plugin
Fetches information about a GitHub repository.
]]
local plugin = {}
plugin.name = 'github'
plugin.category = 'utility'
plugin.description = 'View information about a GitHub repository'
plugin.commands = { 'github', 'gh' }
plugin.help = '/gh <owner/repo> - View information about a GitHub repository.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local tools = require('telegram-bot-lua.tools')
local input = message.args
if not input or input == '' then
return api.send_message(message.chat.id, 'Please specify a repository. Usage: /gh <owner/repo>')
end
-- Extract owner/repo from various input formats
local owner, repo = input:match('^([%w%.%-_]+)/([%w%.%-_]+)$')
if not owner then
-- Try extracting from a full GitHub URL
owner, repo = input:match('github%.com/([%w%.%-_]+)/([%w%.%-_]+)')
end
if not owner or not repo then
return api.send_message(message.chat.id, 'Invalid repository format. Use: /gh owner/repo')
end
local api_url = string.format('https://api.github.com/repos/%s/%s', owner, repo)
- local data, code = http.get_json(api_url, {
+ local data, _ = http.get_json(api_url, {
['Accept'] = 'application/vnd.github.v3+json'
})
if not data then
return api.send_message(message.chat.id, 'Repository not found or GitHub API is unavailable.')
end
if not data or data.message then
return api.send_message(message.chat.id, 'Repository not found: ' .. (data and data.message or 'unknown error'))
end
local lines = {
string.format('<b>%s</b>', tools.escape_html(data.full_name or (owner .. '/' .. repo)))
}
if data.description and data.description ~= '' then
table.insert(lines, tools.escape_html(data.description))
end
table.insert(lines, '')
if data.language then
table.insert(lines, 'Language: <code>' .. tools.escape_html(data.language) .. '</code>')
end
table.insert(lines, string.format('Stars: <code>%s</code>', data.stargazers_count or 0))
table.insert(lines, string.format('Forks: <code>%s</code>', data.forks_count or 0))
table.insert(lines, string.format('Open issues: <code>%s</code>', data.open_issues_count or 0))
if data.license and data.license.spdx_id then
table.insert(lines, 'License: <code>' .. tools.escape_html(data.license.spdx_id) .. '</code>')
end
if data.created_at then
table.insert(lines, 'Created: <code>' .. data.created_at:sub(1, 10) .. '</code>')
end
local keyboard = api.inline_keyboard():row(
api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. owner .. '/' .. repo))
)
return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
end
return plugin
diff --git a/src/plugins/utility/inline.lua b/src/plugins/utility/inline.lua
index 508334c..8d094df 100644
--- a/src/plugins/utility/inline.lua
+++ b/src/plugins/utility/inline.lua
@@ -1,234 +1,234 @@
--[[
mattata v2.0 - Inline Query Plugin
Multi-purpose inline query handler for @botname queries.
Supports: wiki, ud, calc, translate
]]
local plugin = {}
plugin.name = 'inline'
plugin.category = 'utility'
plugin.description = 'Handle inline queries for Wikipedia, Urban Dictionary, calculator, and translation'
plugin.commands = {}
local http = require('src.core.http')
local json = require('dkjson')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
local logger = require('src.core.logger')
local LIBRE_TRANSLATE_URL = 'https://libretranslate.com'
--- Build an InlineQueryResultArticle table.
local function article(id, title, description, message_text, parse_mode)
return {
type = 'article',
id = tostring(id),
title = title,
description = description or '',
input_message_content = {
message_text = message_text,
parse_mode = parse_mode or 'html'
}
}
end
--- Strip HTML tags from a string (used for Wikipedia snippets).
local function strip_html(s)
if not s then return '' end
return s:gsub('<[^>]+>', '')
end
--- Truncate a string to max_len characters, appending '...' if truncated.
local function truncate(s, max_len)
if not s then return '' end
if #s <= max_len then return s end
return s:sub(1, max_len) .. '...'
end
--- Wikipedia inline search: returns up to 5 article results.
local function handle_wiki(query)
local encoded = url.escape(query)
local api_url = string.format(
'https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&format=json&utf8=1&srlimit=5',
encoded
)
- local data, code = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data or not data.query or not data.query.search or #data.query.search == 0 then
return { article(1, 'No results', 'No Wikipedia articles found for "' .. query .. '".',
'No Wikipedia articles found for "' .. tools.escape_html(query) .. '".') }
end
local results = {}
for i, entry in ipairs(data.query.search) do
local title = entry.title or 'Untitled'
local snippet = strip_html(entry.snippet or '')
snippet = truncate(snippet, 200)
local page_url = 'https://en.wikipedia.org/wiki/' .. title:gsub(' ', '_')
local message_text = string.format(
'<b>%s</b>\n\n%s\n\n%s',
tools.escape_html(title),
tools.escape_html(snippet),
tools.escape_html(page_url)
)
results[#results + 1] = article(i, title, snippet, message_text)
end
return results
end
--- Urban Dictionary inline lookup: returns up to 3 article results.
local function handle_ud(query)
local encoded = url.escape(query)
local api_url = 'https://api.urbandictionary.com/v0/define?term=' .. encoded
- local data, code = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data or not data.list or #data.list == 0 then
return { article(1, 'No results', 'No definitions found for "' .. query .. '".',
'No Urban Dictionary definitions found for "' .. tools.escape_html(query) .. '".') }
end
local results = {}
local limit = math.min(3, #data.list)
for i = 1, limit do
local entry = data.list[i]
local word = entry.word or query
local definition = (entry.definition or ''):gsub('%[', ''):gsub('%]', '')
local example = (entry.example or ''):gsub('%[', ''):gsub('%]', '')
definition = truncate(definition, 300)
example = truncate(example, 200)
local desc = truncate(definition, 100)
local lines = {
string.format('<b>%s</b>', tools.escape_html(word)),
'',
string.format('<i>%s</i>', tools.escape_html(definition))
}
if example ~= '' then
table.insert(lines, '')
table.insert(lines, 'Example: ' .. tools.escape_html(example))
end
results[#results + 1] = article(i, word, desc, table.concat(lines, '\n'))
end
return results
end
--- Calculator inline handler: returns a single article result.
local function handle_calc(expression)
local encoded = url.escape(expression)
local api_url = 'https://api.mathjs.org/v4/?expr=' .. encoded
local body, status = http.get(api_url)
if not body or status ~= 200 then
return { article(1, 'Calculation error', 'Could not evaluate: ' .. expression,
'Failed to evaluate expression: ' .. tools.escape_html(expression)) }
end
local result = body:match('^%s*(.-)%s*$')
if not result or result == '' then
return { article(1, 'No result', 'No result for: ' .. expression,
'No result returned for: ' .. tools.escape_html(expression)) }
end
local message_text = string.format(
'<b>Expression:</b> <code>%s</code>\n<b>Result:</b> <code>%s</code>',
tools.escape_html(expression),
tools.escape_html(result)
)
return { article(1, result, expression .. ' = ' .. result, message_text) }
end
--- Translate inline handler: auto-detect source language, translate to English.
local function handle_translate(text)
local request_body = json.encode({
q = text,
source = 'auto',
target = 'en',
format = 'text'
})
local body, code = http.post(LIBRE_TRANSLATE_URL .. '/translate', request_body, 'application/json')
if code ~= 200 or not body then
return { article(1, 'Translation failed', 'Could not translate the given text.',
'Translation failed. The service may be temporarily unavailable.') }
end
local data = json.decode(body)
if not data or not data.translatedText then
return { article(1, 'Translation failed', 'Could not parse translation response.',
'Translation failed. Could not parse the response from the translation service.') }
end
local translated = data.translatedText
local source_lang = data.detectedLanguage and data.detectedLanguage.language or '??'
local message_text = string.format(
'<b>Translation</b> [%s -> EN]\n\n%s',
tools.escape_html(source_lang:upper()),
tools.escape_html(translated)
)
- local desc = truncate(translated, 100)
+ local _ = truncate(translated, 100)
return { article(1, translated, source_lang:upper() .. ' -> EN', message_text) }
end
--- Build the help result shown when no valid type prefix is given.
local function help_results()
local help_text = table.concat({
'<b>Inline Query Help</b>',
'',
'Type <code>@botname &lt;type&gt; &lt;query&gt;</code> in any chat.',
'',
'<b>Supported types:</b>',
' <code>wiki &lt;query&gt;</code> - Search Wikipedia',
' <code>ud &lt;query&gt;</code> - Urban Dictionary lookup',
' <code>calc &lt;expression&gt;</code> - Calculator',
' <code>translate &lt;text&gt;</code> - Translate to English',
'',
'Examples:',
' <code>@botname wiki Lua programming</code>',
' <code>@botname ud yeet</code>',
' <code>@botname calc 2+2*5</code>',
' <code>@botname translate Bonjour le monde</code>'
}, '\n')
return { article(1, 'Inline Query Help', 'Type @botname wiki/ud/calc/translate <query>', help_text) }
end
--- Dispatch table for query types.
local handlers = {
wiki = handle_wiki,
ud = handle_ud,
calc = handle_calc,
translate = handle_translate
}
function plugin.on_inline_query(api, inline_query, ctx)
local ok, err = pcall(function()
local query = inline_query.query or ''
query = query:match('^%s*(.-)%s*$') -- trim whitespace
-- Show help if query is too short
if not query or #query < 2 then
local results = help_results()
return api.answer_inline_query(inline_query.id, results, { cache_time = 300 })
end
-- Parse the type prefix and remaining query
local query_type, query_text = query:match('^(%S+)%s+(.+)$')
if not query_type or not query_text then
local results = help_results()
return api.answer_inline_query(inline_query.id, results, { cache_time = 300 })
end
query_type = query_type:lower()
query_text = query_text:match('^%s*(.-)%s*$') -- trim
local handler = handlers[query_type]
if not handler then
local results = help_results()
return api.answer_inline_query(inline_query.id, results, { cache_time = 300 })
end
if not query_text or query_text == '' then
local results = help_results()
return api.answer_inline_query(inline_query.id, results, { cache_time = 300 })
end
local results = handler(query_text)
api.answer_inline_query(inline_query.id, results, { cache_time = 300 })
end)
if not ok then
-- Silently fail on errors — don't crash the bot for inline queries
logger.warn('Inline query handler error: %s', tostring(err))
end
end
return plugin
diff --git a/src/plugins/utility/lastfm.lua b/src/plugins/utility/lastfm.lua
index 1000011..d1893cc 100644
--- a/src/plugins/utility/lastfm.lua
+++ b/src/plugins/utility/lastfm.lua
@@ -1,112 +1,112 @@
--[[
mattata v2.0 - Last.fm Plugin
Shows now playing / recent tracks from Last.fm.
]]
local plugin = {}
plugin.name = 'lastfm'
plugin.category = 'utility'
plugin.description = 'View your Last.fm now playing and recent tracks'
plugin.commands = { 'lastfm', 'np', 'fmset' }
plugin.help = '/np - Show your currently playing or most recent track.\n/fmset <username> - Link your Last.fm account.\n/lastfm [username] - View recent tracks for a Last.fm user.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
local config = require('src.core.config')
local api_key = config.get('LASTFM_API_KEY')
if not api_key or api_key == '' then
return api.send_message(message.chat.id, 'Last.fm is not configured. The bot admin needs to set LASTFM_API_KEY.')
end
-- /fmset: link Last.fm username
if message.command == 'fmset' then
local username = message.args
if not username or username == '' then
return api.send_message(message.chat.id, 'Please provide your Last.fm username. Usage: /fmset <username>')
end
-- Remove leading @ if present
username = username:gsub('^@', '')
ctx.redis.set('lastfm:' .. message.from.id, username)
return api.send_message(
message.chat.id,
string.format('Your Last.fm username has been set to <b>%s</b>.', tools.escape_html(username)),
{ parse_mode = 'html' }
)
end
-- Determine which Last.fm username to look up
local fm_user = nil
if message.command == 'lastfm' and message.args and message.args ~= '' then
fm_user = message.args:gsub('^@', '')
else
fm_user = ctx.redis.get('lastfm:' .. message.from.id)
if not fm_user then
return api.send_message(
message.chat.id,
'You haven\'t linked your Last.fm account. Use /fmset <username> to link it.'
)
end
end
-- Fetch recent tracks
local api_url = string.format(
'https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=%s&api_key=%s&format=json&limit=1',
url.escape(fm_user),
url.escape(api_key)
)
- local data, status = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data then
return api.send_message(message.chat.id, 'Failed to connect to Last.fm. Please try again later.')
end
if not data or not data.recenttracks or not data.recenttracks.track then
return api.send_message(message.chat.id, 'User not found or no recent tracks available.')
end
local tracks = data.recenttracks.track
if type(tracks) ~= 'table' or #tracks == 0 then
return api.send_message(message.chat.id, 'No recent tracks found for ' .. tools.escape_html(fm_user) .. '.')
end
local track = tracks[1]
local artist = track.artist and (track.artist['#text'] or track.artist.name) or 'Unknown Artist'
local title = track.name or 'Unknown Track'
local album = track.album and track.album['#text'] or nil
local now_playing = track['@attr'] and track['@attr'].nowplaying == 'true'
local lines = {}
local tg_name = tools.escape_html(message.from.first_name)
if now_playing then
table.insert(lines, string.format('%s is now listening to:', tg_name))
else
table.insert(lines, string.format('%s last listened to:', tg_name))
end
table.insert(lines, '')
table.insert(lines, string.format('<b>%s</b> - %s', tools.escape_html(title), tools.escape_html(artist)))
if album and album ~= '' then
table.insert(lines, string.format('Album: <i>%s</i>', tools.escape_html(album)))
end
-- Fetch playcount for this user
local user_url = string.format(
'https://ws.audioscrobbler.com/2.0/?method=user.getinfo&user=%s&api_key=%s&format=json',
url.escape(fm_user),
url.escape(api_key)
)
- local user_data, user_status = http.get_json(user_url)
+ local user_data, _ = http.get_json(user_url)
if user_data then
if user_data.user and user_data.user.playcount then
table.insert(lines, string.format('\nTotal scrobbles: <code>%s</code>', user_data.user.playcount))
end
end
local keyboard = api.inline_keyboard():row(
api.row():url_button('View on Last.fm', 'https://www.last.fm/user/' .. url.escape(fm_user))
)
return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
end
return plugin
diff --git a/src/plugins/utility/pokedex.lua b/src/plugins/utility/pokedex.lua
index 3ca96bd..81a63c1 100644
--- a/src/plugins/utility/pokedex.lua
+++ b/src/plugins/utility/pokedex.lua
@@ -1,106 +1,106 @@
--[[
mattata v2.0 - Pokedex Plugin
Fetches Pokemon information from PokeAPI.
]]
local plugin = {}
plugin.name = 'pokedex'
plugin.category = 'utility'
plugin.description = 'Look up Pokemon information'
plugin.commands = { 'pokedex', 'pokemon', 'dex' }
plugin.help = '/pokedex <name|id> - Look up information about a Pokemon.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local tools = require('telegram-bot-lua.tools')
local input = message.args
if not input or input == '' then
return api.send_message(message.chat.id, 'Please specify a Pokemon name or ID. Usage: /pokedex <name|id>')
end
local query = input:lower():gsub('%s+', '-')
local api_url = 'https://pokeapi.co/api/v2/pokemon/' .. query
- local data, code = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data then
return api.send_message(message.chat.id, 'Pokemon not found. Please check the name or ID and try again.')
end
if not data then
return api.send_message(message.chat.id, 'Failed to parse Pokemon data.')
end
-- Capitalise name
local name = (data.name or query):gsub('^%l', string.upper):gsub('%-(%l)', function(c) return '-' .. c:upper() end)
-- Types
local types = {}
if data.types then
for _, t in ipairs(data.types) do
if t.type and t.type.name then
table.insert(types, t.type.name:gsub('^%l', string.upper))
end
end
end
-- Abilities
local abilities = {}
if data.abilities then
for _, a in ipairs(data.abilities) do
if a.ability and a.ability.name then
local ability_name = a.ability.name:gsub('^%l', string.upper):gsub('%-(%l)', function(c) return '-' .. c:upper() end)
if a.is_hidden then
ability_name = ability_name .. ' (Hidden)'
end
table.insert(abilities, ability_name)
end
end
end
-- Base stats
local stats = {}
if data.stats then
for _, s in ipairs(data.stats) do
if s.stat and s.stat.name then
local stat_name = s.stat.name:upper():gsub('%-', ' ')
stats[stat_name] = s.base_stat
end
end
end
local lines = {
string.format('<b>#%d - %s</b>', data.id or 0, tools.escape_html(name)),
''
}
if #types > 0 then
table.insert(lines, 'Type: <code>' .. table.concat(types, ', ') .. '</code>')
end
table.insert(lines, string.format('Height: <code>%.1fm</code>', (data.height or 0) / 10))
table.insert(lines, string.format('Weight: <code>%.1fkg</code>', (data.weight or 0) / 10))
if #abilities > 0 then
table.insert(lines, 'Abilities: <code>' .. table.concat(abilities, ', ') .. '</code>')
end
if next(stats) then
table.insert(lines, '')
table.insert(lines, '<b>Base Stats</b>')
local stat_order = { 'HP', 'ATTACK', 'DEFENSE', 'SPECIAL ATTACK', 'SPECIAL DEFENSE', 'SPEED' }
for _, stat_name in ipairs(stat_order) do
if stats[stat_name] then
table.insert(lines, string.format('%s: <code>%d</code>', stat_name, stats[stat_name]))
end
end
end
-- Send sprite if available
local sprite = data.sprites and data.sprites.front_default
if sprite then
return api.send_photo(message.chat.id, sprite, { caption = table.concat(lines, '\n'), parse_mode = 'html' })
end
return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/setloc.lua b/src/plugins/utility/setloc.lua
index 5a83c6e..0179817 100644
--- a/src/plugins/utility/setloc.lua
+++ b/src/plugins/utility/setloc.lua
@@ -1,70 +1,70 @@
--[[
mattata v2.0 - Set Location Plugin
Geocodes an address and stores latitude/longitude for weather and time plugins.
]]
local plugin = {}
plugin.name = 'setloc'
plugin.category = 'utility'
plugin.description = 'Set your location for weather and time commands'
plugin.commands = { 'setloc', 'setlocation', 'location' }
plugin.help = '/setloc <address> - Set your location by providing an address or place name.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local http = require('src.core.http')
local url = require('socket.url')
local input = message.args
if not input or input == '' then
-- Show current location
local result = ctx.db.call('sp_get_user_location', { message.from.id })
if result and result[1] then
return api.send_message(
message.chat.id,
string.format(
'Your location is set to: <b>%s</b>\n(<code>%s, %s</code>)',
tools.escape_html(result[1].address or 'Unknown'),
result[1].latitude,
result[1].longitude
),
{ parse_mode = 'html' }
)
end
return api.send_message(message.chat.id, 'You haven\'t set a location yet. Use /setloc <address> to set one.')
end
-- Geocode via Nominatim
local encoded = url.escape(input)
local api_url = string.format(
'https://nominatim.openstreetmap.org/search?q=%s&format=json&limit=1&addressdetails=1',
encoded
)
- local data, status = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data then
return api.send_message(message.chat.id, 'Failed to geocode that address. Please try again.')
end
if not data or #data == 0 then
return api.send_message(message.chat.id, 'No results found for that address. Please try a different query.')
end
local result = data[1]
local lat = tonumber(result.lat)
local lng = tonumber(result.lon)
local address = result.display_name or input
-- Upsert into user_locations
ctx.db.call('sp_upsert_user_location', { message.from.id, lat, lng, address })
return api.send_message(
message.chat.id,
string.format(
'Location set to: <b>%s</b>\n(<code>%s, %s</code>)',
tools.escape_html(address),
lat, lng
),
{ parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/utility/time.lua b/src/plugins/utility/time.lua
index 6949ee3..b3cd38b 100644
--- a/src/plugins/utility/time.lua
+++ b/src/plugins/utility/time.lua
@@ -1,141 +1,141 @@
--[[
mattata v2.0 - Time Plugin
Shows current time and date for a location.
Geocodes via Nominatim, then uses timeapi.io for timezone lookup.
Supports stored locations from setloc.
]]
local plugin = {}
plugin.name = 'time'
plugin.category = 'utility'
plugin.description = 'Get current time for a location'
plugin.commands = { 'time', 't', 'date', 'd' }
plugin.help = '/time [location] - Get the current time and date for a location. Uses your saved location if none is specified.'
local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
local function geocode(query)
local encoded = url.escape(query)
local request_url = 'https://nominatim.openstreetmap.org/search?q=' .. encoded .. '&format=json&limit=1&addressdetails=1'
- local data, code = http.get_json(request_url)
+ local data, _ = http.get_json(request_url)
if not data then
return nil, 'Geocoding request failed.'
end
if #data == 0 then
return nil, 'Location not found. Please check the spelling and try again.'
end
return {
lat = tonumber(data[1].lat),
lon = tonumber(data[1].lon),
name = data[1].display_name
}
end
local function get_timezone(lat, lon)
local request_url = string.format(
'https://timeapi.io/api/TimeZone/coordinate?latitude=%.6f&longitude=%.6f',
lat, lon
)
- local data, code = http.get_json(request_url)
+ local data, _ = http.get_json(request_url)
if not data or not data.timeZone then
return nil, 'Timezone lookup failed.'
end
return data
end
local function format_day_suffix(day)
local d = tonumber(day)
if d == 1 or d == 21 or d == 31 then return 'st'
elseif d == 2 or d == 22 then return 'nd'
elseif d == 3 or d == 23 then return 'rd'
else return 'th'
end
end
function plugin.on_message(api, message, ctx)
local input = message.args
local lat, lon, location_name
if not input or input == '' then
-- Try stored location
local result = ctx.db.call('sp_get_user_location', { message.from.id })
if result and result[1] then
lat = tonumber(result[1].latitude)
lon = tonumber(result[1].longitude)
location_name = result[1].address or string.format('%.4f, %.4f', lat, lon)
else
return api.send_message(
message.chat.id,
'Please specify a location or set your default with /setloc.\nUsage: <code>/time London</code>',
{ parse_mode = 'html' }
)
end
else
local geo, err = geocode(input)
if not geo then
return api.send_message(message.chat.id, err)
end
lat = geo.lat
lon = geo.lon
location_name = geo.name
end
local tz_data, err = get_timezone(lat, lon)
if not tz_data then
return api.send_message(message.chat.id, err)
end
local timezone = tz_data.timeZone or 'Unknown'
local current_time = tz_data.currentLocalTime or ''
local utc_offset = tz_data.currentUtcOffset and tz_data.currentUtcOffset.seconds or 0
local dst_active = tz_data.hasDayLightSaving and tz_data.isDayLightSavingActive
-- Parse the datetime string (format: "2024-01-15T14:30:00.0000000")
local year, month, day, hour, min, sec = current_time:match('(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)')
if not year then
return api.send_message(message.chat.id, 'Failed to parse time data from the API.')
end
local months = { 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December' }
local days_of_week = { 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' }
-- Calculate day of week using Tomohiko Sakamoto's algorithm
local y, m, d = tonumber(year), tonumber(month), tonumber(day)
local t_table = { 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4 }
if m < 3 then y = y - 1 end
local dow = (y + math.floor(y / 4) - math.floor(y / 100) + math.floor(y / 400) + t_table[m] + d) % 7 + 1
local day_suffix = format_day_suffix(day)
local offset_hours = utc_offset / 3600
local offset_str
if offset_hours >= 0 then
offset_str = string.format('+%g', offset_hours)
else
offset_str = string.format('%g', offset_hours)
end
local lines = {
'<b>' .. tools.escape_html(location_name) .. '</b>',
'',
string.format('Time: <b>%s:%s:%s</b>', hour, min, sec),
string.format('Date: <b>%s, %d%s %s %s</b>',
days_of_week[dow],
tonumber(day), day_suffix,
months[tonumber(month)],
year
),
string.format('Timezone: <code>%s</code> (UTC%s)', tools.escape_html(timezone), offset_str)
}
if dst_active then
table.insert(lines, 'DST: Active')
end
return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/urbandictionary.lua b/src/plugins/utility/urbandictionary.lua
index 63ebb83..2797e5a 100644
--- a/src/plugins/utility/urbandictionary.lua
+++ b/src/plugins/utility/urbandictionary.lua
@@ -1,69 +1,69 @@
--[[
mattata v2.0 - Urban Dictionary Plugin
Looks up definitions from Urban Dictionary.
]]
local plugin = {}
plugin.name = 'urbandictionary'
plugin.category = 'utility'
plugin.description = 'Look up definitions on Urban Dictionary'
plugin.commands = { 'urbandictionary', 'urban', 'ud' }
plugin.help = '/ud <word> - Look up a word on Urban Dictionary.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
local input = message.args
if not input or input == '' then
return api.send_message(message.chat.id, 'Please provide a word or phrase to look up. Usage: /ud <word>')
end
local encoded = url.escape(input)
local api_url = 'https://api.urbandictionary.com/v0/define?term=' .. encoded
- local data, code = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data then
return api.send_message(message.chat.id, 'Failed to connect to Urban Dictionary. Please try again later.')
end
if not data or not data.list or #data.list == 0 then
return api.send_message(message.chat.id, 'No definitions found for "' .. tools.escape_html(input) .. '".')
end
local entry = data.list[1]
-- Clean up brackets used for linking on the website
local definition = (entry.definition or ''):gsub('%[', ''):gsub('%]', '')
local example = (entry.example or ''):gsub('%[', ''):gsub('%]', '')
-- Truncate long definitions
if #definition > 1500 then
definition = definition:sub(1, 1500) .. '...'
end
local lines = {
string.format('<b>%s</b>', tools.escape_html(entry.word or input)),
'',
tools.escape_html(definition)
}
if example and example ~= '' then
if #example > 500 then
example = example:sub(1, 500) .. '...'
end
table.insert(lines, '')
table.insert(lines, '<i>' .. tools.escape_html(example) .. '</i>')
end
if entry.thumbs_up or entry.thumbs_down then
table.insert(lines, '')
table.insert(lines, string.format(
'👍 %d 👎 %d',
entry.thumbs_up or 0,
entry.thumbs_down or 0
))
end
return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/weather.lua b/src/plugins/utility/weather.lua
index e40d9c6..63695cd 100644
--- a/src/plugins/utility/weather.lua
+++ b/src/plugins/utility/weather.lua
@@ -1,150 +1,150 @@
--[[
mattata v2.0 - Weather Plugin
Shows current weather for a location using Open-Meteo (no API key needed).
Geocodes via Nominatim (OpenStreetMap). Supports stored locations from setloc.
]]
local plugin = {}
plugin.name = 'weather'
plugin.category = 'utility'
plugin.description = 'Get current weather for a location'
plugin.commands = { 'weather' }
plugin.help = '/weather [location] - Get current weather for a location. If no location is given, your saved location is used (set with /setloc).'
local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
-- WMO weather codes to human-readable descriptions
local WMO_CODES = {
[0] = 'Clear sky',
[1] = 'Mainly clear',
[2] = 'Partly cloudy',
[3] = 'Overcast',
[45] = 'Foggy',
[48] = 'Depositing rime fog',
[51] = 'Light drizzle',
[53] = 'Moderate drizzle',
[55] = 'Dense drizzle',
[56] = 'Light freezing drizzle',
[57] = 'Dense freezing drizzle',
[61] = 'Slight rain',
[63] = 'Moderate rain',
[65] = 'Heavy rain',
[66] = 'Light freezing rain',
[67] = 'Heavy freezing rain',
[71] = 'Slight snowfall',
[73] = 'Moderate snowfall',
[75] = 'Heavy snowfall',
[77] = 'Snow grains',
[80] = 'Slight rain showers',
[81] = 'Moderate rain showers',
[82] = 'Violent rain showers',
[85] = 'Slight snow showers',
[86] = 'Heavy snow showers',
[95] = 'Thunderstorm',
[96] = 'Thunderstorm with slight hail',
[99] = 'Thunderstorm with heavy hail'
}
local function geocode(query)
local encoded = url.escape(query)
local request_url = 'https://nominatim.openstreetmap.org/search?q=' .. encoded .. '&format=json&limit=1&addressdetails=1'
- local data, code = http.get_json(request_url)
+ local data, _ = http.get_json(request_url)
if not data then
return nil, 'Geocoding request failed.'
end
if #data == 0 then
return nil, 'Location not found. Please check the spelling and try again.'
end
return {
lat = tonumber(data[1].lat),
lon = tonumber(data[1].lon),
name = data[1].display_name
}
end
local function get_weather(lat, lon)
local request_url = string.format(
'https://api.open-meteo.com/v1/forecast?latitude=%.6f&longitude=%.6f'
.. '&current=temperature_2m,relative_humidity_2m,apparent_temperature'
.. ',weather_code,wind_speed_10m,wind_direction_10m'
.. '&temperature_unit=celsius&wind_speed_unit=kmh',
lat, lon
)
- local data, code = http.get_json(request_url)
+ local data, _ = http.get_json(request_url)
if not data or not data.current then
return nil, 'Weather API request failed.'
end
return data.current
end
local function c_to_f(c)
return c * 9 / 5 + 32
end
local function wind_direction(degrees)
local dirs = { 'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW' }
local idx = math.floor((degrees / 22.5) + 0.5) % 16 + 1
return dirs[idx]
end
function plugin.on_message(api, message, ctx)
local input = message.args
local lat, lon, location_name
if not input or input == '' then
-- Try stored location
local result = ctx.db.call('sp_get_user_location', { message.from.id })
if result and result[1] then
lat = tonumber(result[1].latitude)
lon = tonumber(result[1].longitude)
location_name = result[1].address or string.format('%.4f, %.4f', lat, lon)
else
return api.send_message(
message.chat.id,
'Please specify a location or set your default with /setloc.\nUsage: <code>/weather London</code>',
{ parse_mode = 'html' }
)
end
else
local geo, err = geocode(input)
if not geo then
return api.send_message(message.chat.id, err)
end
lat = geo.lat
lon = geo.lon
location_name = geo.name
end
local weather, err = get_weather(lat, lon)
if not weather then
return api.send_message(message.chat.id, err)
end
local temp_c = weather.temperature_2m or 0
local feels_c = weather.apparent_temperature or 0
local humidity = weather.relative_humidity_2m or 0
local wind_speed = weather.wind_speed_10m or 0
local wind_dir = wind_direction(weather.wind_direction_10m or 0)
local conditions = WMO_CODES[weather.weather_code] or 'Unknown'
local output = string.format(
'<b>Weather for %s</b>\n\n'
.. 'Conditions: %s\n'
.. 'Temperature: <b>%.1f°C</b> / <b>%.1f°F</b>\n'
.. 'Feels like: %.1f°C / %.1f°F\n'
.. 'Humidity: %d%%\n'
.. 'Wind: %.1f km/h %s',
tools.escape_html(location_name),
conditions,
temp_c, c_to_f(temp_c),
feels_c, c_to_f(feels_c),
humidity,
wind_speed, wind_dir
)
return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/wikipedia.lua b/src/plugins/utility/wikipedia.lua
index 069b252..e424fc0 100644
--- a/src/plugins/utility/wikipedia.lua
+++ b/src/plugins/utility/wikipedia.lua
@@ -1,118 +1,118 @@
--[[
mattata v2.0 - Wikipedia Plugin
Looks up Wikipedia articles using the MediaWiki API.
]]
local plugin = {}
plugin.name = 'wikipedia'
plugin.category = 'utility'
plugin.description = 'Look up Wikipedia articles'
plugin.commands = { 'wikipedia', 'wiki', 'w' }
plugin.help = '/wiki <query> - Search Wikipedia for an article.'
local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
local search_wikipedia_fallback
local function search_wikipedia(query, lang)
lang = lang or 'en'
local encoded = url.escape(query)
local search_url = string.format(
'https://%s.wikipedia.org/api/rest_v1/page/summary/%s?redirect=true',
lang, encoded
)
- local data, code = http.get_json(search_url, { ['Accept'] = 'application/json' })
+ local data, _ = http.get_json(search_url, { ['Accept'] = 'application/json' })
if not data then
return search_wikipedia_fallback(query, lang)
end
if data.type == 'not_found' or data.type == 'https://mediawiki.org/wiki/HyperSwitch/errors/not_found' then
return search_wikipedia_fallback(query, lang)
end
return data
end
search_wikipedia_fallback = function(query, lang)
lang = lang or 'en'
local encoded = url.escape(query)
local search_url = string.format(
'https://%s.wikipedia.org/w/api.php?action=opensearch&search=%s&limit=1&format=json',
lang, encoded
)
local data, code = http.get_json(search_url)
if not data then
return nil, 'Wikipedia search failed (HTTP ' .. tostring(code) .. ').'
end
if not data[2] or #data[2] == 0 then
return nil, 'No Wikipedia articles found for that query.'
end
-- Fetch the summary for the first result
local title = data[2][1]
local title_encoded = url.escape(title)
local summary_url = string.format(
'https://%s.wikipedia.org/api/rest_v1/page/summary/%s?redirect=true',
lang, title_encoded
)
- local summary, summary_code = http.get_json(summary_url, { ['Accept'] = 'application/json' })
+ local summary, _ = http.get_json(summary_url, { ['Accept'] = 'application/json' })
if not summary then
return nil, 'Failed to retrieve article summary.'
end
return summary
end
function plugin.on_message(api, message, ctx)
local input = message.args
if not input or input == '' then
return api.send_message(
message.chat.id,
'Please provide a search term.\nUsage: <code>/wiki search term</code>',
{ parse_mode = 'html' }
)
end
local data, err = search_wikipedia(input)
if not data then
return api.send_message(message.chat.id, err or 'No Wikipedia articles found for that query.')
end
-- Handle disambiguation pages
if data.type == 'disambiguation' then
local output = string.format(
'<b>%s</b> (disambiguation)\n\n%s\n\n<a href="%s">View on Wikipedia</a>',
tools.escape_html(data.title or input),
tools.escape_html(data.extract or 'This is a disambiguation page.'),
tools.escape_html(data.content_urls and data.content_urls.desktop and data.content_urls.desktop.page or '')
)
return api.send_message(message.chat.id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true } })
end
local title = data.title or input
local extract = data.extract or data.description or 'No summary available.'
local page_url = data.content_urls and data.content_urls.desktop and data.content_urls.desktop.page or ''
-- Truncate long extracts
if #extract > 800 then
extract = extract:sub(1, 797) .. '...'
end
local lines = {
'<b>' .. tools.escape_html(title) .. '</b>'
}
if data.description and data.description ~= '' and data.description ~= extract then
table.insert(lines, '<i>' .. tools.escape_html(data.description) .. '</i>')
end
table.insert(lines, '')
table.insert(lines, tools.escape_html(extract))
if page_url ~= '' then
table.insert(lines, '')
table.insert(lines, '<a href="' .. tools.escape_html(page_url) .. '">Read more on Wikipedia</a>')
end
return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html', link_preview_options = { is_disabled = true } })
end
return plugin
diff --git a/src/plugins/utility/xkcd.lua b/src/plugins/utility/xkcd.lua
index 1649a71..132fa3a 100644
--- a/src/plugins/utility/xkcd.lua
+++ b/src/plugins/utility/xkcd.lua
@@ -1,62 +1,62 @@
--[[
mattata v2.0 - XKCD Plugin
Fetches XKCD comics.
]]
local plugin = {}
plugin.name = 'xkcd'
plugin.category = 'utility'
plugin.description = 'View XKCD comics'
plugin.commands = { 'xkcd' }
plugin.help = '/xkcd [number] - View an XKCD comic. If no number is given, shows the latest.'
function plugin.on_message(api, message, ctx)
local http = require('src.core.http')
local tools = require('telegram-bot-lua.tools')
local input = message.args
local api_url
if input and input:match('^%d+$') then
api_url = string.format('https://xkcd.com/%s/info.0.json', input)
elseif input and input:lower() == 'random' then
-- Fetch latest to get the max number, then pick random
- local latest, latest_code = http.get_json('https://xkcd.com/info.0.json')
+ local latest, _ = http.get_json('https://xkcd.com/info.0.json')
if latest and latest.num then
local random_num = math.random(1, latest.num)
api_url = string.format('https://xkcd.com/%d/info.0.json', random_num)
end
if not api_url then
return api.send_message(message.chat.id, 'Failed to fetch XKCD. Please try again.')
end
else
api_url = 'https://xkcd.com/info.0.json'
end
- local data, code = http.get_json(api_url)
+ local data, _ = http.get_json(api_url)
if not data then
return api.send_message(message.chat.id, 'Comic not found. Please check the number and try again.')
end
if not data then
return api.send_message(message.chat.id, 'Failed to parse XKCD response.')
end
local caption = string.format(
'<b>#%d - %s</b>\n<i>%s</i>',
data.num or 0,
tools.escape_html(data.title or 'Untitled'),
tools.escape_html(data.alt or '')
)
-- Send the comic image with caption
if data.img then
local keyboard = api.inline_keyboard():row(
api.row():url_button('View on xkcd.com', string.format('https://xkcd.com/%d/', data.num))
)
return api.send_photo(message.chat.id, data.img, { caption = caption, parse_mode = 'html', reply_markup = keyboard })
end
return api.send_message(message.chat.id, caption, { parse_mode = 'html' })
end
return plugin

File Metadata

Mime Type
text/x-diff
Expires
Wed, Apr 1, 11:14 AM (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
57008
Default Alt Text
(121 KB)

Event Timeline