Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
39 KB
Referenced Files
None
Subscribers
None
diff --git a/.env.example b/.env.example
index 9d17ea7..89fbdb1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,52 +1,53 @@
# mattata v2.2 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=
# Bot links (optional, defaults shown)
CHANNEL_URL=https://t.me/mattata
SUPPORT_URL=https://t.me/mattataSupport
GITHUB_URL=https://github.com/wrxck/mattata
DEV_URL=https://t.me/mattataDev
# 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 14a98ca..070e94e 100644
--- a/src/core/router.lua
+++ b/src/core/router.lua
@@ -1,470 +1,474 @@
--[[
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
-- 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 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' } })
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
-- 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
-- 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',
'chat_boost', 'removed_chat_boost',
'poll', 'poll_answer'
}
})
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/purge.lua b/src/plugins/admin/purge.lua
index cd388e0..a8d1263 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', table.pack(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), { parse_mode = '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 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/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 9d8cc32..54e8559 100644
--- a/src/plugins/media/gif.lua
+++ b/src/plugins/media/gif.lua
@@ -1,50 +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.'
-local TENOR_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ'
-
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
+ query, tenor_key
)
local data, code = 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

File Metadata

Mime Type
text/x-diff
Expires
Mon, May 11, 2:14 AM (5 d, 13 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
62979
Default Alt Text
(39 KB)

Event Timeline