Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
48 KB
Referenced Files
None
Subscribers
None
diff --git a/.env.example b/.env.example
index 3f865a1..0f41852 100644
--- a/.env.example
+++ b/.env.example
@@ -1,46 +1,47 @@
# mattata v2.0 Configuration
# Copy to .env and fill in required values
# Required
BOT_TOKEN=
BOT_ADMINS=221714512
BOT_NAME=mattata
# PostgreSQL (primary database)
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=mattata
DATABASE_USER=mattata
DATABASE_PASSWORD=changeme
# Redis (cache/sessions only)
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Mode: polling (default) or webhook
WEBHOOK_ENABLED=false
WEBHOOK_URL=
WEBHOOK_PORT=8443
WEBHOOK_SECRET=
POLLING_TIMEOUT=60
POLLING_LIMIT=100
# AI (disabled by default)
AI_ENABLED=false
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
# Optional API keys (core features work without any of these)
LASTFM_API_KEY=
YOUTUBE_API_KEY=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPAMWATCH_TOKEN=
+TENOR_API_KEY=
# Logging
LOG_CHAT=
DEBUG=false
diff --git a/src/core/config.lua b/src/core/config.lua
index 4366798..fbdef8c 100644
--- a/src/core/config.lua
+++ b/src/core/config.lua
@@ -1,163 +1,167 @@
--[[
mattata v2.0 - Configuration Module
Reads configuration from .env file with os.getenv() fallback.
Provides typed access to all configuration values.
]]
local config = {}
local env_values = {}
local loaded = false
-- Parse a .env file into a table
local function parse_env_file(path)
local values = {}
local file = io.open(path, 'r')
if not file then
return values
end
for line in file:lines() do
line = line:match('^%s*(.-)%s*$') -- trim
if line ~= '' and not line:match('^#') then
local key, value = line:match('^([%w_]+)%s*=%s*(.*)$')
if key then
-- Strip surrounding quotes
- value = value:match('^"(.*)"$') or value:match("^'(.*)'$") or value
- -- Strip inline comments (only for unquoted values)
- value = value:match('^(.-)%s+#') or value
+ local quoted = value:match('^"(.*)"$') or value:match("^'(.*)'$")
+ if quoted then
+ value = quoted
+ else
+ -- Strip inline comments only for unquoted values
+ value = value:match('^(.-)%s+#') or value
+ end
values[key] = value
end
end
end
file:close()
return values
end
-- Load .env file (called once)
function config.load(path)
path = path or '.env'
env_values = parse_env_file(path)
loaded = true
end
-- Get a string value with optional default
function config.get(key, default)
if not loaded then
config.load()
end
local value = env_values[key]
if value == nil or value == '' then
value = os.getenv(key)
end
if value == nil or value == '' then
return default
end
return value
end
-- Get a numeric value
function config.get_number(key, default)
local value = config.get(key)
if value == nil then
return default
end
return tonumber(value) or default
end
-- Get a boolean value
function config.is_enabled(key)
local value = config.get(key)
if value == nil then
return false
end
value = value:lower()
return value == 'true' or value == '1' or value == 'yes'
end
-- Get a comma-separated list as a table
function config.get_list(key)
local value = config.get(key)
if not value or value == '' then
return {}
end
local list = {}
for item in value:gmatch('[^,]+') do
item = item:match('^%s*(.-)%s*$')
if item ~= '' then
local num = tonumber(item)
table.insert(list, num or item)
end
end
return list
end
-- Convenience accessors for common config groups
function config.bot_token()
return config.get('BOT_TOKEN')
end
function config.bot_admins()
return config.get_list('BOT_ADMINS')
end
function config.bot_name()
return config.get('BOT_NAME', 'mattata')
end
function config.database()
return {
host = config.get('DATABASE_HOST', 'postgres'),
port = config.get_number('DATABASE_PORT', 5432),
database = config.get('DATABASE_NAME', 'mattata'),
user = config.get('DATABASE_USER', 'mattata'),
password = config.get('DATABASE_PASSWORD', 'changeme')
}
end
function config.redis_config()
return {
host = config.get('REDIS_HOST', 'redis'),
port = config.get_number('REDIS_PORT', 6379),
password = config.get('REDIS_PASSWORD'),
db = config.get_number('REDIS_DB', 0)
}
end
function config.polling()
return {
timeout = config.get_number('POLLING_TIMEOUT', 60),
limit = config.get_number('POLLING_LIMIT', 100)
}
end
function config.webhook()
return {
enabled = config.is_enabled('WEBHOOK_ENABLED'),
url = config.get('WEBHOOK_URL'),
port = config.get_number('WEBHOOK_PORT', 8443),
secret = config.get('WEBHOOK_SECRET')
}
end
function config.ai()
return {
enabled = config.is_enabled('AI_ENABLED'),
openai_key = config.get('OPENAI_API_KEY'),
openai_model = config.get('OPENAI_MODEL', 'gpt-4o'),
anthropic_key = config.get('ANTHROPIC_API_KEY'),
anthropic_model = config.get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929')
}
end
function config.debug()
return config.is_enabled('DEBUG')
end
function config.log_chat()
return config.get_number('LOG_CHAT')
end
-- Version constant
config.VERSION = '2.1'
return config
diff --git a/src/core/router.lua b/src/core/router.lua
index 321828a..fa368b9 100644
--- a/src/core/router.lua
+++ b/src/core/router.lua
@@ -1,410 +1,414 @@
--[[
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 json = require('dkjson')
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
-- Admin check is lazy — only resolved when ctx:check_admin() is called
local function build_ctx(message)
local ctx = {}
for k, v in pairs(ctx_base) do
ctx[k] = v
end
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
-- For backward compat: admin plugins that check ctx.is_admin will still
-- need to call ctx:check_admin() first. The router does this for admin_only plugins.
ctx.is_mod = false
return ctx
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) and true or false
-- 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)
message.text = message.text:gsub(name, 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 (with Redis caching)
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
-- Cache alias lookups with TTL instead of hgetall on every message
local cache_key = 'cache:aliases:' .. message.chat.id
local cached_aliases = redis_mod.get(cache_key)
local aliases
if cached_aliases then
local ok, decoded = pcall(json.decode, cached_aliases)
if ok and decoded then
aliases = decoded
end
end
if not aliases then
aliases = redis_mod.hgetall('chat:' .. message.chat.id .. ':aliases')
if type(aliases) == 'table' then
pcall(function()
redis_mod.setex(cache_key, 300, json.encode(aliases))
end)
end
end
if type(aliases) == 'table' then
for alias, original in pairs(aliases) do
if command == alias then
message.text = '/' .. original .. (rest or '')
message.is_alias = true
break
end
end
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
-- 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 '')
), 'html')
end
end
end
end
end
-- Run passive handlers (on_new_message) for all non-disabled plugins
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_new_message and not session.is_plugin_disabled(message.chat.id, 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
-- Handle member join events
if message.new_chat_members and plugin.on_member_join then
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 or {
- chat = {},
- message_id = callback_query.inline_message_id,
- from = callback_query.from
- }
+ 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' } })
local lang_code = session.get_setting(inline_query.from.id, 'language') or 'en_gb'
ctx.lang = i18n.get(lang_code)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.on_inline_query then
local ok, err = pcall(plugin.on_inline_query, api, inline_query, ctx)
if not ok then
logger.error('Plugin %s.on_inline_query 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
api.on_callback_query = function(cb)
local ok, err = pcall(on_callback_query, cb)
if not ok then logger.error('on_callback_query error: %s', tostring(err)) 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
-- Cron: copas background thread, runs every 60s
copas.addthread(function()
while true do
copas.pause(60)
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.cron then
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
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'
}
})
end
return router
diff --git a/src/middleware/blocklist.lua b/src/middleware/blocklist.lua
index 6197b74..78ff6c5 100644
--- a/src/middleware/blocklist.lua
+++ b/src/middleware/blocklist.lua
@@ -1,74 +1,125 @@
--[[
- mattata v2.0 - Blocklist Middleware
+ mattata v2.1 - Blocklist Middleware
Checks global bans, group bans, and SpamWatch. Stops if blocked.
]]
local blocklist = {}
blocklist.name = 'blocklist'
local config = require('src.core.config')
local session = require('src.core.session')
+local logger = require('src.core.logger')
+
+-- SpamWatch async check with Redis caching
+local function check_spamwatch(ctx, user_id, token)
+ -- Check cache first (positive = banned, negative = not banned)
+ local ban_cached = ctx.redis.get('spamwatch:ban:' .. user_id)
+ if ban_cached then
+ return true
+ end
+ local safe_cached = ctx.redis.get('spamwatch:safe:' .. user_id)
+ if safe_cached then
+ return false
+ end
+
+ -- Async HTTPS check
+ local ok, result = pcall(function()
+ local https = require('ssl.https')
+ local ltn12 = require('ltn12')
+ local response_body = {}
+ local _, code = https.request({
+ url = 'https://api.spamwat.ch/banlist/' .. tostring(user_id),
+ method = 'GET',
+ sink = ltn12.sink.table(response_body),
+ headers = {
+ ['Authorization'] = 'Bearer ' .. token,
+ ['Accept'] = 'application/json'
+ }
+ })
+ return code
+ end)
+
+ if ok then
+ if result == 200 then
+ -- User is banned on SpamWatch, cache for 1 hour
+ ctx.redis.setex('spamwatch:ban:' .. user_id, 3600, '1')
+ return true
+ else
+ -- Not banned (or error), cache safe status for 1 hour
+ ctx.redis.setex('spamwatch:safe:' .. user_id, 3600, '1')
+ return false
+ end
+ else
+ logger.warn('SpamWatch API error for user %s: %s', tostring(user_id), tostring(result))
+ return false
+ end
+end
function blocklist.run(ctx, message)
if not message.from then
return ctx, false
end
local user_id = message.from.id
-- Global admins are never blocked
if ctx.is_global_admin then
return ctx, true
end
-- Check global blocklist
if session.is_globally_blocklisted(user_id) then
ctx.is_blocklisted = true
return ctx, false
end
-- Check global ban (federation-level)
local global_ban = ctx.redis.get('global_ban:' .. user_id)
if global_ban then
ctx.is_globally_banned = true
-- Auto-ban in groups
if ctx.is_group then
pcall(function()
ctx.api.ban_chat_member(message.chat.id, user_id)
end)
end
return ctx, false
end
-- Check per-group blocklist
if ctx.is_group then
local group_blocked = ctx.redis.get('group_blocklist:' .. message.chat.id .. ':' .. user_id)
if group_blocked then
ctx.is_group_blocklisted = true
return ctx, false
end
-- Check blocklisted chats
local chat_blocked = ctx.redis.get('blocklisted_chats:' .. message.chat.id)
if chat_blocked then
pcall(function()
ctx.api.leave_chat(message.chat.id)
end)
return ctx, false
end
end
-- SpamWatch check (if configured)
local spamwatch_token = config.get('SPAMWATCH_TOKEN')
if spamwatch_token and spamwatch_token ~= '' then
- local cached = ctx.redis.get('not_blocklisted:' .. user_id)
- if not cached then
- -- Check will be done asynchronously in future; for now just mark as not checked
- ctx.spamwatch_checked = false
+ local is_banned = check_spamwatch(ctx, user_id, spamwatch_token)
+ if is_banned then
+ ctx.is_spamwatch_banned = true
+ if ctx.is_group then
+ pcall(function()
+ ctx.api.ban_chat_member(message.chat.id, user_id)
+ end)
+ end
+ return ctx, false
end
end
return ctx, true
end
return blocklist
diff --git a/src/plugins/admin/join_captcha.lua b/src/plugins/admin/join_captcha.lua
index afbef6b..8344f7e 100644
--- a/src/plugins/admin/join_captcha.lua
+++ b/src/plugins/admin/join_captcha.lua
@@ -1,166 +1,178 @@
--[[
mattata v2.0 - Join Captcha Plugin
Handles captcha verification for new members joining the group.
]]
local plugin = {}
plugin.name = 'join_captcha'
plugin.category = 'admin'
plugin.description = 'Captcha challenge for new members'
plugin.commands = {}
plugin.help = ''
plugin.group_only = true
plugin.admin_only = false
local json = require('dkjson')
-- Generate a simple math captcha
local function generate_captcha()
math.randomseed(os.time())
local a = math.random(1, 20)
local b = math.random(1, 20)
local operators = { '+', '-' }
local op = operators[math.random(1, 2)]
local answer
if op == '+' then
answer = a + b
else
-- Ensure non-negative result
if a < b then a, b = b, a end
answer = a - b
end
return string.format('%d %s %d', a, op, b), tostring(answer)
end
-- Generate wrong answers for the keyboard
local function generate_options(correct_answer)
local options = { correct_answer }
local correct_num = tonumber(correct_answer)
while #options < 4 do
local wrong = correct_num + math.random(-5, 5)
if wrong ~= correct_num and wrong >= 0 then
local str = tostring(wrong)
local duplicate = false
for _, v in ipairs(options) do
if v == str then duplicate = true; break end
end
if not duplicate then
table.insert(options, str)
end
end
end
-- Shuffle
for i = #options, 2, -1 do
local j = math.random(1, i)
options[i], options[j] = options[j], options[i]
end
return options
end
function plugin.on_member_join(api, message, ctx)
if not ctx.is_group then return end
-- Check if captcha is enabled
local enabled = ctx.db.execute(
"SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'captcha_enabled'",
{ message.chat.id }
)
if not enabled or #enabled == 0 or enabled[1].value ~= 'true' then
return
end
if not require('src.core.permissions').can_restrict(api, message.chat.id) then return end
local timeout_result = ctx.db.execute(
"SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'captcha_timeout'",
{ message.chat.id }
)
local timeout = (timeout_result and #timeout_result > 0) and tonumber(timeout_result[1].value) or 300
for _, new_member in ipairs(message.new_chat_members) do
if new_member.is_bot then goto continue end
-- Restrict the new member
- api.restrict_chat_member(message.chat.id, new_member.id, os.time() + timeout, {
+ api.restrict_chat_member(message.chat.id, new_member.id, {
can_send_messages = false,
- can_send_media_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
- })
+ }, { until_date = os.time() + timeout })
-- Generate captcha
local question, answer = generate_captcha()
local options = generate_options(answer)
-- Build keyboard
local keyboard = { inline_keyboard = { {} } }
for _, opt in ipairs(options) do
table.insert(keyboard.inline_keyboard[1], {
text = opt,
callback_data = string.format('join_captcha:%s:%s:%s', message.chat.id, new_member.id, opt)
})
end
local tools = require('telegram-bot-lua.tools')
local text = string.format(
'Welcome, <a href="tg://user?id=%d">%s</a>! Please solve this to verify you\'re human:\n\n<b>What is %s?</b>\n\nYou have %d seconds.',
new_member.id,
tools.escape_html(new_member.first_name),
question,
timeout
)
local sent = api.send_message(message.chat.id, text, 'html', false, false, nil, json.encode(keyboard))
-- Store captcha state
if sent and sent.result then
ctx.session.set_captcha(message.chat.id, new_member.id, answer, sent.result.message_id, timeout)
end
::continue::
end
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local data = callback_query.data
if not data then return end
local chat_id, user_id, selected = data:match('^(%-?%d+):(%d+):(.+)$')
if not chat_id then return end
chat_id = tonumber(chat_id)
user_id = tonumber(user_id)
-- Only the joining user can answer
if callback_query.from.id ~= user_id then
return api.answer_callback_query(callback_query.id, 'This captcha is not for you.')
end
local captcha = ctx.session.get_captcha(chat_id, user_id)
if not captcha then
return api.answer_callback_query(callback_query.id, 'This captcha has expired.')
end
if selected == captcha.text then
-- Correct answer - unrestrict user
- api.restrict_chat_member(chat_id, user_id, 0, {
+ api.restrict_chat_member(chat_id, user_id, {
can_send_messages = true,
- can_send_media_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
})
ctx.session.clear_captcha(chat_id, user_id)
local tools = require('telegram-bot-lua.tools')
api.edit_message_text(message.chat.id, message.message_id, string.format(
'<a href="tg://user?id=%d">%s</a> has been verified. Welcome!',
user_id, tools.escape_html(callback_query.from.first_name)
), 'html')
api.answer_callback_query(callback_query.id, 'Correct! Welcome to the group.')
else
-- Wrong answer
api.answer_callback_query(callback_query.id, 'Wrong answer. Try again!')
end
end
return plugin
diff --git a/src/plugins/admin/purge.lua b/src/plugins/admin/purge.lua
index c28ca04..48f78d4 100644
--- a/src/plugins/admin/purge.lua
+++ b/src/plugins/admin/purge.lua
@@ -1,53 +1,71 @@
--[[
- mattata v2.0 - Purge Plugin
+ mattata v2.1 - Purge Plugin
+ Batch-deletes messages using delete_messages API for efficiency.
]]
local plugin = {}
plugin.name = 'purge'
plugin.category = 'admin'
plugin.description = 'Delete messages in bulk'
plugin.commands = { 'purge' }
plugin.help = '/purge - Deletes all messages from the replied-to message up to the command message.'
plugin.group_only = true
plugin.admin_only = true
+local BATCH_SIZE = 100
+
function plugin.on_message(api, message, ctx)
local permissions = require('src.core.permissions')
if not permissions.can_delete(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Delete Messages" admin permission to use this command.')
end
if not message.reply then
return api.send_message(message.chat.id, 'Please reply to the first message you want to delete, and all messages from that point to your command will be purged.')
end
local start_id = message.reply.message_id
local end_id = message.message_id
local count = 0
local failed = 0
+ -- Batch into groups of up to 100 and use delete_messages
+ local batch = {}
for msg_id = start_id, end_id do
- local success = api.delete_message(message.chat.id, msg_id)
+ table.insert(batch, msg_id)
+ if #batch >= BATCH_SIZE then
+ local success = api.delete_messages(message.chat.id, batch)
+ if success then
+ count = count + #batch
+ else
+ failed = failed + #batch
+ end
+ batch = {}
+ end
+ end
+ -- Delete remaining messages
+ if #batch > 0 then
+ local success = api.delete_messages(message.chat.id, batch)
if success then
- count = count + 1
+ count = count + #batch
else
- failed = failed + 1
+ failed = failed + #batch
end
end
pcall(function()
ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, nil, 'purge', string.format('Purged %d messages (%d failed)', count, failed) })
end)
local status = api.send_message(message.chat.id, string.format('Purged <b>%d</b> message(s).', count), 'html')
- -- auto-delete the status message after a short delay
+ -- Auto-delete the status message after a short delay using copas (non-blocking)
if status and status.result then
pcall(function()
- local socket = require('socket')
- socket.sleep(3)
+ local copas = require('copas')
+ copas.pause(3)
api.delete_message(message.chat.id, status.result.message_id)
end)
end
end
return plugin
diff --git a/src/plugins/admin/wordfilter.lua b/src/plugins/admin/wordfilter.lua
index 28b9e32..5b5bf7c 100644
--- a/src/plugins/admin/wordfilter.lua
+++ b/src/plugins/admin/wordfilter.lua
@@ -1,94 +1,103 @@
--[[
mattata v2.0 - Word Filter Plugin
]]
local plugin = {}
plugin.name = 'wordfilter'
plugin.category = 'admin'
plugin.description = 'Toggle word filter and process filtered messages'
plugin.commands = { 'wordfilter' }
plugin.help = '/wordfilter <on|off> - Toggle word filtering. Filtered words are managed with /filter and /unfilter.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if not message.args then
local enabled = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'wordfilter_enabled' })
local status = (enabled and #enabled > 0 and enabled[1].value == 'true') and 'enabled' or 'disabled'
return api.send_message(message.chat.id, string.format(
'Word filter is currently <b>%s</b>.\nUsage: /wordfilter <on|off>', status
), 'html')
end
local arg = message.args:lower()
if arg == 'on' or arg == 'enable' then
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'wordfilter_enabled', 'true' })
require('src.core.session').invalidate_setting(message.chat.id, 'wordfilter_enabled')
return api.send_message(message.chat.id, 'Word filter has been enabled.')
elseif arg == 'off' or arg == 'disable' then
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'wordfilter_enabled', 'false' })
require('src.core.session').invalidate_setting(message.chat.id, 'wordfilter_enabled')
return api.send_message(message.chat.id, 'Word filter has been disabled.')
else
return api.send_message(message.chat.id, 'Usage: /wordfilter <on|off>')
end
end
function plugin.on_new_message(api, message, ctx)
if not ctx.is_group or not message.text or message.text == '' then return end
if ctx.is_admin or ctx.is_global_admin then return end
if not require('src.core.permissions').can_delete(api, message.chat.id) then return end
-- check if wordfilter is enabled (cached)
local session = require('src.core.session')
local enabled = session.get_cached_setting(message.chat.id, 'wordfilter_enabled', function()
local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'wordfilter_enabled' })
if result and #result > 0 then return result[1].value end
return nil
end, 300)
if enabled ~= 'true' then
return
end
-- get filters for this chat (cached)
local filters = session.get_cached_list(message.chat.id, 'filters', function()
return ctx.db.call('sp_get_filters', { message.chat.id })
end, 300)
if not filters or #filters == 0 then return end
local text = message.text:lower()
for _, f in ipairs(filters) do
local match = pcall(function()
return text:match(f.pattern:lower())
end)
if match and text:match(f.pattern:lower()) then
-- execute action
if f.action == 'delete' then
api.delete_message(message.chat.id, message.message_id)
elseif f.action == 'warn' then
api.delete_message(message.chat.id, message.message_id)
local hash = string.format('chat:%s:%s', message.chat.id, message.from.id)
ctx.redis.hincrby(hash, 'warnings', 1)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been warned for using a filtered word.',
message.from.id, require('telegram-bot-lua.tools').escape_html(message.from.first_name)
), 'html')
elseif f.action == 'ban' then
api.delete_message(message.chat.id, message.message_id)
api.ban_chat_member(message.chat.id, message.from.id)
elseif f.action == 'kick' then
api.delete_message(message.chat.id, message.message_id)
api.ban_chat_member(message.chat.id, message.from.id)
api.unban_chat_member(message.chat.id, message.from.id)
elseif f.action == 'mute' then
api.delete_message(message.chat.id, message.message_id)
- api.restrict_chat_member(message.chat.id, message.from.id, os.time() + 3600, {
- can_send_messages = false
- })
+ api.restrict_chat_member(message.chat.id, message.from.id, {
+ 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
+ }, { until_date = os.time() + 3600 })
end
return
end
end
end
return plugin
diff --git a/src/plugins/fun/aesthetic.lua b/src/plugins/fun/aesthetic.lua
index 82e63bc..1546f00 100644
--- a/src/plugins/fun/aesthetic.lua
+++ b/src/plugins/fun/aesthetic.lua
@@ -1,51 +1,55 @@
--[[
mattata v2.0 - Aesthetic Plugin
Converts ASCII text to fullwidth Unicode characters (vaporwave style).
]]
local plugin = {}
plugin.name = 'aesthetic'
plugin.category = 'fun'
plugin.description = 'Convert text to fullwidth aesthetic characters'
plugin.commands = { 'aesthetic', 'fullwidth', 'fw' }
plugin.help = '/aesthetic <text> - Convert text to fullwidth vaporwave text. Use in reply to convert the replied message.'
-- Fullwidth characters start at U+FF01 for '!' (0x21) through U+FF5E for '~' (0x7E).
-- Space (0x20) maps to ideographic space U+3000.
+-- Multi-byte UTF-8 characters are passed through unchanged.
local function to_fullwidth(text)
local result = {}
- for i = 1, #text do
- local byte = text:byte(i)
- if byte == 0x20 then
+ for char in text:gmatch('[\1-\127\194-\244][\128-\191]*') do
+ local byte = char:byte(1)
+ if #char > 1 then
+ -- Multi-byte UTF-8 character, pass through unchanged
+ table.insert(result, char)
+ elseif byte == 0x20 then
-- ASCII space -> ideographic space U+3000
table.insert(result, '\xE3\x80\x80')
elseif byte >= 0x21 and byte <= 0x7E then
-- ASCII printable -> fullwidth equivalent
-- U+FF01 + (byte - 0x21)
local codepoint = 0xFF01 + (byte - 0x21)
-- Encode as UTF-8 (3-byte sequence for U+FF01..U+FF5E)
local b1 = 0xE0 + math.floor(codepoint / 4096)
local b2 = 0x80 + math.floor((codepoint % 4096) / 64)
local b3 = 0x80 + (codepoint % 64)
table.insert(result, string.char(b1, b2, b3))
else
- table.insert(result, string.char(byte))
+ table.insert(result, char)
end
end
return table.concat(result)
end
function plugin.on_message(api, message, ctx)
local input
if message.reply and message.reply.text and message.reply.text ~= '' then
input = message.reply.text
elseif message.args and message.args ~= '' then
input = message.args
else
return api.send_message(message.chat.id, 'Please provide some text, or use this command in reply to a message.')
end
local output = to_fullwidth(input)
return api.send_message(message.chat.id, output)
end
return plugin
diff --git a/src/plugins/fun/flip.lua b/src/plugins/fun/flip.lua
index 6c5e512..210f0d4 100644
--- a/src/plugins/fun/flip.lua
+++ b/src/plugins/fun/flip.lua
@@ -1,65 +1,65 @@
--[[
mattata v2.0 - Flip Plugin
Reverse and flip text using upside-down Unicode characters.
]]
local plugin = {}
plugin.name = 'flip'
plugin.category = 'fun'
plugin.description = 'Flip text upside down'
plugin.commands = { 'flip', 'reverse' }
plugin.help = '/flip <text> - Flip text upside down. Use in reply to flip the replied message.'
local FLIP_MAP = {
['a'] = '\xC9\x90', ['b'] = 'q', ['c'] = '\xC9\x94', ['d'] = 'p',
['e'] = '\xC7\x9D', ['f'] = '\xC9\x9F', ['g'] = '\xC6\x83', ['h'] = '\xC9\xA5',
['i'] = '\xE1\xB4\x89', ['j'] = '\xC9\xBE', ['k'] = '\xCA\x9E', ['l'] = 'l',
['m'] = '\xC9\xAF', ['n'] = 'u', ['o'] = 'o', ['p'] = 'd',
['q'] = 'b', ['r'] = '\xC9\xB9', ['s'] = 's', ['t'] = '\xCA\x87',
['u'] = 'n', ['v'] = '\xCA\x8C', ['w'] = '\xCA\x8D', ['x'] = 'x',
['y'] = '\xCA\x8E', ['z'] = 'z',
['A'] = '\xE2\x88\x80', ['B'] = '\xF0\x9D\x99\xB1', ['C'] = '\xC6\x86', ['D'] = '\xE1\x97\xA1',
['E'] = '\xC6\x8E', ['F'] = '\xE2\x84\xB2', ['G'] = '\xE2\x85\x81', ['H'] = 'H',
['I'] = 'I', ['J'] = '\xC5\xBF', ['K'] = '\xE2\x8B\x8A', ['L'] = '\xCB\xA5',
['M'] = 'W', ['N'] = 'N', ['O'] = 'O', ['P'] = '\xC6\x8A',
['Q'] = '\xD2\x8C', ['R'] = '\xCA\x81', ['S'] = 'S', ['T'] = '\xE2\x8A\xA5',
['U'] = '\xE2\x88\xA9', ['V'] = '\xCE\x9B', ['W'] = 'M', ['X'] = 'X',
['Y'] = '\xE2\x85\x84', ['Z'] = 'Z',
['1'] = '\xC6\x96', ['2'] = '\xE1\x84\x85', ['3'] = '\xC6\x90', ['4'] = '\xE1\x84\x8D',
['5'] = '\xC7\x82', ['6'] = '9', ['7'] = '\xE1\x84\x82', ['8'] = '8',
['9'] = '6', ['0'] = '0',
['.'] = '\xCB\x99', [','] = '\xCA\xBB', ['?'] = '\xC2\xBF', ['!'] = '\xC2\xA1',
['\''] = ',', ['"'] = ',,', ['('] = ')', [')'] = '(',
['['] = ']', [']'] = '[', ['{'] = '}', ['}'] = '{',
['<'] = '>', ['>'] = '<', ['_'] = '\xE2\x80\xBE', [';'] = '\xD8\x9B',
['&'] = '\xE2\x85\x8B',
}
local function flip_text(text)
local chars = {}
- -- Iterate through UTF-8 characters
- for char in text:gmatch('.') do
+ -- Iterate through UTF-8 codepoints (not bytes)
+ for char in text:gmatch('[\1-\127\194-\244][\128-\191]*') do
table.insert(chars, FLIP_MAP[char] or char)
end
-- Reverse the order
local reversed = {}
for i = #chars, 1, -1 do
table.insert(reversed, chars[i])
end
return table.concat(reversed)
end
function plugin.on_message(api, message, ctx)
local input
if message.reply and message.reply.text and message.reply.text ~= '' then
input = message.reply.text
elseif message.args and message.args ~= '' then
input = message.args
else
return api.send_message(message.chat.id, 'Please provide some text to flip, or use this command in reply to a message.')
end
local output = flip_text(input)
return api.send_message(message.chat.id, output)
end
return plugin
diff --git a/src/plugins/media/gif.lua b/src/plugins/media/gif.lua
index e2eccee..930e5a4 100644
--- a/src/plugins/media/gif.lua
+++ b/src/plugins/media/gif.lua
@@ -1,63 +1,66 @@
--[[
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.'
-local TENOR_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ'
-
function plugin.on_message(api, message, ctx)
local https = require('ssl.https')
local json = require('dkjson')
local url = require('socket.url')
local ltn12 = require('ltn12')
+ 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>.', '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
+ query, tenor_key
)
local response_body = {}
local res, code = https.request({
url = api_url,
method = 'GET',
sink = ltn12.sink.table(response_body),
headers = {
['Accept'] = 'application/json'
}
})
if not res or code ~= 200 then
return api.send_message(message.chat.id, 'Failed to search Tenor. Please try again later.')
end
local body = table.concat(response_body)
local data, _ = json.decode(body)
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

File Metadata

Mime Type
text/x-diff
Expires
Thu, May 14, 9:43 AM (2 d, 1 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
63450
Default Alt Text
(48 KB)

Event Timeline