Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
577 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/.env.example b/.env.example
index 0f41852..89fbdb1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,47 +1,53 @@
-# mattata v2.0 Configuration
+# 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/.gitignore b/.gitignore
index 231ba0c..47e8b80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,35 +1,36 @@
# Environment
.env
+.env.*
# Lua compiled
*.luac
# LuaRocks
/lua_modules/
/.luarocks/
# Downloads & temp
/downloads/
/tmp/
*.tmp
# Backups
/backups/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker volumes
pgdata/
redisdata/
# Old v1 config (use .env now)
configuration.lua
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 170b6a6..14ae1f5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,100 +1,143 @@
# Contributing to mattata
## Plugin Development
### Plugin Contract
Every plugin must export a table with these fields:
```lua
local plugin = {}
plugin.name = 'mycommand' -- Unique identifier
plugin.category = 'utility' -- admin, utility, fun, media, ai
plugin.description = 'Short desc' -- For help text
plugin.commands = { 'cmd', 'alias' } -- Without / prefix
plugin.help = '/cmd [args] - Usage.' -- Full usage text
-- Optional flags
plugin.group_only = false -- Restrict to groups
plugin.admin_only = false -- Require group admin
plugin.global_admin_only = false -- Require bot owner
plugin.permanent = false -- Cannot be disabled
```
### Handler Functions
```lua
-- Command handler (when /cmd matches)
function plugin.on_message(api, message, ctx) end
-- Callback query handler (buttons with data "pluginname:data")
function plugin.on_callback_query(api, callback_query, message, ctx) end
-- Passive handler (runs on every message, no command needed)
function plugin.on_new_message(api, message, ctx) end
-- New member handler
function plugin.on_member_join(api, message, ctx) end
-- Inline query handler
function plugin.on_inline_query(api, inline_query, ctx) end
+-- Reaction changes
+function plugin.on_reaction(api, update, ctx) end
+
+-- Member status changes (join, leave, promoted, etc.)
+function plugin.on_chat_member_update(api, update, ctx) end
+
+-- Bot's own membership changes
+function plugin.on_my_chat_member(api, update, ctx) end
+
+-- Join request received
+function plugin.on_chat_join_request(api, update, ctx) end
+
+-- Poll state change
+function plugin.on_poll(api, update, ctx) end
+
+-- User voted on poll
+function plugin.on_poll_answer(api, update, ctx) end
+
+-- Chat boost received
+function plugin.on_chat_boost(api, update, ctx) end
+
+-- Chat boost removed
+function plugin.on_removed_chat_boost(api, update, ctx) end
+
-- Cron job (runs every minute)
function plugin.cron(api, ctx) end
```
### Context Object (`ctx`)
| Field | Type | Description |
|-------|------|-------------|
| `ctx.api` | table | Telegram Bot API |
-| `ctx.db` | table | PostgreSQL (query, execute, insert, upsert) |
+| `ctx.db` | table | PostgreSQL via stored procedures — use `ctx.db.call('sp_name', {args})` |
+| `ctx.http` | table | Async HTTP client — `ctx.http.get(url)`, `ctx.http.post(url, body)` |
| `ctx.redis` | table | Redis client proxy |
| `ctx.session` | table | Session/cache manager |
| `ctx.config` | table | Configuration reader |
| `ctx.i18n` | table | Language manager |
| `ctx.permissions` | table | Permission checks |
| `ctx.lang` | table | Current language strings |
| `ctx.is_group` | bool | Is group chat |
| `ctx.is_admin` | bool | Is user group admin |
| `ctx.is_global_admin` | bool | Is user bot owner |
### Adding a Plugin
1. Create your plugin in the appropriate category directory
2. Add the plugin name to the category's `src/plugins/<category>/init.lua`
3. Test with `/reload` (admin only)
### Database Migrations
-If your plugin needs database tables, add a migration file to `src/db/migrations/`:
+If your plugin needs database tables or stored procedures, add a migration file to `src/db/migrations/`:
```lua
local migration = {}
function migration.up()
return [[
CREATE TABLE IF NOT EXISTS my_table (
id SERIAL PRIMARY KEY,
...
- )
+ );
+
+ CREATE OR REPLACE FUNCTION sp_my_operation(p_id BIGINT, p_value TEXT)
+ RETURNS VOID AS $$
+ BEGIN
+ INSERT INTO my_table (id, value) VALUES (p_id, p_value)
+ ON CONFLICT (id) DO UPDATE SET value = p_value;
+ END;
+ $$ LANGUAGE plpgsql;
]]
end
return migration
```
+All database operations should use stored procedures via `ctx.db.call()` rather than raw SQL:
+
+```lua
+-- Good: stored procedure
+ctx.db.call('sp_my_operation', {user_id, value})
+
+-- Avoid: raw SQL
+ctx.db.query('INSERT INTO my_table ...')
+```
+
### Code Style
- Use 4 spaces for indentation
- Local variables in `snake_case`
- Module tables as `local plugin = {}`
- Always return the plugin table
- Wrap API calls that might fail in `pcall`
- Use `require('telegram-bot-lua.tools').escape_html()` for user input in HTML messages
## Language Translations
Language files are in `src/languages/`. To add a new language:
1. Copy `en_gb.lua` as a template
2. Translate all string values
3. Add the language code to `src/languages/init.lua`
diff --git a/README.md b/README.md
index bd3b2cf..6e74f3a 100644
--- a/README.md
+++ b/README.md
@@ -1,130 +1,140 @@
# mattata
A feature-rich Telegram group management and utility bot, written in Lua.
## Features
- **Group Administration** - Ban, kick, mute, warn, tempban, tempmute, promote, demote, trust
- **Federation System** - Cross-group ban management with federated admin networks
-- **Captcha Verification** - Challenge new members before granting chat access
-- **Anti-Spam** - Rate limiting, word filters, link filtering
+- **Captcha Verification** - Challenge new members with built-in or custom captcha
+- **Anti-Spam** - Rate limiting, word filters, link filtering, auto-delete
- **100+ Plugins** - Weather, translate, search, currency, Wikipedia, AI chat, and more
+- **Async HTTP** - Non-blocking HTTP client for plugin API calls via copas
+- **Stored Procedures** - All database operations use PostgreSQL stored procedures
+- **Forum Topics** - Full support for forum topic management and slow mode
+- **Reaction Karma** - Track karma via message reactions
+- **RSS Feeds** - Subscribe to and monitor RSS/Atom feeds
+- **Scheduled Messages** - Queue messages for future delivery
+- **Inline Queries** - Inline search and sharing across chats
+- **QR Codes** - Generate QR codes from text
- **Multi-Language** - 10 language packs included (EN, DE, AR, PL, PT, TR, Scottish)
- **PostgreSQL + Redis** - PostgreSQL for persistent data, Redis for caching
- **Hot-Reloadable Plugins** - Reload plugins without restarting the bot
- **Docker Ready** - One command deployment with Docker Compose
## Quick Start (Docker)
```bash
cp .env.example .env
# Edit .env and set BOT_TOKEN
docker compose up -d
```
## Quick Start (Manual)
### Prerequisites
- Lua 5.3+
- LuaRocks
- PostgreSQL 14+
- Redis 7+
### Installation
```bash
# Install Lua dependencies
luarocks install telegram-bot-lua
luarocks install pgmoon
luarocks install redis-lua
luarocks install dkjson
luarocks install luautf8
# Configure
cp .env.example .env
# Edit .env with your settings
# Start
lua main.lua
```
## Configuration
All configuration is managed through environment variables. See `.env.example` for the full reference.
### Required
| Variable | Description |
|----------|-------------|
| `BOT_TOKEN` | Telegram Bot API token from @BotFather |
### Optional API Keys
| Variable | Description | Used By |
|----------|-------------|---------|
| `LASTFM_API_KEY` | Last.fm API key | `/lastfm`, `/np` |
| `YOUTUBE_API_KEY` | YouTube Data API v3 key | `/youtube` |
| `SPOTIFY_CLIENT_ID` | Spotify app client ID | `/spotify` |
| `SPOTIFY_CLIENT_SECRET` | Spotify app client secret | `/spotify` |
| `SPAMWATCH_TOKEN` | SpamWatch API token | Anti-spam |
| `OPENAI_API_KEY` | OpenAI API key | `/ai` |
| `ANTHROPIC_API_KEY` | Anthropic API key | `/ai` |
## Architecture
```
mattata/
├── main.lua # Entry point
├── src/
│ ├── core/ # Framework modules
│ │ ├── config.lua # .env configuration reader
│ │ ├── loader.lua # Plugin discovery & hot-reload
│ │ ├── router.lua # Event dispatch
│ │ ├── middleware.lua # Middleware pipeline
│ │ ├── database.lua # PostgreSQL (pgmoon)
│ │ ├── redis.lua # Redis connection
+│ │ ├── http.lua # Async HTTP client (copas)
│ │ ├── permissions.lua # Admin/mod/trusted checks
│ │ ├── session.lua # Redis session/cache management
│ │ ├── i18n.lua # Language manager
│ │ └── logger.lua # Structured logging
│ ├── middleware/ # Middleware chain
│ ├── plugins/ # Plugin categories
-│ │ ├── admin/ # Group management (30+ plugins)
-│ │ ├── utility/ # Tools & info (25+ plugins)
-│ │ ├── fun/ # Entertainment (13 plugins)
-│ │ ├── media/ # Media search (6 plugins)
+│ │ ├── admin/ # Group management (35+ plugins)
+│ │ ├── utility/ # Tools & info (33+ plugins)
+│ │ ├── fun/ # Entertainment (16 plugins)
+│ │ ├── media/ # Media search (7 plugins)
│ │ └── ai/ # LLM integration
│ ├── db/migrations/ # PostgreSQL schema migrations
│ ├── languages/ # 10 language packs
│ └── data/ # Static data (slaps, join messages)
├── docker-compose.yml
+├── docker-compose.matticate.yml
├── Dockerfile
└── .env.example
```
## Plugin Development
Plugins follow a simple contract:
```lua
local plugin = {}
plugin.name = 'myplugin'
plugin.category = 'utility'
plugin.description = 'Does something useful'
plugin.commands = { 'mycommand', 'alias' }
plugin.help = '/mycommand <args> - Does the thing.'
function plugin.on_message(api, message, ctx)
return api.send_message(message.chat.id, 'Hello!')
end
return plugin
```
Add your plugin to the category's `init.lua` manifest and it will be auto-loaded.
## License
MIT License - see [LICENSE](LICENSE) for details.
## Credits
Created by [Matt Hesketh](https://github.com/wrxck).
diff --git a/docker-compose.matticate.yml b/docker-compose.matticate.yml
new file mode 100644
index 0000000..9e67e37
--- /dev/null
+++ b/docker-compose.matticate.yml
@@ -0,0 +1,13 @@
+services:
+ bot:
+ build: .
+ env_file: .env.matticate
+ networks:
+ - databases
+ restart: unless-stopped
+ volumes:
+ - ./downloads-matticate:/app/downloads
+
+networks:
+ databases:
+ external: true
diff --git a/docker-compose.yml b/docker-compose.yml
index 4f75fb4..767878f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,45 +1,13 @@
-version: '3.8'
-
services:
bot:
build: .
env_file: .env
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
+ networks:
+ - databases
restart: unless-stopped
volumes:
- ./downloads:/app/downloads
- postgres:
- image: postgres:16-alpine
- environment:
- POSTGRES_DB: ${DATABASE_NAME:-mattata}
- POSTGRES_USER: ${DATABASE_USER:-mattata}
- POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-changeme}
- volumes:
- - pgdata:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-mattata}"]
- interval: 5s
- timeout: 5s
- retries: 5
- restart: unless-stopped
-
- redis:
- image: redis:7-alpine
- command: redis-server --appendonly yes
- volumes:
- - redisdata:/data
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 5s
- timeout: 5s
- retries: 5
- restart: unless-stopped
-
-volumes:
- pgdata:
- redisdata:
+networks:
+ databases:
+ external: true
diff --git a/main.lua b/main.lua
index 703464d..7cde9aa 100644
--- a/main.lua
+++ b/main.lua
@@ -1,94 +1,120 @@
--[[
_ _ _
_ __ ___ __ _| |_| |_ __ _| |_ __ _
| '_ ` _ \ / _` | __| __/ _` | __/ _` |
| | | | | | (_| | |_| || (_| | || (_| |
|_| |_| |_|\__,_|\__|\__\__,_|\__\__,_|
v2.1
Copyright 2020-2026 Matthew Hesketh <matthew@matthewhesketh.com>
See LICENSE for details
]]
local config = require('src.core.config')
local logger = require('src.core.logger')
local database = require('src.core.database')
local redis = require('src.core.redis')
local session = require('src.core.session')
local i18n = require('src.core.i18n')
local loader = require('src.core.loader')
local router = require('src.core.router')
local migrations = require('src.db.init')
-- 1. Load configuration
config.load('.env')
logger.init()
logger.info('mattata v%s starting...', config.VERSION)
-- 2. Validate required config
assert(config.bot_token(), 'BOT_TOKEN is required. Set it in .env or as an environment variable.')
-- 3. Configure telegram-bot-lua
local api = require('telegram-bot-lua').configure(config.bot_token())
local tools = require('telegram-bot-lua.tools')
logger.info('Bot: @%s (%s) [%d]', api.info.username, api.info.first_name, api.info.id)
-- 4. Connect to PostgreSQL
local db_ok, db_err = database.connect()
if not db_ok then
logger.error('Cannot start without PostgreSQL: %s', tostring(db_err))
os.exit(1)
end
-- 5. Run database migrations
migrations.run(database)
-- 6. Connect to Redis
local redis_ok, redis_err = redis.connect()
if not redis_ok then
logger.error('Cannot start without Redis: %s', tostring(redis_err))
os.exit(1)
end
session.init(redis)
-- 7. Load languages
i18n.init()
-- 8. Load all plugins
loader.init(api, database, redis)
-- 9. Build context factory and start router
local ctx_base = {
api = api,
tools = tools,
db = database,
redis = redis,
session = session,
config = config,
i18n = i18n,
permissions = require('src.core.permissions'),
logger = logger
}
router.init(api, tools, loader, ctx_base)
--- 10. Notify admins
+-- 10. Register bot command menu with Telegram
+local json = require('dkjson')
+local user_commands = {}
+local admin_commands = {}
+for _, plugin in ipairs(loader.get_plugins()) do
+ if plugin.commands and #plugin.commands > 0 and plugin.description and plugin.description ~= '' then
+ local entry = { command = plugin.commands[1], description = plugin.description }
+ if plugin.admin_only or plugin.global_admin_only then
+ table.insert(admin_commands, entry)
+ else
+ table.insert(user_commands, entry)
+ end
+ end
+end
+-- Set default commands (visible to all users)
+api.set_my_commands(json.encode(user_commands))
+-- Set admin commands (visible to group admins only)
+if #admin_commands > 0 then
+ -- Merge user + admin so admins see everything
+ local all_commands = {}
+ for _, cmd in ipairs(user_commands) do table.insert(all_commands, cmd) end
+ for _, cmd in ipairs(admin_commands) do table.insert(all_commands, cmd) end
+ api.set_my_commands(json.encode(all_commands), { type = 'all_chat_administrators' })
+end
+logger.info('Registered %d user commands and %d admin commands with Telegram', #user_commands, #admin_commands)
+
+-- 11. Notify admins
local info_msg = string.format(
'<pre>mattata v%s connected!\n\n Username: @%s\n Name: %s\n ID: %d\n Plugins: %d</pre>',
config.VERSION,
tools.escape_html(api.info.username),
tools.escape_html(api.info.first_name),
api.info.id,
loader.count()
)
if config.log_chat() then
api.send_message(config.log_chat(), info_msg, 'html')
end
for _, admin_id in ipairs(config.bot_admins()) do
api.send_message(admin_id, info_msg, 'html')
end
--- 11. Start the bot
+-- 12. Start the bot
logger.info('Starting main loop...')
router.run()
diff --git a/mattata-2.0-0.rockspec b/mattata-2.2-0.rockspec
similarity index 88%
rename from mattata-2.0-0.rockspec
rename to mattata-2.2-0.rockspec
index ae3be6d..12d799b 100644
--- a/mattata-2.0-0.rockspec
+++ b/mattata-2.2-0.rockspec
@@ -1,32 +1,34 @@
package = 'mattata'
-version = '2.0-0'
+version = '2.2-0'
source = {
- url = 'git://github.com/wrxck/mattata.git'
+ url = 'git://github.com/wrxck/mattata.git',
+ tag = 'v2.2.0'
}
description = {
summary = 'A feature-rich Telegram bot written in Lua',
detailed = 'mattata is a powerful, plugin-based Telegram group management and utility bot.',
homepage = 'https://github.com/wrxck/mattata',
maintainer = 'Matthew Hesketh <matthew@matthewhesketh.com>',
license = 'MIT'
}
dependencies = {
'lua >= 5.3',
'telegram-bot-lua >= 3.0',
'pgmoon >= 1.16'
}
build = {
type = 'builtin',
modules = {
['mattata.core.config'] = 'src/core/config.lua',
['mattata.core.loader'] = 'src/core/loader.lua',
['mattata.core.router'] = 'src/core/router.lua',
['mattata.core.middleware'] = 'src/core/middleware.lua',
['mattata.core.database'] = 'src/core/database.lua',
['mattata.core.redis'] = 'src/core/redis.lua',
+ ['mattata.core.http'] = 'src/core/http.lua',
['mattata.core.i18n'] = 'src/core/i18n.lua',
['mattata.core.logger'] = 'src/core/logger.lua',
['mattata.core.permissions'] = 'src/core/permissions.lua',
['mattata.core.session'] = 'src/core/session.lua'
}
}
diff --git a/spec/helpers/mock_api.lua b/spec/helpers/mock_api.lua
index b7552a0..5a6e4e0 100644
--- a/spec/helpers/mock_api.lua
+++ b/spec/helpers/mock_api.lua
@@ -1,243 +1,277 @@
--[[
mattata v2.1 - Mock Telegram Bot API
Records all calls and returns configurable responses for testing.
Includes async/handler stubs for copas-based concurrency support.
]]
local mock_api = {}
function mock_api.new()
local api = {
info = { id = 123456789, username = 'testbot', first_name = 'Test Bot' },
calls = {},
}
local custom_handlers = {}
local function record(method, ...)
table.insert(api.calls, { method = method, args = {...} })
end
- function api.send_message(chat_id, text, parse_mode, ...)
- record('send_message', chat_id, text, parse_mode, ...)
+ function api.send_message(chat_id, text, opts)
+ record('send_message', chat_id, text, opts)
return { ok = true, result = { message_id = #api.calls, chat = { id = chat_id } } }
end
function api.get_chat_member(chat_id, user_id)
record('get_chat_member', chat_id, user_id)
if custom_handlers.get_chat_member then
return custom_handlers.get_chat_member(chat_id, user_id)
end
-- Default: regular member
return { ok = true, result = { status = 'member', user = { id = user_id } } }
end
- function api.ban_chat_member(chat_id, user_id, until_date)
- record('ban_chat_member', chat_id, user_id, until_date)
+ function api.ban_chat_member(chat_id, user_id, opts)
+ record('ban_chat_member', chat_id, user_id, opts)
return { ok = true, result = true }
end
function api.unban_chat_member(chat_id, user_id)
record('unban_chat_member', chat_id, user_id)
return { ok = true, result = true }
end
- function api.restrict_chat_member(chat_id, user_id, perms_or_until, maybe_perms)
- record('restrict_chat_member', chat_id, user_id, perms_or_until, maybe_perms)
+ function api.restrict_chat_member(chat_id, user_id, permissions, opts)
+ record('restrict_chat_member', chat_id, user_id, permissions, opts)
return { ok = true, result = true }
end
+ function api.send_photo(chat_id, photo, opts)
+ record('send_photo', chat_id, photo, opts)
+ return { ok = true, result = { message_id = #api.calls, chat = { id = chat_id } } }
+ end
+
+ function api.send_document(chat_id, document, opts)
+ record('send_document', chat_id, document, opts)
+ return { ok = true, result = { message_id = #api.calls, chat = { id = chat_id } } }
+ end
+
+ function api.send_video(chat_id, video, opts)
+ record('send_video', chat_id, video, opts)
+ return { ok = true, result = { message_id = #api.calls, chat = { id = chat_id } } }
+ end
+
+ function api.send_audio(chat_id, audio, opts)
+ record('send_audio', chat_id, audio, opts)
+ return { ok = true, result = { message_id = #api.calls, chat = { id = chat_id } } }
+ end
+
function api.delete_message(chat_id, message_id)
record('delete_message', chat_id, message_id)
return { ok = true, result = true }
end
- function api.pin_chat_message(chat_id, message_id, disable_notification)
- record('pin_chat_message', chat_id, message_id, disable_notification)
+ function api.pin_chat_message(chat_id, message_id, opts)
+ record('pin_chat_message', chat_id, message_id, opts)
return { ok = true, result = true }
end
- function api.unpin_chat_message(chat_id, message_id)
- record('unpin_chat_message', chat_id, message_id)
+ function api.unpin_chat_message(chat_id, opts)
+ record('unpin_chat_message', chat_id, opts)
return { ok = true, result = true }
end
+ function api.send_dice(chat_id, opts)
+ record('send_dice', chat_id, opts)
+ return { ok = true, result = { message_id = #api.calls, dice = { value = 4 } } }
+ end
+
function api.get_chat(chat_id)
record('get_chat', chat_id)
return { ok = true, result = { id = chat_id, first_name = 'Test User' } }
end
- function api.edit_message_text(chat_id, message_id, text, parse_mode, ...)
- record('edit_message_text', chat_id, message_id, text, parse_mode, ...)
+ function api.edit_message_text(chat_id, message_id, text, opts)
+ record('edit_message_text', chat_id, message_id, text, opts)
return { ok = true, result = { message_id = message_id } }
end
- function api.edit_message_reply_markup(chat_id, message_id, inline_message_id, keyboard)
- record('edit_message_reply_markup', chat_id, message_id, inline_message_id, keyboard)
+ function api.edit_message_reply_markup(chat_id, message_id, opts)
+ record('edit_message_reply_markup', chat_id, message_id, opts)
return { ok = true, result = { message_id = message_id } }
end
- function api.answer_callback_query(callback_id, text)
- record('answer_callback_query', callback_id, text)
+ function api.answer_callback_query(callback_id, opts)
+ record('answer_callback_query', callback_id, opts)
return { ok = true }
end
function api.get_updates(timeout, offset, limit, allowed)
record('get_updates', timeout, offset, limit, allowed)
return { ok = true, result = {} }
end
function api.leave_chat(chat_id)
record('leave_chat', chat_id)
return { ok = true, result = true }
end
function api.inline_keyboard()
local kb = {}
function kb:row(...)
return self
end
return kb
end
function api.row()
local r = {}
function r:callback_data_button(text, data)
return self
end
function r:url_button(text, url)
return self
end
return r
end
-- Helper to set custom get_chat_member behavior
function api.set_admin(chat_id, user_id)
local original_handler = custom_handlers.get_chat_member
custom_handlers.get_chat_member = function(cid, uid)
if cid == chat_id and uid == user_id then
return {
ok = true,
result = {
status = 'administrator',
user = { id = uid },
can_restrict_members = true,
can_delete_messages = true,
can_pin_messages = true,
can_promote_members = true,
can_invite_users = true,
}
}
end
if original_handler then
return original_handler(cid, uid)
end
return { ok = true, result = { status = 'member', user = { id = uid } } }
end
end
-- Helper to set the bot as an admin with specified permissions
function api.set_bot_admin(chat_id, perms)
perms = perms or {}
local original_handler = custom_handlers.get_chat_member
custom_handlers.get_chat_member = function(cid, uid)
if cid == chat_id and uid == api.info.id then
return {
ok = true,
result = {
status = 'administrator',
user = { id = uid },
can_restrict_members = perms.can_restrict_members or false,
can_delete_messages = perms.can_delete_messages or false,
can_pin_messages = perms.can_pin_messages or false,
can_promote_members = perms.can_promote_members or false,
can_invite_users = perms.can_invite_users or false,
}
}
end
if original_handler then
return original_handler(cid, uid)
end
return { ok = true, result = { status = 'member', user = { id = uid } } }
end
end
function api.set_creator(chat_id, user_id)
local original_handler = custom_handlers.get_chat_member
custom_handlers.get_chat_member = function(cid, uid)
if cid == chat_id and uid == user_id then
return {
ok = true,
result = {
status = 'creator',
user = { id = uid },
}
}
end
if original_handler then
return original_handler(cid, uid)
end
return { ok = true, result = { status = 'member', user = { id = uid } } }
end
end
-- Handler stubs (overwritten by router.run() in production)
api.on_message = function() end
api.on_edited_message = function() end
api.on_callback_query = function() end
api.on_inline_query = function() end
+ api.on_chat_join_request = function() end
+ api.on_chat_member = function() end
+ api.on_my_chat_member = function() end
+ api.on_message_reaction = function() end
+ api.on_message_reaction_count = function() end
+ api.on_chat_boost = function() end
+ api.on_removed_chat_boost = function() end
+ api.on_poll = function() end
+ api.on_poll_answer = function() end
-- Async stubs (telegram-bot-lua async system)
api.async = {
run = function() end,
stop = function() end,
all = function(fns) return {} end,
spawn = function(fn) if fn then fn() end end,
sleep = function() end,
is_running = function() return false end,
}
-- api.run stub — no-op (prevents tests from entering copas.loop)
function api.run(opts)
record('run', opts)
end
-- process_update stub
function api.process_update(update)
record('process_update', update)
end
function api.reset()
api.calls = {}
custom_handlers = {}
end
function api.get_call(method)
for _, call in ipairs(api.calls) do
if call.method == method then return call end
end
return nil
end
function api.get_calls(method)
local results = {}
for _, call in ipairs(api.calls) do
if call.method == method then
table.insert(results, call)
end
end
return results
end
function api.count_calls(method)
local count = 0
for _, call in ipairs(api.calls) do
if call.method == method then count = count + 1 end
end
return count
end
return api
end
return mock_api
diff --git a/spec/helpers/mock_redis.lua b/spec/helpers/mock_redis.lua
index 5b92e0b..32b995f 100644
--- a/spec/helpers/mock_redis.lua
+++ b/spec/helpers/mock_redis.lua
@@ -1,151 +1,157 @@
--[[
mattata v2.0 - Mock Redis
In-memory implementation of Redis operations for testing.
]]
local mock_redis = {}
function mock_redis.new()
local redis = {
store = {},
sets = {},
hashes = {},
ttls = {},
commands = {},
}
local function record(cmd, ...) table.insert(redis.commands, { cmd = cmd, args = {...} }) end
function redis.get(key) record('get', key); return redis.store[key] end
function redis.set(key, value) record('set', key, value); redis.store[key] = tostring(value) end
function redis.setex(key, ttl, value) record('setex', key, ttl, value); redis.store[key] = tostring(value); redis.ttls[key] = ttl end
function redis.setnx(key, value)
record('setnx', key, value)
if redis.store[key] == nil then redis.store[key] = tostring(value); return 1 end
return 0
end
function redis.del(key) record('del', key); redis.store[key] = nil; redis.sets[key] = nil; redis.hashes[key] = nil end
function redis.exists(key) record('exists', key); return redis.store[key] ~= nil and 1 or 0 end
function redis.expire(key, ttl) record('expire', key, ttl); redis.ttls[key] = ttl end
function redis.incr(key)
record('incr', key)
redis.store[key] = (tonumber(redis.store[key]) or 0) + 1
return redis.store[key]
end
function redis.incrby(key, n)
record('incrby', key, n)
redis.store[key] = (tonumber(redis.store[key]) or 0) + n
return redis.store[key]
end
+ function redis.getset(key, value)
+ record('getset', key, value)
+ local old = redis.store[key]
+ redis.store[key] = tostring(value)
+ return old
+ end
function redis.hget(key, field) record('hget', key, field); return redis.hashes[key] and redis.hashes[key][field] end
function redis.hset(key, field, value)
record('hset', key, field, value)
if not redis.hashes[key] then redis.hashes[key] = {} end
redis.hashes[key][field] = tostring(value)
end
function redis.hdel(key, field) record('hdel', key, field); if redis.hashes[key] then redis.hashes[key][field] = nil end end
function redis.hgetall(key) record('hgetall', key); return redis.hashes[key] or {} end
function redis.hexists(key, field) record('hexists', key, field); return redis.hashes[key] and redis.hashes[key][field] ~= nil end
function redis.hincrby(key, field, n)
record('hincrby', key, field, n)
if not redis.hashes[key] then redis.hashes[key] = {} end
redis.hashes[key][field] = (tonumber(redis.hashes[key][field]) or 0) + n
return redis.hashes[key][field]
end
function redis.sadd(key, value)
record('sadd', key, value)
if not redis.sets[key] then redis.sets[key] = {} end
redis.sets[key][tostring(value)] = true
end
function redis.srem(key, value) record('srem', key, value); if redis.sets[key] then redis.sets[key][tostring(value)] = nil end end
function redis.sismember(key, value) record('sismember', key, value); return redis.sets[key] and redis.sets[key][tostring(value)] and 1 or 0 end
function redis.smembers(key)
record('smembers', key)
local result = {}
if redis.sets[key] then
for v in pairs(redis.sets[key]) do table.insert(result, v) end
end
return result
end
function redis.scard(key)
record('scard', key)
local count = 0
if redis.sets[key] then
for _ in pairs(redis.sets[key]) do count = count + 1 end
end
return count
end
function redis.rpush(key, value)
record('rpush', key, value)
if not redis.store[key] then redis.store[key] = {} end
if type(redis.store[key]) == 'table' then table.insert(redis.store[key], value) end
end
function redis.lrange(key, start, stop)
record('lrange', key, start, stop)
local data = redis.store[key]
if type(data) ~= 'table' then return {} end
local result = {}
-- Redis uses 0-based index, -1 means end
local len = #data
if start < 0 then start = len + start end
if stop < 0 then stop = len + stop end
for i = start + 1, math.min(stop + 1, len) do
table.insert(result, data[i])
end
return result
end
function redis.ltrim(key, start, stop) record('ltrim', key, start, stop) end
function redis.scan(pattern)
record('scan', pattern)
local results = {}
-- Convert Redis glob pattern to Lua pattern
local lua_pattern = '^' .. pattern:gsub('([%.%+%(%)%[%]%%])', '%%%1'):gsub('%*', '.*'):gsub('%?', '.') .. '$'
for key in pairs(redis.store) do
if type(key) == 'string' and key:match(lua_pattern) then
table.insert(results, key)
end
end
return results
end
function redis.keys(pattern) return redis.scan(pattern) end
function redis.pipeline(fn) record('pipeline'); return nil end
function redis.client() return redis end
function redis.connect() return true end
function redis.disconnect() end
function redis.reset()
redis.store = {}
redis.sets = {}
redis.hashes = {}
redis.ttls = {}
redis.commands = {}
end
-- Helper: check if a specific command was issued
function redis.has_command(cmd)
for _, c in ipairs(redis.commands) do
if c.cmd == cmd then return true end
end
return false
end
-- Helper: count occurrences of a command
function redis.count_commands(cmd)
local count = 0
for _, c in ipairs(redis.commands) do
if c.cmd == cmd then count = count + 1 end
end
return count
end
return redis
end
return mock_redis
diff --git a/spec/middleware/captcha_spec.lua b/spec/middleware/captcha_spec.lua
index 08678fd..7ff8dd6 100644
--- a/spec/middleware/captcha_spec.lua
+++ b/spec/middleware/captcha_spec.lua
@@ -1,107 +1,108 @@
--[[
Tests for src/middleware/captcha.lua
Tests pending captcha blocking, no captcha pass-through.
]]
describe('middleware.captcha', function()
local captcha_mw
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
package.loaded['src.middleware.captcha'] = nil
package.loaded['src.core.session'] = {
get_captcha = function(chat_id, user_id)
local redis = env.redis
local hash = string.format('chat:%s:captcha:%s', tostring(chat_id), tostring(user_id))
local text = redis.hget(hash, 'text')
if not text then return nil end
return { text = text, message_id = redis.hget(hash, 'id') }
end,
}
captcha_mw = require('src.middleware.captcha')
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('name', function()
it('should be "captcha"', function()
assert.are.equal('captcha', captcha_mw.name)
end)
end)
describe('non-group messages', function()
it('should pass through for private messages', function()
ctx.is_group = false
local _, should_continue = captcha_mw.run(ctx, message)
assert.is_true(should_continue)
end)
end)
describe('no from', function()
it('should pass through when no from', function()
message.from = nil
local _, should_continue = captcha_mw.run(ctx, message)
assert.is_true(should_continue)
end)
end)
describe('no pending captcha', function()
it('should pass through when user has no pending captcha', function()
local _, should_continue = captcha_mw.run(ctx, message)
assert.is_true(should_continue)
end)
end)
describe('pending captcha', function()
before_each(function()
- -- Set up a pending captcha
+ -- Set up a pending captcha (hash for data + sentinel key for EXISTS fast path)
local hash = string.format('chat:%s:captcha:%s', message.chat.id, message.from.id)
env.redis.hset(hash, 'text', 'ABCD')
env.redis.hset(hash, 'id', '42')
+ env.redis.set('captcha:' .. message.chat.id .. ':' .. message.from.id, '1')
end)
it('should block regular messages from unverified users', function()
local _, should_continue = captcha_mw.run(ctx, message)
assert.is_false(should_continue)
end)
it('should delete blocked messages', function()
captcha_mw.run(ctx, message)
test_helper.assert_api_called(env.api, 'delete_message')
end)
it('should delete the correct message', function()
message.message_id = 99
captcha_mw.run(ctx, message)
local call = env.api.get_call('delete_message')
assert.are.equal(message.chat.id, call.args[1])
assert.are.equal(99, call.args[2])
end)
it('should allow new_chat_members messages even with pending captcha', function()
message.new_chat_members = { { id = message.from.id } }
local _, should_continue = captcha_mw.run(ctx, message)
assert.is_true(should_continue)
end)
it('should not delete new_chat_members messages', function()
message.new_chat_members = { { id = message.from.id } }
captcha_mw.run(ctx, message)
test_helper.assert_api_not_called(env.api, 'delete_message')
end)
end)
describe('run interface', function()
it('should be a valid middleware', function()
assert.are.equal('table', type(captcha_mw))
assert.are.equal('function', type(captcha_mw.run))
end)
end)
end)
diff --git a/spec/plugins/admin/ban_spec.lua b/spec/plugins/admin/ban_spec.lua
index 73700fd..7fe95d4 100644
--- a/spec/plugins/admin/ban_spec.lua
+++ b/spec/plugins/admin/ban_spec.lua
@@ -1,296 +1,296 @@
--[[
Tests for src/plugins/admin/ban.lua
Tests target resolution (reply, args, username), admin check, bot permission check,
ban execution, and logging.
]]
describe('plugins.admin.ban', function()
local ban_plugin
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
-- Mock dependencies
package.loaded['src.plugins.admin.ban'] = nil
package.loaded['src.core.logger'] = {
debug = function() end,
info = function() end,
warn = function() end,
error = function() end,
}
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
is_enabled = function() return false end,
bot_admins = function() return {} end,
load = function() end,
VERSION = '2.0',
}
package.loaded['src.core.session'] = {
get_admin_status = function() return nil end,
set_admin_status = function() end,
get_cached_setting = function(chat_id, key, fetch_fn, ttl)
return fetch_fn()
end,
}
package.loaded['src.core.permissions'] = {
is_global_admin = function() return false end,
is_group_admin = function(api, chat_id, user_id)
-- Target user is not admin by default
return false
end,
can_restrict = function(api, chat_id)
-- Bot can restrict by default in tests
return true
end,
}
-- Mock telegram-bot-lua.tools
package.loaded['telegram-bot-lua.tools'] = {
escape_html = function(text)
if not text then return '' end
return tostring(text):gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;')
end,
}
ban_plugin = require('src.plugins.admin.ban')
env = test_helper.setup()
message = test_helper.make_message({
text = '/ban',
command = 'ban',
})
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('plugin metadata', function()
it('should have name "ban"', function()
assert.are.equal('ban', ban_plugin.name)
end)
it('should be in admin category', function()
assert.are.equal('admin', ban_plugin.category)
end)
it('should be group_only', function()
assert.is_true(ban_plugin.group_only)
end)
it('should be admin_only', function()
assert.is_true(ban_plugin.admin_only)
end)
it('should have ban and b commands', function()
assert.are.same({ 'ban', 'b' }, ban_plugin.commands)
end)
it('should have a help string', function()
assert.is_truthy(ban_plugin.help)
assert.is_truthy(ban_plugin.help:match('/ban'))
end)
end)
describe('bot permission check', function()
it('should error when bot lacks restrict permission', function()
package.loaded['src.core.permissions'].can_restrict = function() return false end
-- Reload plugin to pick up new mock
package.loaded['src.plugins.admin.ban'] = nil
ban_plugin = require('src.plugins.admin.ban')
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Ban Users')
end)
end)
describe('target resolution', function()
it('should prompt when no target specified', function()
message.args = nil
message.reply = nil
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify')
end)
it('should resolve target from reply', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = nil
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
local call = env.api.get_call('ban_chat_member')
assert.are.equal(222222, call.args[2])
end)
it('should resolve target from user ID in args', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
end)
it('should resolve target from username in args', function()
env.redis.set('username:targetuser', '333333')
message.args = '@targetuser'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
local call = env.api.get_call('ban_chat_member')
assert.are.equal(333333, call.args[2])
end)
it('should extract reason from args after user ID', function()
message.args = '222222 spamming links'
ban_plugin.on_message(env.api, message, ctx)
-- Check that reason was logged to DB
local found = false
for _, q in ipairs(env.db.queries) do
if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
assert.are.equal('spamming links', q.params[4])
end
end
assert.is_true(found)
end)
it('should extract reason from args when replying', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = 'being disruptive'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
assert.are.equal('being disruptive', q.params[4])
end
end
assert.is_true(found)
end)
it('should strip "for" prefix from reason', function()
message.args = '222222 for spamming'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
assert.are.equal('spamming', q.params[4])
end
end
assert.is_true(found)
end)
it('should not ban the bot itself', function()
message.args = tostring(env.api.info.id)
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_not_called(env.api, 'ban_chat_member')
end)
end)
describe('admin target check', function()
it('should not ban an admin', function()
package.loaded['src.core.permissions'].is_group_admin = function(api, chat_id, user_id)
if user_id == 222222 then return true end
return false
end
package.loaded['src.plugins.admin.ban'] = nil
ban_plugin = require('src.plugins.admin.ban')
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_not_called(env.api, 'ban_chat_member')
test_helper.assert_sent_message_matches(env.api, "can't ban")
end)
end)
describe('ban execution', function()
it('should call ban_chat_member with correct chat and user', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local call = env.api.get_call('ban_chat_member')
assert.is_not_nil(call)
assert.are.equal(message.chat.id, call.args[1])
assert.are.equal(222222, call.args[2])
end)
it('should send success message with HTML', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local calls = env.api.get_calls('send_message')
-- Find the success message (not the prompt)
local found = false
for _, call in ipairs(calls) do
if call.args[2]:match('has banned') then
found = true
- assert.are.equal('html', call.args[3])
+ assert.are.equal('html', call.args[3].parse_mode)
end
end
assert.is_true(found)
end)
end)
describe('logging', function()
it('should log ban to bans table', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
assert.are.equal(message.chat.id, q.params[1])
assert.are.equal(222222, q.params[2])
assert.are.equal(message.from.id, q.params[3])
end
end
assert.is_true(found)
end)
it('should log ban to admin_actions table', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
if q.op == 'call' and q.func_name == 'sp_log_admin_action' then
found = true
assert.are.equal('ban', q.params[4])
assert.are.equal(message.from.id, q.params[2])
assert.are.equal(222222, q.params[3])
end
end
assert.is_true(found)
end)
end)
describe('message cleanup', function()
it('should delete the command message', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
-- Should have called delete_message for the command
local found = false
for _, call in ipairs(env.api.calls) do
if call.method == 'delete_message' and call.args[2] == message.message_id then
found = true
end
end
assert.is_true(found)
end)
it('should delete the replied-to message', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = nil
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, call in ipairs(env.api.calls) do
if call.method == 'delete_message' and call.args[2] == 50 then
found = true
end
end
assert.is_true(found)
end)
end)
end)
diff --git a/spec/plugins/utility/help_spec.lua b/spec/plugins/utility/help_spec.lua
index 1e825bb..a0aa58d 100644
--- a/spec/plugins/utility/help_spec.lua
+++ b/spec/plugins/utility/help_spec.lua
@@ -1,186 +1,186 @@
--[[
Tests for src/plugins/utility/help.lua
Tests help display, per-command help, callback navigation.
]]
describe('plugins.utility.help', function()
local help_plugin
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
package.loaded['src.plugins.utility.help'] = nil
package.loaded['src.core.logger'] = {
debug = function() end, info = function() end,
warn = function() end, error = function() end,
}
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
is_enabled = function() return false end,
bot_admins = function() return {} end,
bot_name = function() return 'mattata' end,
load = function() end, VERSION = '2.0',
}
package.loaded['telegram-bot-lua.tools'] = {
escape_html = function(text)
if not text then return '' end
return tostring(text):gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;')
end,
}
-- Mock loader
package.loaded['src.core.loader'] = {
get_by_command = function(cmd)
if cmd == 'ping' then
return { name = 'ping', help = '/ping - PONG!', commands = { 'ping' } }
end
return nil
end,
get_help = function(category)
if category == 'admin' then
return {
{ name = 'ban', category = 'admin', commands = { 'ban' }, help = '/ban', description = 'Ban users' },
}
end
return {
{ name = 'ping', category = 'utility', commands = { 'ping' }, help = '/ping', description = 'Ping' },
{ name = 'help', category = 'utility', commands = { 'help' }, help = '/help', description = 'Help' },
}
end,
}
package.loaded['src.core.permissions'] = {
is_group_admin = function() return false end,
}
help_plugin = require('src.plugins.utility.help')
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('plugin metadata', function()
it('should have name "help"', function()
assert.are.equal('help', help_plugin.name)
end)
it('should have help and start commands', function()
assert.are.same({ 'help', 'start' }, help_plugin.commands)
end)
it('should be permanent', function()
assert.is_true(help_plugin.permanent)
end)
it('should be in utility category', function()
assert.are.equal('utility', help_plugin.category)
end)
end)
describe('on_message', function()
it('should show main help menu without args', function()
message.args = nil
help_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'send_message')
test_helper.assert_sent_message_matches(env.api, 'feature%-rich')
end)
it('should include user first name', function()
message.args = nil
help_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Test')
end)
it('should include bot name', function()
message.args = nil
help_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Test Bot')
end)
it('should show specific command help when args provided', function()
message.args = 'ping'
help_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'PONG')
end)
it('should handle /help with / prefix in args', function()
message.args = '/ping'
help_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'PONG')
end)
it('should show "not found" for unknown command', function()
message.args = 'nonexistent'
help_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'No plugin found')
end)
it('should use HTML parse mode', function()
message.args = nil
help_plugin.on_message(env.api, message, ctx)
local call = env.api.get_call('send_message')
- assert.are.equal('html', call.args[3])
+ assert.are.equal('html', call.args[3].parse_mode)
end)
end)
describe('on_callback_query', function()
local callback_query, cb_message
before_each(function()
callback_query = test_helper.make_callback_query()
cb_message = callback_query.message
end)
it('should handle cmds page navigation', function()
callback_query.data = 'cmds:1'
help_plugin.on_callback_query(env.api, callback_query, cb_message, ctx)
test_helper.assert_api_called(env.api, 'edit_message_text')
end)
it('should handle admin cmds page navigation', function()
callback_query.data = 'acmds:1'
help_plugin.on_callback_query(env.api, callback_query, cb_message, ctx)
test_helper.assert_api_called(env.api, 'edit_message_text')
end)
it('should handle links callback', function()
callback_query.data = 'links'
help_plugin.on_callback_query(env.api, callback_query, cb_message, ctx)
test_helper.assert_api_called(env.api, 'edit_message_text')
end)
it('should handle back callback', function()
callback_query.data = 'back'
help_plugin.on_callback_query(env.api, callback_query, cb_message, ctx)
test_helper.assert_api_called(env.api, 'edit_message_text')
end)
it('should handle noop callback', function()
callback_query.data = 'noop'
help_plugin.on_callback_query(env.api, callback_query, cb_message, ctx)
test_helper.assert_api_called(env.api, 'answer_callback_query')
end)
it('should handle settings callback', function()
callback_query.data = 'settings'
cb_message.chat.type = 'supergroup'
help_plugin.on_callback_query(env.api, callback_query, cb_message, ctx)
-- Non-admin should get "you need to be an admin" callback
test_helper.assert_api_called(env.api, 'answer_callback_query')
end)
it('should allow admin to access settings', function()
package.loaded['src.core.permissions'].is_group_admin = function() return true end
package.loaded['src.plugins.utility.help'] = nil
help_plugin = require('src.plugins.utility.help')
callback_query.data = 'settings'
cb_message.chat.type = 'supergroup'
help_plugin.on_callback_query(env.api, callback_query, cb_message, ctx)
test_helper.assert_api_called(env.api, 'edit_message_reply_markup')
end)
end)
end)
diff --git a/spec/plugins/utility/ping_spec.lua b/spec/plugins/utility/ping_spec.lua
index 00d50b2..1b61164 100644
--- a/spec/plugins/utility/ping_spec.lua
+++ b/spec/plugins/utility/ping_spec.lua
@@ -1,107 +1,107 @@
--[[
Tests for src/plugins/utility/ping.lua
Tests ping and pong command responses.
]]
describe('plugins.utility.ping', function()
local ping_plugin
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
package.loaded['src.plugins.utility.ping'] = nil
package.loaded['socket'] = {
gettime = function() return os.time() end,
}
ping_plugin = require('src.plugins.utility.ping')
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('plugin metadata', function()
it('should have name "ping"', function()
assert.are.equal('ping', ping_plugin.name)
end)
it('should be in utility category', function()
assert.are.equal('utility', ping_plugin.category)
end)
it('should have ping and pong commands', function()
assert.are.same({ 'ping', 'pong' }, ping_plugin.commands)
end)
it('should have help text', function()
assert.is_truthy(ping_plugin.help)
assert.is_truthy(ping_plugin.help:match('/ping'))
end)
it('should have a description', function()
assert.are.equal('Check bot responsiveness', ping_plugin.description)
end)
it('should not be admin_only', function()
assert.is_falsy(ping_plugin.admin_only)
end)
it('should not be group_only', function()
assert.is_falsy(ping_plugin.group_only)
end)
end)
describe('on_message', function()
it('should respond with Pong for /ping', function()
message.command = 'ping'
message.date = os.time()
ping_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'send_message')
test_helper.assert_sent_message_matches(env.api, 'Pong!')
end)
it('should include latency in response', function()
message.command = 'ping'
message.date = os.time()
ping_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, '%d+ms')
end)
it('should use HTML parse mode for ping', function()
message.command = 'ping'
message.date = os.time()
ping_plugin.on_message(env.api, message, ctx)
local call = env.api.get_call('send_message')
- assert.are.equal('html', call.args[3])
+ assert.are.equal('html', call.args[3].parse_mode)
end)
it('should respond with snarky message for /pong', function()
message.command = 'pong'
ping_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'send_message')
test_helper.assert_sent_message_matches(env.api, 'extra mile')
end)
it('should send message to correct chat', function()
message.command = 'ping'
message.date = os.time()
ping_plugin.on_message(env.api, message, ctx)
local call = env.api.get_call('send_message')
assert.are.equal(message.chat.id, call.args[1])
end)
it('should work in private chats', function()
message = test_helper.make_private_message({
text = '/ping',
})
message.command = 'ping'
message.date = os.time()
ping_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'send_message')
end)
end)
end)
diff --git a/src/core/database.lua b/src/core/database.lua
index e33ac3d..e357678 100644
--- a/src/core/database.lua
+++ b/src/core/database.lua
@@ -1,369 +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()
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()
+ 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)
- database.release(pg)
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/http.lua b/src/core/http.lua
new file mode 100644
index 0000000..b1955d0
--- /dev/null
+++ b/src/core/http.lua
@@ -0,0 +1,121 @@
+--[[
+ mattata v2.1 - Async HTTP Module
+ Provides copas-compatible non-blocking HTTP requests.
+ Uses copas.http which wraps luasocket with async I/O.
+ Includes retry with exponential backoff for transient failures.
+]]
+
+local http_mod = {}
+
+local copas = require('copas')
+local http = require('copas.http')
+local ltn12 = require('ltn12')
+local logger = require('src.core.logger')
+
+local DEFAULT_HEADERS = {
+ ['User-Agent'] = 'mattata-telegram-bot/2.1'
+}
+
+local DEFAULT_TIMEOUT = 10 -- seconds
+local DEFAULT_MAX_RETRIES = 2 -- total attempts = 1 + retries
+local INITIAL_BACKOFF = 0.5 -- seconds
+local MAX_BACKOFF = 8 -- seconds
+
+-- Codes that should be retried
+local RETRYABLE_CODES = { [429] = true, [500] = true, [502] = true, [503] = true, [504] = true }
+
+local function merge_headers(custom)
+ local headers = {}
+ for k, v in pairs(DEFAULT_HEADERS) do headers[k] = v end
+ if custom then
+ for k, v in pairs(custom) do headers[k] = v end
+ end
+ return headers
+end
+
+function http_mod.request(opts)
+ local max_retries = opts.max_retries or DEFAULT_MAX_RETRIES
+ local timeout = opts.timeout or DEFAULT_TIMEOUT
+ local backoff = INITIAL_BACKOFF
+
+ for attempt = 1, max_retries + 1 do
+ local body = {}
+ local request_opts = {
+ url = opts.url,
+ method = opts.method or 'GET',
+ headers = merge_headers(opts.headers),
+ sink = ltn12.sink.table(body),
+ source = opts.source,
+ redirect = opts.redirect ~= false,
+ create = function()
+ local tcp = copas.wrap(require('socket').tcp())
+ tcp:settimeout(timeout)
+ return tcp
+ end
+ }
+
+ local ok, code_or_err, response_headers = pcall(http.request, request_opts)
+
+ if not ok then
+ -- Network error (timeout, connection refused, etc.)
+ if attempt <= max_retries then
+ logger.warn('HTTP %s %s attempt %d failed: %s — retrying in %.1fs',
+ opts.method or 'GET', opts.url, attempt, tostring(code_or_err), backoff)
+ copas.pause(backoff)
+ backoff = math.min(backoff * 2, MAX_BACKOFF)
+ else
+ logger.error('HTTP %s %s failed after %d attempts: %s',
+ opts.method or 'GET', opts.url, attempt, tostring(code_or_err))
+ return '', 0, {}
+ end
+ else
+ local code = code_or_err
+ -- Check for retryable HTTP status codes
+ if RETRYABLE_CODES[code] and attempt <= max_retries then
+ -- Respect Retry-After header for 429
+ local retry_after = response_headers and response_headers['retry-after']
+ local wait = tonumber(retry_after) or backoff
+ wait = math.min(wait, MAX_BACKOFF)
+ logger.warn('HTTP %s %s returned %d — retrying in %.1fs',
+ opts.method or 'GET', opts.url, code, wait)
+ copas.pause(wait)
+ backoff = math.min(backoff * 2, MAX_BACKOFF)
+ else
+ return table.concat(body), code, response_headers
+ end
+ end
+ end
+
+ return '', 0, {}
+end
+
+function http_mod.get(url, headers)
+ return http_mod.request({ url = url, headers = headers })
+end
+
+function http_mod.post(url, post_body, content_type, headers)
+ headers = headers or {}
+ headers['Content-Type'] = content_type or 'application/x-www-form-urlencoded'
+ headers['Content-Length'] = tostring(#post_body)
+ return http_mod.request({
+ url = url,
+ method = 'POST',
+ headers = headers,
+ source = ltn12.source.string(post_body)
+ })
+end
+
+function http_mod.get_json(url, headers)
+ local json = require('dkjson')
+ local body, code = http_mod.get(url, headers)
+ if code ~= 200 then
+ return nil, code
+ end
+ local data, _, err = json.decode(body)
+ if err then
+ return nil, 'JSON parse error: ' .. tostring(err)
+ end
+ return data, code
+end
+
+return http_mod
diff --git a/src/core/loader.lua b/src/core/loader.lua
index 1a01ad3..a29f13a 100644
--- a/src/core/loader.lua
+++ b/src/core/loader.lua
@@ -1,168 +1,196 @@
--[[
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)
- for _, pname in ipairs(PERMANENT_PLUGINS) do
- if pname == name then
- return true
- end
- end
- return false
+ 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/permissions.lua b/src/core/permissions.lua
index 19aa3b5..88d40c5 100644
--- a/src/core/permissions.lua
+++ b/src/core/permissions.lua
@@ -1,123 +1,136 @@
--[[
mattata v2.0 - Permissions Module
Centralised permission checks for admin/mod/trusted roles.
Includes bot permission checks with Redis caching.
]]
local permissions = {}
local config = require('src.core.config')
local session = require('src.core.session')
+-- Cached set of global admin IDs (rebuilt every 5 minutes)
+local admin_set = nil
+local admin_set_expires = 0
+
-- Check if a user is a global bot admin
function permissions.is_global_admin(user_id)
user_id = tonumber(user_id)
if not user_id then
return false
end
- for _, admin_id in ipairs(config.bot_admins()) do
- if tonumber(admin_id) == user_id then
- return true
+ local now = os.time()
+ if not admin_set or now >= admin_set_expires then
+ admin_set = {}
+ for _, id in ipairs(config.bot_admins()) do
+ admin_set[tonumber(id)] = true
end
+ admin_set_expires = now + 300
end
- return false
+ return admin_set[user_id] or false
+end
+
+-- Force rebuild of the global admin set (e.g. after config change)
+function permissions.clear_admin_cache()
+ admin_set = nil
+ admin_set_expires = 0
end
-- Check if a user is a group admin (Telegram admin/creator) or bot global admin
function permissions.is_group_admin(api, chat_id, user_id)
if not chat_id or not user_id then
return false
end
if permissions.is_global_admin(user_id) then
return true
end
-- Check cache first
local cached = session.get_admin_status(chat_id, user_id)
if cached ~= nil then
return cached
end
-- Query Telegram API
local member, err = api.get_chat_member(chat_id, user_id)
if not member or not member.result then
return false, err
end
local status = member.result.status
local is_admin = (status == 'creator' or status == 'administrator')
session.set_admin_status(chat_id, user_id, is_admin)
return is_admin, status
end
-- Check if a user is a moderator (custom role, stored in PostgreSQL)
function permissions.is_group_mod(db, chat_id, user_id)
if not chat_id or not user_id then
return false
end
local result = db.call('sp_check_group_moderator', { chat_id, user_id })
return result and #result > 0
end
-- check if a user is trusted in a group
function permissions.is_trusted(db, chat_id, user_id)
if not chat_id or not user_id then
return false
end
local result = db.call('sp_check_trusted_user', { chat_id, user_id })
return result and #result > 0
end
-- Check if the bot has a specific permission in a chat (cached for 5 min)
-- permission: 'can_restrict_members', 'can_delete_messages', 'can_promote_members',
-- 'can_pin_messages', 'can_invite_users'
function permissions.check_bot_can(api, chat_id, permission)
if not chat_id or not permission then
return false
end
-- Check cache first
local cache_key = string.format('bot_perm:%s', permission)
local cached = session.get_cached_setting(chat_id, cache_key, function()
local member, _ = api.get_chat_member(chat_id, api.info.id)
if not member or not member.result then
return nil
end
if member.result.status ~= 'administrator' then
return 'false'
end
return member.result[permission] and 'true' or 'false'
end, 300)
return cached == 'true'
end
-- Check if the bot can restrict members in a chat
function permissions.can_restrict(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_restrict_members')
end
-- Check if the bot can delete messages
function permissions.can_delete(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_delete_messages')
end
-- Check if the bot can promote members
function permissions.can_promote(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_promote_members')
end
-- Check if the bot can pin messages
function permissions.can_pin(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_pin_messages')
end
-- Check if the bot can invite users
function permissions.can_invite(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_invite_users')
end
-- Check if a user has admin OR mod rights
function permissions.is_admin_or_mod(api, db, chat_id, user_id)
if permissions.is_group_admin(api, chat_id, user_id) then
return true
end
return permissions.is_group_mod(db, chat_id, user_id)
end
return permissions
diff --git a/src/core/redis.lua b/src/core/redis.lua
index c75df8a..a805a8f 100644
--- a/src/core/redis.lua
+++ b/src/core/redis.lua
@@ -1,313 +1,312 @@
--[[
mattata v2.1 - Redis Connection Pool Module
Redis is used as cache/session store only. PostgreSQL is the primary database.
Implements connection pooling with copas semaphore guards,
automatic reconnection with backoff, SCAN replacement for KEYS, and pipeline support.
]]
local redis_mod = {}
local redis_lib = require('redis')
local config = require('src.core.config')
local logger = require('src.core.logger')
local copas_sem = require('copas.semaphore')
local pool = {}
local pool_size = 5
local pool_semaphore = nil
local redis_cfg = nil
-- Override hgetall to return key-value table instead of flat array
redis_lib.commands.hgetall = redis_lib.command('hgetall', {
response = function(response)
local result = {}
for i = 1, #response, 2 do
result[response[i]] = response[i + 1]
end
return result
end
})
-- Create a single Redis connection
local function create_connection()
if not redis_cfg then
redis_cfg = config.redis_config()
end
local conn
local ok, err = pcall(function()
conn = redis_lib.connect({
host = redis_cfg.host,
port = redis_cfg.port
})
end)
if not ok then
return nil, err
end
if redis_cfg.password and redis_cfg.password ~= '' then
conn:auth(redis_cfg.password)
end
if redis_cfg.db and redis_cfg.db ~= 0 then
conn:select(redis_cfg.db)
end
return conn
end
-- Acquire a connection from the pool
local function acquire()
-- Take a semaphore permit (blocks coroutine if pool exhausted)
if pool_semaphore then
local ok, err = pool_semaphore:take(1, 10)
if not ok then
logger.error('Redis pool semaphore timeout: %s', tostring(err))
return nil, 'Redis pool exhausted'
end
end
- -- Try pooled connections, discard dead ones
- while #pool > 0 do
- local conn = table.remove(pool)
- local ok = pcall(function() conn:ping() end)
- if ok then
- return conn
- end
- logger.warn('Discarding dead pooled Redis connection')
+ -- Return pooled connection (dead connections handled by safe_call retry)
+ if #pool > 0 then
+ return table.remove(pool)
end
-- Create fresh connection
local conn, err = create_connection()
if not conn then
logger.error('Failed to create Redis connection: %s', tostring(err))
if pool_semaphore then pool_semaphore:give(1) end
return nil, err
end
return conn
end
-- Release a connection back to the pool
local function release(conn)
if not conn then return end
if #pool < pool_size then
table.insert(pool, conn)
else
pcall(function() conn:quit() end)
end
if pool_semaphore then pool_semaphore:give(1) end
end
-- Discard a connection without returning it to the pool
local function discard(conn)
if conn then
pcall(function() conn:quit() end)
end
if pool_semaphore then pool_semaphore:give(1) end
end
-- Safe command wrapper with auto-reconnect
-- method_name is a string like 'get', 'set', etc.
local function safe_call(method_name, ...)
local conn, err = acquire()
if not conn then
return nil
end
local ok, result = pcall(function(...)
return conn[method_name](conn, ...)
end, ...)
if not ok then
-- Connection may have dropped mid-call — discard and retry once
logger.warn('Redis %s failed: %s — retrying after reconnect', method_name, tostring(result))
discard(conn)
conn, err = acquire()
if not conn then
logger.error('Redis reconnect failed: %s', tostring(err))
return nil
end
ok, result = pcall(function(...)
return conn[method_name](conn, ...)
end, ...)
if not ok then
logger.error('Redis %s failed after reconnect: %s', method_name, tostring(result))
discard(conn)
return nil
end
end
release(conn)
return result
end
function redis_mod.connect()
redis_cfg = config.redis_config()
pool_size = config.get_number('REDIS_POOL_SIZE', 5)
-- Create initial connection to validate credentials
local conn, err = create_connection()
if not conn then
logger.error('Failed to connect to Redis: %s', tostring(err))
return false, err
end
table.insert(pool, conn)
-- Create semaphore to guard concurrent pool access
-- max = pool_size, start = pool_size (all permits available), timeout = 10s
pool_semaphore = copas_sem.new(pool_size, pool_size, 10)
logger.info('Connected to Redis at %s:%d (db %d, pool size: %d)', redis_cfg.host, redis_cfg.port, redis_cfg.db or 0, pool_size)
return true
end
-- Get the raw redis client (deprecated — prefer using proxy functions)
function redis_mod.client()
logger.warn('redis.client() is deprecated — use redis proxy functions instead')
local conn = acquire()
return conn
end
-- Proxy common operations with auto-reconnect
function redis_mod.get(key)
return safe_call('get', key)
end
function redis_mod.set(key, value)
return safe_call('set', key, value)
end
function redis_mod.setex(key, ttl, value)
return safe_call('setex', key, ttl, value)
end
function redis_mod.setnx(key, value)
return safe_call('setnx', key, value)
end
function redis_mod.del(key)
return safe_call('del', key)
end
function redis_mod.exists(key)
return safe_call('exists', key)
end
function redis_mod.expire(key, ttl)
return safe_call('expire', key, ttl)
end
function redis_mod.incr(key)
return safe_call('incr', key)
end
function redis_mod.incrby(key, amount)
return safe_call('incrby', key, amount)
end
+function redis_mod.getset(key, value)
+ return safe_call('getset', key, value)
+end
+
function redis_mod.hget(key, field)
return safe_call('hget', key, field)
end
function redis_mod.hset(key, field, value)
return safe_call('hset', key, field, value)
end
function redis_mod.hdel(key, field)
return safe_call('hdel', key, field)
end
function redis_mod.hgetall(key)
return safe_call('hgetall', key)
end
function redis_mod.hexists(key, field)
return safe_call('hexists', key, field)
end
function redis_mod.hincrby(key, field, increment)
return safe_call('hincrby', key, field, increment)
end
function redis_mod.sadd(key, value)
return safe_call('sadd', key, value)
end
function redis_mod.srem(key, value)
return safe_call('srem', key, value)
end
function redis_mod.sismember(key, value)
return safe_call('sismember', key, value)
end
function redis_mod.smembers(key)
return safe_call('smembers', key)
end
-- List operations (used by AI plugin)
function redis_mod.rpush(key, value)
return safe_call('rpush', key, value)
end
function redis_mod.lrange(key, start, stop)
return safe_call('lrange', key, start, stop)
end
function redis_mod.ltrim(key, start, stop)
return safe_call('ltrim', key, start, stop)
end
-- SCAN-based iteration — replaces all KEYS usage
-- Returns all keys matching pattern without blocking
function redis_mod.scan(pattern)
local conn = acquire()
if not conn then
return {}
end
local results = {}
local cursor = '0'
repeat
local ok, reply = pcall(function()
return conn:scan(cursor, { match = pattern, count = 100 })
end)
if not ok or not reply then
discard(conn)
return results
end
cursor = reply[1]
for _, key in ipairs(reply[2]) do
table.insert(results, key)
end
until cursor == '0'
release(conn)
return results
end
-- DEPRECATED: kept for compatibility but uses SCAN internally
function redis_mod.keys(pattern)
logger.warn('redis.keys() called — prefer redis.scan() to avoid blocking')
return redis_mod.scan(pattern)
end
-- Pipeline support: batch multiple commands and execute together
function redis_mod.pipeline(fn)
local conn = acquire()
if not conn then
return nil
end
local pipeline = conn:pipeline()
fn(pipeline)
local ok, results = pcall(function()
return pipeline:execute()
end)
if not ok then
logger.error('Redis pipeline failed: %s', tostring(results))
discard(conn)
return nil
end
release(conn)
return results
end
function redis_mod.disconnect()
for _, conn in ipairs(pool) do
pcall(function() conn:quit() end)
end
pool = {}
pool_semaphore = nil
logger.info('Disconnected from Redis (pool drained)')
end
return redis_mod
diff --git a/src/core/router.lua b/src/core/router.lua
index fa368b9..070e94e 100644
--- a/src/core/router.lua
+++ b/src/core/router.lua
@@ -1,414 +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 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
+-- 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 = {}
- for k, v in pairs(ctx_base) do
- ctx[k] = v
- end
+ 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
- -- 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
+-- 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) and true or false
+ 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)
- message.text = message.text:gsub(name, tostring(entity.user.id), 1)
+ -- 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 (with Redis caching)
+-- 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
- -- 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
+ -- 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 '')
- ), 'html')
+ ), { parse_mode = '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
+ -- 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
- -- Handle member join events
- if message.new_chat_members and plugin.on_member_join then
+ 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' } })
- local lang_code = session.get_setting(inline_query.from.id, 'language') or 'en_gb'
- ctx.lang = i18n.get(lang_code)
+ ctx.lang = i18n.get(session.get_setting(inline_query.from.id, 'language') or 'en_gb')
+ dispatch_event('on_inline_query', inline_query, ctx)
+end
- 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
+-- 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
- 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
+ -- 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
+ -- 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_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
+ 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', 'message_reaction_count',
+ 'chat_boost', 'removed_chat_boost',
+ 'poll', 'poll_answer'
}
})
end
return router
diff --git a/src/core/session.lua b/src/core/session.lua
index 2841ee6..ee02228 100644
--- a/src/core/session.lua
+++ b/src/core/session.lua
@@ -1,215 +1,219 @@
--[[
mattata v2.0 - Session Manager
Redis wrapper for transient/cached data with TTL management.
]]
local session = {}
local redis
-- Initialise with redis module reference (avoids circular require)
function session.init(redis_mod)
redis = redis_mod
end
-- Settings cache (5 min TTL, fallback to PostgreSQL)
function session.get_setting(chat_id, key)
local cache_key = string.format('cache:setting:%s:%s', tostring(chat_id), tostring(key))
return redis.get(cache_key)
end
function session.set_setting(chat_id, key, value, ttl)
ttl = ttl or 300
local cache_key = string.format('cache:setting:%s:%s', tostring(chat_id), tostring(key))
return redis.setex(cache_key, ttl, tostring(value))
end
function session.invalidate_setting(chat_id, key)
local cache_key = string.format('cache:setting:%s:%s', tostring(chat_id), tostring(key))
return redis.del(cache_key)
end
-- Generic cached setting helper: check Redis first, fallback to fetch_fn, cache result
-- Used by on_new_message handlers to avoid DB queries on every message
function session.get_cached_setting(chat_id, key, fetch_fn, ttl)
ttl = ttl or 300
local cache_key = string.format('cache:setting:%s:%s', tostring(chat_id), tostring(key))
local cached = redis.get(cache_key)
if cached ~= nil then
if cached == '__nil__' then
return nil
end
return cached
end
local value = fetch_fn()
if value ~= nil then
redis.setex(cache_key, ttl, tostring(value))
else
-- Cache the nil result to avoid repeated DB queries
redis.setex(cache_key, ttl, '__nil__')
end
return value
end
-- Cache a JSON-serialisable table (for filter/trigger lists)
function session.get_cached_list(chat_id, key, fetch_fn, ttl)
ttl = ttl or 300
local json = require('dkjson')
local cache_key = string.format('cache:list:%s:%s', tostring(chat_id), tostring(key))
local cached = redis.get(cache_key)
if cached ~= nil then
if cached == '[]' then
return {}
end
local decoded = json.decode(cached)
if decoded then return decoded end
end
local value = fetch_fn()
if value then
redis.setex(cache_key, ttl, json.encode(value))
else
redis.setex(cache_key, ttl, '[]')
end
return value or {}
end
-- Invalidate a cached list
function session.invalidate_cached_list(chat_id, key)
local cache_key = string.format('cache:list:%s:%s', tostring(chat_id), tostring(key))
return redis.del(cache_key)
end
-- Admin cache (5 min TTL — increased from 2 min for performance)
function session.get_admin_status(chat_id, user_id)
local cache_key = string.format('cache:admin:%s:%s', tostring(chat_id), tostring(user_id))
local val = redis.get(cache_key)
if val == nil then
return nil
end
return val == '1'
end
function session.set_admin_status(chat_id, user_id, is_admin)
local cache_key = string.format('cache:admin:%s:%s', tostring(chat_id), tostring(user_id))
return redis.setex(cache_key, 300, is_admin and '1' or '0')
end
+function session.invalidate_admin_status(chat_id, user_id)
+ local cache_key = string.format('cache:admin:%s:%s', tostring(chat_id), tostring(user_id))
+ return redis.del(cache_key)
+end
+
-- Action state (multi-step commands, 5 min TTL)
function session.set_action(chat_id, message_id, command)
local key = string.format('action:%s:%s', tostring(chat_id), tostring(message_id))
return redis.setex(key, 300, command)
end
function session.get_action(chat_id, message_id)
local key = string.format('action:%s:%s', tostring(chat_id), tostring(message_id))
return redis.get(key)
end
function session.del_action(chat_id, message_id)
local key = string.format('action:%s:%s', tostring(chat_id), tostring(message_id))
return redis.del(key)
end
-- AFK status (persistent until return)
function session.set_afk(user_id, note)
redis.hset('afk:' .. tostring(user_id), 'since', tostring(os.time()))
if note and note ~= '' then
redis.hset('afk:' .. tostring(user_id), 'note', note)
end
end
function session.get_afk(user_id)
local since = redis.hget('afk:' .. tostring(user_id), 'since')
if not since then
return nil
end
return {
since = tonumber(since),
note = redis.hget('afk:' .. tostring(user_id), 'note')
}
end
function session.clear_afk(user_id)
redis.hdel('afk:' .. tostring(user_id), 'since')
redis.hdel('afk:' .. tostring(user_id), 'note')
-- Use SCAN instead of KEYS to clean up replied keys
local replied_keys = redis.scan('afk:' .. tostring(user_id) .. ':replied:*')
for _, key in ipairs(replied_keys) do
redis.del(key)
end
end
-- Captcha state (configurable TTL)
function session.set_captcha(chat_id, user_id, text, message_id, timeout)
timeout = timeout or 300
local hash = string.format('chat:%s:captcha:%s', tostring(chat_id), tostring(user_id))
redis.hset(hash, 'text', text)
redis.hset(hash, 'id', tostring(message_id))
+ redis.expire(hash, timeout)
redis.setex('captcha:' .. chat_id .. ':' .. user_id, timeout, '1')
end
function session.get_captcha(chat_id, user_id)
local hash = string.format('chat:%s:captcha:%s', tostring(chat_id), tostring(user_id))
local text = redis.hget(hash, 'text')
local id = redis.hget(hash, 'id')
if not text then
return nil
end
return { text = text, message_id = id }
end
function session.clear_captcha(chat_id, user_id)
local hash = string.format('chat:%s:captcha:%s', tostring(chat_id), tostring(user_id))
redis.hdel(hash, 'text')
redis.hdel(hash, 'id')
redis.del('captcha:' .. chat_id .. ':' .. user_id)
end
-- Rate limiting (short TTL counters)
function session.increment_rate(chat_id, user_id, ttl)
ttl = ttl or 5
local key = string.format('antispam:%s:%s', tostring(chat_id), tostring(user_id))
local count = redis.incr(key)
if count == 1 then
redis.expire(key, ttl)
end
return tonumber(count)
end
function session.get_rate(chat_id, user_id)
local key = string.format('antispam:%s:%s', tostring(chat_id), tostring(user_id))
return tonumber(redis.get(key)) or 0
end
-- Global blocklist (single exists check — fixed from double call)
function session.is_globally_blocklisted(user_id)
local result = redis.exists('global_blocklist:' .. tostring(user_id))
return result == 1 or result == true
end
function session.set_global_blocklist(user_id, ttl)
- redis.set('global_blocklist:' .. tostring(user_id), '1')
- if ttl then
- redis.expire('global_blocklist:' .. tostring(user_id), ttl)
- end
+ ttl = ttl or 604800 -- default 7 days
+ redis.setex('global_blocklist:' .. tostring(user_id), ttl, '1')
end
-- Disabled plugins cache
function session.get_disabled_plugins(chat_id)
return redis.smembers('disabled_plugins:' .. tostring(chat_id)) or {}
end
function session.is_plugin_disabled(chat_id, plugin_name)
local val = redis.sismember('disabled_plugins:' .. tostring(chat_id), plugin_name)
return val and val ~= false and val ~= 0
end
function session.disable_plugin(chat_id, plugin_name)
return redis.sadd('disabled_plugins:' .. tostring(chat_id), plugin_name)
end
function session.enable_plugin(chat_id, plugin_name)
return redis.srem('disabled_plugins:' .. tostring(chat_id), plugin_name)
end
return session
diff --git a/src/db/init.lua b/src/db/init.lua
index a65c6d7..36019d1 100644
--- a/src/db/init.lua
+++ b/src/db/init.lua
@@ -1,108 +1,109 @@
--[[
mattata v2.0 - Migration Runner
Runs pending SQL migrations in order, wrapped in transactions.
Supports migrations from src/db/migrations/ AND plugin.migration fields.
]]
local migrations = {}
local logger = require('src.core.logger')
local migration_files = {
{ name = '001_initial_schema', path = 'src.db.migrations.001_initial_schema' },
{ name = '002_federation_tables', path = 'src.db.migrations.002_federation_tables' },
{ name = '003_statistics_tables', path = 'src.db.migrations.003_statistics_tables' },
{ name = '004_performance_indexes', path = 'src.db.migrations.004_performance_indexes' },
- { name = '005_stored_procedures', path = 'src.db.migrations.005_stored_procedures' }
+ { name = '005_stored_procedures', path = 'src.db.migrations.005_stored_procedures' },
+ { name = '006_import_procedures', path = 'src.db.migrations.006_import_procedures' }
}
function migrations.run(db)
-- Create migrations tracking table
db.query([[
CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
]])
-- Run each migration if not already applied
for _, mig in ipairs(migration_files) do
local applied = db.execute(
'SELECT 1 FROM schema_migrations WHERE name = $1',
{ mig.name }
)
if not applied or #applied == 0 then
logger.info('Running migration: %s', mig.name)
local ok, mod = pcall(require, mig.path)
if ok and type(mod) == 'table' and mod.up then
-- Wrap migration in a transaction
local begin_ok, begin_err = db.query('BEGIN')
if not begin_ok and begin_err then
logger.error('Failed to begin transaction for migration %s: %s', mig.name, tostring(begin_err))
os.exit(1)
end
local sql = mod.up()
local migration_ok = true
local migration_err = nil
-- Split on semicolons, respecting $$-delimited blocks
local statements = {}
local current = ''
local in_dollar = false
local i = 1
while i <= #sql do
if sql:sub(i, i + 1) == '$$' then
in_dollar = not in_dollar
current = current .. '$$'
i = i + 2
elseif sql:sub(i, i) == ';' and not in_dollar then
local trimmed = current:match('^%s*(.-)%s*$')
if trimmed ~= '' then
statements[#statements + 1] = trimmed
end
current = ''
i = i + 1
else
current = current .. sql:sub(i, i)
i = i + 1
end
end
local trimmed = current:match('^%s*(.-)%s*$')
if trimmed ~= '' then
statements[#statements + 1] = trimmed
end
for _, statement in ipairs(statements) do
local result, err = db.query(statement)
if not result and err then
migration_ok = false
migration_err = err
break
end
end
if not migration_ok then
logger.error('Migration %s failed: %s — rolling back', mig.name, tostring(migration_err))
db.query('ROLLBACK')
os.exit(1)
end
-- Record migration as applied using parameterized query
db.execute(
'INSERT INTO schema_migrations (name) VALUES ($1)',
{ mig.name }
)
db.query('COMMIT')
logger.info('Migration %s applied successfully', mig.name)
else
logger.error('Failed to load migration %s: %s', mig.name, tostring(mod))
os.exit(1)
end
else
logger.debug('Migration %s already applied', mig.name)
end
end
logger.info('All migrations up to date')
end
return migrations
diff --git a/src/db/migrations/006_import_procedures.lua b/src/db/migrations/006_import_procedures.lua
new file mode 100644
index 0000000..21d41b9
--- /dev/null
+++ b/src/db/migrations/006_import_procedures.lua
@@ -0,0 +1,39 @@
+--[[
+ Migration 006 - Import Procedures
+ Additional stored procedures needed for the import plugin.
+ These return columns not covered by the base stored procedures in 005.
+]]
+
+local migration = {}
+
+function migration.up()
+ return [[
+
+-- Get all settings for a chat (import needs key + value pairs)
+CREATE OR REPLACE FUNCTION sp_get_all_chat_settings(
+ p_chat_id BIGINT
+) RETURNS TABLE(key TEXT, value TEXT) AS $$
+ SELECT cs.key, cs.value FROM chat_settings cs
+ WHERE cs.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+-- Get filters with response column (import needs pattern, action, and response)
+CREATE OR REPLACE FUNCTION sp_get_filters_full(
+ p_chat_id BIGINT
+) RETURNS TABLE(pattern TEXT, action VARCHAR(20), response TEXT) AS $$
+ SELECT f.pattern, f.action, f.response FROM filters f
+ WHERE f.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+-- Get welcome message with parse_mode (import needs both columns)
+CREATE OR REPLACE FUNCTION sp_get_welcome_message_full(
+ p_chat_id BIGINT
+) RETURNS TABLE(message TEXT, parse_mode TEXT) AS $$
+ SELECT wm.message, wm.parse_mode FROM welcome_messages wm
+ WHERE wm.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+ ]]
+end
+
+return migration
diff --git a/src/middleware/captcha.lua b/src/middleware/captcha.lua
index f41cb03..d76e98c 100644
--- a/src/middleware/captcha.lua
+++ b/src/middleware/captcha.lua
@@ -1,37 +1,43 @@
--[[
mattata v2.0 - Captcha Middleware
Gates new members with captcha verification when enabled.
The actual captcha challenge is handled by the join_captcha plugin.
This middleware restricts unverified users from chatting.
]]
local captcha = {}
captcha.name = 'captcha'
local session = require('src.core.session')
function captcha.run(ctx, message)
if not ctx.is_group or not message.from then
return ctx, true
end
- -- Check if this user has a pending captcha
+ -- Fast path: single EXISTS check (avoids 2 HGET calls for 99% of messages)
+ local has_captcha = ctx.redis.exists('captcha:' .. message.chat.id .. ':' .. message.from.id)
+ if has_captcha ~= 1 and has_captcha ~= true then
+ return ctx, true
+ end
+
+ -- Slow path: user has pending captcha, fetch details
local pending = session.get_captcha(message.chat.id, message.from.id)
if not pending then
return ctx, true
end
-- If user has pending captcha, only allow callback query responses (handled elsewhere)
-- Block regular messages from unverified users
if not message.new_chat_members then
-- Delete the message from the unverified user
pcall(function()
ctx.api.delete_message(message.chat.id, message.message_id)
end)
return ctx, false
end
return ctx, true
end
return captcha
diff --git a/src/middleware/stats.lua b/src/middleware/stats.lua
index b25e061..3186d56 100644
--- a/src/middleware/stats.lua
+++ b/src/middleware/stats.lua
@@ -1,94 +1,112 @@
--[[
mattata v2.0 - Stats Middleware
Increments Redis counters for message and command statistics.
Counters are flushed to PostgreSQL every 5 minutes via cron.
]]
local stats_mw = {}
stats_mw.name = 'stats'
local logger = require('src.core.logger')
function stats_mw.run(ctx, message)
if not message.from or not message.chat then
return ctx, true
end
local chat_id = message.chat.id
local user_id = message.from.id
local date = os.date('!%Y-%m-%d')
-- Increment message counter in Redis
local msg_key = string.format('stats:msg:%s:%s:%s', chat_id, date, user_id)
pcall(function()
local count = ctx.redis.incr(msg_key)
if count == 1 then
ctx.redis.expire(msg_key, 86400) -- 24h TTL
end
end)
-- Track command usage
if message.text and message.text:match('^[/!#]') then
local cmd = message.text:match('^[/!#]([%w_]+)')
if cmd then
local cmd_key = string.format('stats:cmd:%s:%s:%s', cmd:lower(), chat_id, date)
pcall(function()
local count = ctx.redis.incr(cmd_key)
if count == 1 then
ctx.redis.expire(cmd_key, 86400)
end
end)
end
end
return ctx, true
end
-- Cron job: flush Redis stats counters to PostgreSQL
-- Called from the stats flush plugin every 5 minutes
function stats_mw.flush(db, redis)
- -- Flush message stats
+ -- Flush message stats (atomic read+reset with GETSET)
local msg_keys = redis.scan('stats:msg:*')
local flushed = 0
for _, key in ipairs(msg_keys) do
- local count = tonumber(redis.get(key))
+ -- Atomically read and reset to 0 (no counts lost between read and clear)
+ local count = tonumber(redis.getset(key, '0'))
if count and count > 0 then
-- Parse key: stats:msg:{chat_id}:{date}:{user_id}
local chat_id, date, user_id = key:match('stats:msg:(%-?%d+):(%d%d%d%d%-%d%d%-%d%d):(%d+)')
if chat_id and date and user_id then
- pcall(function()
+ local ok = pcall(function()
db.call('sp_flush_message_stats', {
tonumber(chat_id), tonumber(user_id), date, count
})
end)
- redis.del(key)
+ if ok then
+ redis.del(key)
+ else
+ -- DB flush failed — restore the count so it's retried next cycle
+ redis.incrby(key, count)
+ end
flushed = flushed + 1
+ else
+ redis.del(key)
end
+ else
+ redis.del(key)
end
end
- -- Flush command stats
+ -- Flush command stats (atomic read+reset with GETSET)
local cmd_keys = redis.scan('stats:cmd:*')
for _, key in ipairs(cmd_keys) do
- local count = tonumber(redis.get(key))
+ local count = tonumber(redis.getset(key, '0'))
if count and count > 0 then
-- Parse key: stats:cmd:{command}:{chat_id}:{date}
local command, chat_id, date = key:match('stats:cmd:([%w_]+):(%-?%d+):(%d%d%d%d%-%d%d%-%d%d)')
if command and chat_id and date then
- pcall(function()
+ local ok = pcall(function()
db.call('sp_flush_command_stats', {
tonumber(chat_id), command, date, count
})
end)
- redis.del(key)
+ if ok then
+ redis.del(key)
+ else
+ redis.incrby(key, count)
+ end
flushed = flushed + 1
+ else
+ redis.del(key)
end
+ else
+ redis.del(key)
end
end
if flushed > 0 then
logger.info('Flushed %d stats counters to PostgreSQL', flushed)
end
end
return stats_mw
diff --git a/src/plugins/admin/addalias.lua b/src/plugins/admin/addalias.lua
index 5d8b318..2082179 100644
--- a/src/plugins/admin/addalias.lua
+++ b/src/plugins/admin/addalias.lua
@@ -1,52 +1,52 @@
--[[
mattata v2.0 - Add Alias Plugin
]]
local plugin = {}
plugin.name = 'addalias'
plugin.category = 'admin'
plugin.description = 'Add a command alias'
plugin.commands = { 'addalias' }
plugin.help = '/addalias <alias> <command> - Creates a command alias. Use /delalias <alias> to remove.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
-- List existing aliases
local aliases = ctx.redis.hgetall('chat:' .. message.chat.id .. ':aliases')
if not aliases or not next(aliases) then
return api.send_message(message.chat.id, 'No aliases are set.\nUsage: /addalias <alias> <command>')
end
local output = '<b>Command aliases:</b>\n\n'
for alias, original in pairs(aliases) do
output = output .. string.format('/<code>%s</code> -> /<code>%s</code>\n',
tools.escape_html(alias), tools.escape_html(original))
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
local alias, command = message.args:lower():match('^(%S+)%s+(%S+)$')
if not alias or not command then
return api.send_message(message.chat.id, 'Usage: /addalias <alias> <command>')
end
-- Strip leading slashes
alias = alias:gsub('^[/!#]', '')
command = command:gsub('^[/!#]', '')
if alias == command then
return api.send_message(message.chat.id, 'The alias can\'t be the same as the command.')
end
ctx.redis.hset('chat:' .. message.chat.id .. ':aliases', alias, command)
api.send_message(message.chat.id, string.format(
'Alias created: /<code>%s</code> -> /<code>%s</code>',
tools.escape_html(alias), tools.escape_html(command)
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/addtrigger.lua b/src/plugins/admin/addtrigger.lua
index 033b060..90134f0 100644
--- a/src/plugins/admin/addtrigger.lua
+++ b/src/plugins/admin/addtrigger.lua
@@ -1,117 +1,143 @@
--[[
mattata v2.0 - Add Trigger Plugin
]]
local plugin = {}
plugin.name = 'addtrigger'
plugin.category = 'admin'
plugin.description = 'Add a trigger (auto-response pattern)'
-plugin.commands = { 'addtrigger' }
+plugin.commands = { 'addtrigger', 'deltrigger' }
plugin.help = '/addtrigger <pattern> <response> - Adds a trigger. Use /deltrigger <number> to remove.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
return api.send_message(message.chat.id,
'Usage: /addtrigger <pattern> <response>\n\n'
.. 'The pattern is a Lua pattern that will be matched against incoming messages. '
.. 'When matched, the response is sent.')
end
-- if used with /deltrigger, handle deletion
if message.command == 'deltrigger' then
local index = tonumber(message.args)
if not index then
return api.send_message(message.chat.id, 'Usage: /deltrigger <number>')
end
local triggers = ctx.db.call('sp_get_triggers_ordered', { message.chat.id })
if not triggers or not triggers[index] then
return api.send_message(message.chat.id, 'Invalid trigger number. Use /triggers to see the list.')
end
ctx.db.call('sp_delete_trigger_by_id', { triggers[index].id })
-- invalidate trigger cache
require('src.core.session').invalidate_cached_list(message.chat.id, 'triggers')
return api.send_message(message.chat.id, string.format(
'Trigger <code>%s</code> has been removed.',
tools.escape_html(triggers[index].pattern)
- ), 'html')
+ ), { parse_mode = 'html' })
end
-- parse pattern and response (split on first newline or first space after the pattern)
local pattern, response
if message.args:match('\n') then
pattern, response = message.args:match('^(.-)%s*\n%s*(.+)$')
else
pattern, response = message.args:match('^(%S+)%s+(.+)$')
end
if not pattern or not response then
return api.send_message(message.chat.id, 'Usage: /addtrigger <pattern> <response>')
end
pattern = pattern:match('^%s*(.-)%s*$')
response = response:match('^%s*(.-)%s*$')
- -- validate pattern
+ -- validate pattern syntax
local ok = pcall(string.match, '', pattern)
if not ok then
return api.send_message(message.chat.id, 'Invalid Lua pattern. Please check your syntax.')
end
+ -- reject patterns that could cause catastrophic backtracking
+ if #pattern > 128 then
+ return api.send_message(message.chat.id, 'Pattern too long (max 128 characters).')
+ end
+ local wq_count = 0
+ do
+ local i = 1
+ while i <= #pattern do
+ if pattern:sub(i, i) == '%' then
+ i = i + 2
+ elseif pattern:sub(i, i) == '.' and i < #pattern then
+ local nc = pattern:sub(i + 1, i + 1)
+ if nc == '+' or nc == '*' or nc == '-' then wq_count = wq_count + 1 end
+ i = i + 1
+ else
+ i = i + 1
+ end
+ end
+ end
+ if wq_count > 3 then
+ return api.send_message(message.chat.id, 'Pattern too complex (too many wildcard repetitions).')
+ end
+
-- check for duplicate
local existing = ctx.db.call('sp_check_trigger_exists', { message.chat.id, pattern })
if existing and #existing > 0 then
ctx.db.call('sp_update_trigger_response', { response, message.chat.id, pattern })
return api.send_message(message.chat.id, string.format(
'Trigger <code>%s</code> has been updated.',
tools.escape_html(pattern)
- ), 'html')
+ ), { parse_mode = 'html' })
end
ctx.db.call('sp_insert_trigger', { message.chat.id, pattern, response, message.from.id })
-- invalidate trigger cache
local session = require('src.core.session')
session.invalidate_cached_list(message.chat.id, 'triggers')
api.send_message(message.chat.id, string.format(
'Trigger added: <code>%s</code> -> %s',
tools.escape_html(pattern),
tools.escape_html(response:sub(1, 100)) .. (#response > 100 and '...' or '')
- ), 'html')
+ ), { parse_mode = 'html' })
end
-- handle trigger matching on every new message
function plugin.on_new_message(api, message, ctx)
if not ctx.is_group or not message.text or message.text == '' then return end
-- don't trigger on commands
if message.text:match('^[/!#]') then return end
-- cache triggers per chat (5-min ttl)
local session = require('src.core.session')
local triggers = session.get_cached_list(message.chat.id, 'triggers', function()
return ctx.db.call('sp_get_triggers', { message.chat.id })
end, 300)
if not triggers or #triggers == 0 then return end
local text = message.text:lower()
for _, t in ipairs(triggers) do
+ -- Skip patterns that could cause catastrophic backtracking
+ if #t.pattern > 128 then goto continue end
local ok, matched = pcall(function()
return text:match(t.pattern:lower())
end)
if ok and matched then
if t.is_media and t.file_id then
-- send media response
- api.send_document(message.chat.id, t.file_id, nil, nil, nil, message.message_id)
+ api.send_document(message.chat.id, t.file_id, { reply_parameters = { message_id = message.message_id } })
else
- api.send_message(message.chat.id, t.response, nil, nil, nil, message.message_id)
+ api.send_message(message.chat.id, t.response, { reply_parameters = { message_id = message.message_id } })
end
return
end
+ ::continue::
end
end
return plugin
diff --git a/src/plugins/admin/administration.lua b/src/plugins/admin/administration.lua
index 362e75d..e3e7623 100644
--- a/src/plugins/admin/administration.lua
+++ b/src/plugins/admin/administration.lua
@@ -1,180 +1,178 @@
--[[
mattata v2.0 - Administration Plugin
Main settings panel with inline keyboard for toggling settings.
]]
local plugin = {}
plugin.name = 'administration'
plugin.category = 'admin'
plugin.description = 'Main administration settings panel'
plugin.commands = { 'administration', 'settings' }
plugin.help = '/administration - Opens the administration settings panel. Alias: /settings'
plugin.group_only = true
plugin.admin_only = true
-local json = require('dkjson')
-
-- toggleable settings with display names and keys
local SETTINGS = {
{ key = 'antilink_enabled', name = 'Anti-Link', description = 'Delete Telegram invite links from non-admins' },
{ key = 'wordfilter_enabled', name = 'Word Filter', description = 'Filter messages matching patterns' },
{ key = 'captcha_enabled', name = 'Join Captcha', description = 'Require captcha for new members' },
{ key = 'antibot', name = 'Anti-Bot', description = 'Kick bots added by non-admins' },
{ key = 'delete_commands', name = 'Delete Commands', description = 'Auto-delete command messages' },
{ key = 'force_group_language', name = 'Force Group Language', description = 'Force all users to use group language' },
{ key = 'welcome_enabled', name = 'Welcome Message', description = 'Send welcome message for new members' },
{ key = 'log_admin_actions', name = 'Log Admin Actions', description = 'Log admin actions to log chat' },
{ key = 'anonymous_admin', name = 'Anonymous Admin', description = 'Hide admin names in action messages' },
{ key = 'lock_stickers', name = 'Lock Stickers', description = 'Prevent non-admins from sending stickers' },
{ key = 'lock_gifs', name = 'Lock GIFs', description = 'Prevent non-admins from sending GIFs' },
{ key = 'lock_forwards', name = 'Lock Forwards', description = 'Prevent non-admins from forwarding messages' }
}
local function is_setting_enabled(ctx, chat_id, key)
local result = ctx.db.call('sp_get_chat_setting', { chat_id, key })
return result and #result > 0 and result[1].value == 'true'
end
local function build_keyboard(ctx, chat_id, page)
page = page or 1
local per_page = 6
local start_idx = (page - 1) * per_page + 1
local end_idx = math.min(start_idx + per_page - 1, #SETTINGS)
local total_pages = math.ceil(#SETTINGS / per_page)
local keyboard = { inline_keyboard = {} }
for i = start_idx, end_idx do
local s = SETTINGS[i]
local enabled = is_setting_enabled(ctx, chat_id, s.key)
local status_icon = enabled and '[ON]' or '[OFF]'
table.insert(keyboard.inline_keyboard, {
{
text = string.format('%s %s', s.name, status_icon),
callback_data = string.format('administration:toggle:%s:%d', s.key, page)
}
})
end
-- navigation row
if total_pages > 1 then
local nav_row = {}
if page > 1 then
table.insert(nav_row, {
text = '<< Previous',
callback_data = 'administration:page:' .. (page - 1)
})
end
table.insert(nav_row, {
text = string.format('%d/%d', page, total_pages),
callback_data = 'administration:noop'
})
if page < total_pages then
table.insert(nav_row, {
text = 'Next >>',
callback_data = 'administration:page:' .. (page + 1)
})
end
table.insert(keyboard.inline_keyboard, nav_row)
end
-- close button
table.insert(keyboard.inline_keyboard, {
{
text = 'Close',
callback_data = 'administration:close'
}
})
return keyboard
end
local function build_message(ctx, chat_id)
local tools = require('telegram-bot-lua.tools')
local chat_info = ''
local chat = ctx.api and ctx.api.get_chat(chat_id) or nil
if chat and chat.result then
chat_info = tools.escape_html(chat.result.title or 'this group')
else
chat_info = 'this group'
end
return string.format(
'<b>Administration settings for %s</b>\n\nTap a setting to toggle it on or off.',
chat_info
)
end
function plugin.on_message(api, message, ctx)
local text = build_message(ctx, message.chat.id)
local keyboard = build_keyboard(ctx, message.chat.id, 1)
- api.send_message(message.chat.id, text, 'html', false, false, nil, json.encode(keyboard))
+ api.send_message(message.chat.id, text, { parse_mode = 'html', reply_markup = keyboard })
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local permissions = require('src.core.permissions')
local data = callback_query.data
if not data then return end
-- only admins can change settings
if not permissions.is_group_admin(api, message.chat.id, callback_query.from.id) then
- return api.answer_callback_query(callback_query.id, 'Only admins can change settings.')
+ return api.answer_callback_query(callback_query.id, { text = 'Only admins can change settings.' })
end
if data == 'noop' then
return api.answer_callback_query(callback_query.id)
end
if data == 'close' then
return api.delete_message(message.chat.id, message.message_id)
end
if data:match('^page:%d+$') then
local page = tonumber(data:match('^page:(%d+)$'))
local text = build_message(ctx, message.chat.id)
local keyboard = build_keyboard(ctx, message.chat.id, page)
- api.edit_message_text(message.chat.id, message.message_id, text, 'html', false, json.encode(keyboard))
+ api.edit_message_text(message.chat.id, message.message_id, text, { parse_mode = 'html', reply_markup = keyboard })
return api.answer_callback_query(callback_query.id)
end
if data:match('^toggle:') then
local key, page = data:match('^toggle:(%S+):(%d+)$')
if not key then
key = data:match('^toggle:(%S+)$')
page = 1
end
page = tonumber(page) or 1
-- toggle the setting
local currently_enabled = is_setting_enabled(ctx, message.chat.id, key)
if currently_enabled then
ctx.db.call('sp_disable_chat_setting', { message.chat.id, key })
else
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, key, 'true' })
end
-- invalidate cache for the toggled setting
require('src.core.session').invalidate_setting(message.chat.id, key)
-- find the setting name for the callback response
local setting_name = key
for _, s in ipairs(SETTINGS) do
if s.key == key then
setting_name = s.name
break
end
end
local new_state = not currently_enabled
-- rebuild keyboard with updated state
local text = build_message(ctx, message.chat.id)
local keyboard = build_keyboard(ctx, message.chat.id, page)
- api.edit_message_text(message.chat.id, message.message_id, text, 'html', false, json.encode(keyboard))
+ api.edit_message_text(message.chat.id, message.message_id, text, { parse_mode = 'html', reply_markup = keyboard })
- return api.answer_callback_query(callback_query.id, string.format(
+ return api.answer_callback_query(callback_query.id, { text = string.format(
'%s is now %s.', setting_name, new_state and 'enabled' or 'disabled'
- ))
+ ) })
end
end
return plugin
diff --git a/src/plugins/admin/allowedlinks.lua b/src/plugins/admin/allowedlinks.lua
index 1728ef0..6ea2f1b 100644
--- a/src/plugins/admin/allowedlinks.lua
+++ b/src/plugins/admin/allowedlinks.lua
@@ -1,32 +1,32 @@
--[[
mattata v2.0 - Allowed Links Plugin
]]
local plugin = {}
plugin.name = 'allowedlinks'
plugin.category = 'admin'
plugin.description = 'List allowed links in the group'
plugin.commands = { 'allowedlinks' }
plugin.help = '/allowedlinks - Lists all links that are allowed in this group when anti-link is enabled.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local result = ctx.db.call('sp_get_allowed_links', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No links are allowlisted. Use /allowlink <link> to add one.')
end
local output = '<b>Allowed links:</b>\n\n'
for i, row in ipairs(result) do
output = output .. string.format('%d. <code>%s</code>\n', i, tools.escape_html(row.link))
end
output = output .. string.format('\n<i>Total: %d link(s)</i>\nUse /allowlink <link> to add more.', #result)
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/allowlink.lua b/src/plugins/admin/allowlink.lua
index 7e70f57..a8c4fed 100644
--- a/src/plugins/admin/allowlink.lua
+++ b/src/plugins/admin/allowlink.lua
@@ -1,65 +1,65 @@
--[[
mattata v2.0 - Allow Link Plugin
]]
local plugin = {}
plugin.name = 'allowlink'
plugin.category = 'admin'
plugin.description = 'Add or remove a link from the allowed links list'
plugin.commands = { 'allowlink' }
plugin.help = '/allowlink <link|@username> - Adds a link to the allowed list. /allowlink remove <link> - Removes it.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
return api.send_message(message.chat.id, 'Usage:\n/allowlink <link|@username> - Allow a link\n/allowlink remove <link|@username> - Remove from allowed list')
end
local args = message.args
local is_remove = false
if args:lower():match('^remove%s+') or args:lower():match('^del%s+') then
is_remove = true
args = args:gsub('^%S+%s+', '')
end
-- normalise the link - extract the relevant part
local link = args:match('^%s*(.-)%s*$')
-- strip protocol and domain prefixes
link = link:gsub('^https?://', '')
link = link:gsub('^[Tt]%.?[Mm][Ee]/', '')
link = link:gsub('^[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Mm][Ee]/', '')
link = link:gsub('^[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Dd][Oo][Gg]/', '')
link = link:gsub('^@', '')
if link == '' then
return api.send_message(message.chat.id, 'Please provide a valid link or username.')
end
if is_remove then
ctx.db.call('sp_delete_allowed_link', { message.chat.id, link })
-- also try with lowercase
ctx.db.call('sp_delete_allowed_link', { message.chat.id, link:lower() })
return api.send_message(message.chat.id, string.format(
'Link <code>%s</code> has been removed from the allowed list.',
tools.escape_html(link)
- ), 'html')
+ ), { parse_mode = 'html' })
end
-- check if already allowed
local existing = ctx.db.call('sp_check_allowed_link', { message.chat.id, link })
if existing and #existing > 0 then
return api.send_message(message.chat.id, 'That link is already allowed.')
end
ctx.db.call('sp_insert_allowed_link', { message.chat.id, link })
api.send_message(message.chat.id, string.format(
'Link <code>%s</code> has been added to the allowed list.',
tools.escape_html(link)
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/allowlist.lua b/src/plugins/admin/allowlist.lua
index 95977f3..85d52b7 100644
--- a/src/plugins/admin/allowlist.lua
+++ b/src/plugins/admin/allowlist.lua
@@ -1,76 +1,76 @@
--[[
mattata v2.0 - Allowlist Plugin
]]
local plugin = {}
plugin.name = 'allowlist'
plugin.category = 'admin'
plugin.description = 'Manage the group allowlist'
plugin.commands = { 'allowlist' }
plugin.help = '/allowlist add <user> - Adds a user to the allowlist. /allowlist remove <user> - Removes a user. /allowlist - Lists allowlisted users.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
-- list allowlisted users
local result = ctx.db.call('sp_get_allowlisted_users', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No users are allowlisted.\nUsage: /allowlist add <user>')
end
local output = '<b>Allowlisted users:</b>\n\n'
for _, row in ipairs(result) do
local info = api.get_chat(row.user_id)
local name = info and info.result and tools.escape_html(info.result.first_name) or tostring(row.user_id)
output = output .. string.format('- <a href="tg://user?id=%s">%s</a> [%s]\n', row.user_id, name, row.user_id)
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
local action, target = message.args:lower():match('^(%S+)%s+(.+)$')
if not action then
return api.send_message(message.chat.id, 'Usage: /allowlist <add|remove> <user>')
end
-- resolve target user
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
else
user_id = target:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'I couldn\'t find that user. Try replying to their message or providing a valid username/ID.')
end
if action == 'add' then
ctx.db.call('sp_set_member_role', { message.chat.id, user_id, 'allowlisted' })
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been added to the allowlist.',
user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
elseif action == 'remove' or action == 'del' or action == 'delete' then
ctx.db.call('sp_remove_allowlisted', { message.chat.id, user_id })
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been removed from the allowlist.',
user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
else
api.send_message(message.chat.id, 'Usage: /allowlist <add|remove> <user>')
end
end
return plugin
diff --git a/src/plugins/admin/antilink.lua b/src/plugins/admin/antilink.lua
index fcaef8a..ca761ba 100644
--- a/src/plugins/admin/antilink.lua
+++ b/src/plugins/admin/antilink.lua
@@ -1,96 +1,96 @@
--[[
mattata v2.0 - Anti-Link Plugin
]]
local plugin = {}
plugin.name = 'antilink'
plugin.category = 'admin'
plugin.description = 'Toggle anti-link mode to delete Telegram invite links from non-admins'
plugin.commands = { 'antilink' }
plugin.help = '/antilink <on|off> - Toggle anti-link mode.'
plugin.group_only = true
plugin.admin_only = true
local INVITE_PATTERNS = {
'[Tt]%.?[Mm][Ee]/[Jj][Oo][Ii][Nn][Cc][Hh][Aa][Tt]/[%w_%-]+',
'[Tt]%.?[Mm][Ee]/[%+][%w_%-]+',
'[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Mm][Ee]/[Jj][Oo][Ii][Nn][Cc][Hh][Aa][Tt]/[%w_%-]+',
'[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Dd][Oo][Gg]/[Jj][Oo][Ii][Nn][Cc][Hh][Aa][Tt]/[%w_%-]+',
'[Tt][Gg]://[Jj][Oo][Ii][Nn]%?[Ii][Nn][Vv][Ii][Tt][Ee]=[%w_%-]+'
}
function plugin.on_message(api, message, ctx)
if not message.args then
local enabled = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'antilink_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(
'Anti-link is currently <b>%s</b>.\nUsage: /antilink <on|off>', status
- ), 'html')
+ ), { parse_mode = 'html' })
end
local arg = message.args:lower()
if arg == 'on' or arg == 'enable' then
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'antilink_enabled', 'true' })
require('src.core.session').invalidate_setting(message.chat.id, 'antilink_enabled')
return api.send_message(message.chat.id, 'Anti-link has been enabled. Telegram invite links from non-admins will be deleted.')
elseif arg == 'off' or arg == 'disable' then
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'antilink_enabled', 'false' })
require('src.core.session').invalidate_setting(message.chat.id, 'antilink_enabled')
return api.send_message(message.chat.id, 'Anti-link has been disabled.')
else
return api.send_message(message.chat.id, 'Usage: /antilink <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 ctx.is_global_admin or ctx:check_admin() then return end
if not require('src.core.permissions').can_delete(api, message.chat.id) then return end
-- check if antilink is enabled (cached)
local session = require('src.core.session')
local enabled = session.get_cached_setting(message.chat.id, 'antilink_enabled', function()
local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'antilink_enabled' })
if result and #result > 0 then return result[1].value end
return nil
end, 300)
if enabled ~= 'true' then
return
end
-- check if user is trusted
local permissions = require('src.core.permissions')
if permissions.is_trusted(ctx.db, message.chat.id, message.from.id) then
return
end
-- build full text including entity urls
local text = message.text
if message.entities then
for _, entity in ipairs(message.entities) do
if entity.type == 'text_link' and entity.url then
text = text .. ' ' .. entity.url
end
end
end
-- check for allowed links
for _, pattern in ipairs(INVITE_PATTERNS) do
if text:match(pattern) then
-- check if link is allowed
local link = text:match(pattern)
local allowed = ctx.db.call('sp_check_allowed_link', { message.chat.id, link })
if not allowed or #allowed == 0 then
api.delete_message(message.chat.id, message.message_id)
local tools = require('telegram-bot-lua.tools')
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a>, invite links are not allowed in this group.',
message.from.id, tools.escape_html(message.from.first_name)
- ), 'html')
+ ), { parse_mode = 'html' })
return
end
end
end
end
return plugin
diff --git a/src/plugins/admin/antispam.lua b/src/plugins/admin/antispam.lua
index 2f2c7e7..187e6c1 100644
--- a/src/plugins/admin/antispam.lua
+++ b/src/plugins/admin/antispam.lua
@@ -1,71 +1,71 @@
--[[
mattata v2.0 - Antispam Plugin
]]
local plugin = {}
plugin.name = 'antispam'
plugin.category = 'admin'
plugin.description = 'Configure antispam settings'
plugin.commands = { 'antispam' }
plugin.help = '/antispam [text|sticker|photo|video|document|forward] <limit> - Set per-type message limits.'
plugin.group_only = true
plugin.admin_only = true
local VALID_TYPES = {
text = true,
sticker = true,
photo = true,
video = true,
document = true,
forward = true,
audio = true,
voice = true,
gif = true
}
function plugin.on_message(api, message, ctx)
if not message.args then
-- show current antispam settings
local settings = ctx.db.call('sp_get_chat_settings_like', { message.chat.id, 'antispam_%' })
local output = '<b>Antispam settings:</b>\n\n'
if settings and #settings > 0 then
for _, row in ipairs(settings) do
local msg_type = row.key:gsub('antispam_', '')
output = output .. string.format('- %s: %s message(s) per 5 seconds\n', msg_type, row.value)
end
else
output = output .. 'No custom limits set. Default limits apply.\n'
end
output = output .. '\nUsage: <code>/antispam &lt;type&gt; &lt;limit&gt;</code>\nTypes: text, sticker, photo, video, document, forward, audio, voice, gif\n'
output = output .. '<code>/antispam &lt;type&gt; off</code> - Remove limit'
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
local msg_type, limit = message.args:lower():match('^(%S+)%s+(.+)$')
if not msg_type then
return api.send_message(message.chat.id, 'Usage: /antispam <type> <limit|off>')
end
if not VALID_TYPES[msg_type] then
return api.send_message(message.chat.id, 'Invalid type. Valid types: text, sticker, photo, video, document, forward, audio, voice, gif')
end
local setting_key = 'antispam_' .. msg_type
if limit == 'off' or limit == 'disable' or limit == '0' then
ctx.db.call('sp_delete_chat_setting', { message.chat.id, setting_key })
- return api.send_message(message.chat.id, string.format('Antispam limit for <b>%s</b> has been removed.', msg_type), 'html')
+ return api.send_message(message.chat.id, string.format('Antispam limit for <b>%s</b> has been removed.', msg_type), { parse_mode = 'html' })
end
limit = tonumber(limit)
if not limit or limit < 1 or limit > 100 then
return api.send_message(message.chat.id, 'Limit must be a number between 1 and 100.')
end
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, setting_key, tostring(limit) })
api.send_message(message.chat.id, string.format(
'Antispam limit for <b>%s</b> set to <b>%d</b> message(s) per 5 seconds.',
msg_type, limit
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/autodelete.lua b/src/plugins/admin/autodelete.lua
new file mode 100644
index 0000000..dc2b535
--- /dev/null
+++ b/src/plugins/admin/autodelete.lua
@@ -0,0 +1,74 @@
+--[[
+ mattata v2.0 - Auto-Delete Plugin
+ Configures automatic deletion of bot responses after a delay.
+ The actual deletion logic is handled by a separate middleware/handler.
+]]
+
+local plugin = {}
+plugin.name = 'autodelete'
+plugin.category = 'admin'
+plugin.description = 'Auto-delete bot command messages after a configurable delay'
+plugin.commands = { 'autodelete' }
+plugin.help = '/autodelete <off|10|30|60|300> - Set the delay before bot responses are auto-deleted.'
+plugin.group_only = true
+plugin.admin_only = true
+
+local session = require('src.core.session')
+
+local VALID_DELAYS = {
+ ['10'] = true,
+ ['30'] = true,
+ ['60'] = true,
+ ['300'] = true
+}
+
+local HUMAN_LABELS = {
+ ['10'] = '10 seconds',
+ ['30'] = '30 seconds',
+ ['60'] = '1 minute',
+ ['300'] = '5 minutes'
+}
+
+function plugin.on_message(api, message, ctx)
+ if not message.args then
+ local current = session.get_setting(message.chat.id, 'autodelete_delay')
+ local status
+ if current then
+ status = string.format('Auto-delete is set to <b>%s</b>.', HUMAN_LABELS[current] or (current .. ' seconds'))
+ else
+ status = 'Auto-delete is currently <b>disabled</b>.'
+ end
+ return api.send_message(message.chat.id,
+ status .. '\n\n'
+ .. 'Usage: <code>/autodelete &lt;delay&gt;</code>\n\n'
+ .. 'Valid values:\n'
+ .. '<code>off</code> - Disable auto-delete\n'
+ .. '<code>10</code> - 10 seconds\n'
+ .. '<code>30</code> - 30 seconds\n'
+ .. '<code>60</code> - 1 minute\n'
+ .. '<code>300</code> - 5 minutes',
+ { parse_mode = 'html' }
+ )
+ end
+
+ local arg = message.args:lower():gsub('%s+', '')
+
+ if arg == 'off' or arg == 'disable' then
+ session.invalidate_setting(message.chat.id, 'autodelete_delay')
+ return api.send_message(message.chat.id, 'Auto-delete has been disabled.')
+ end
+
+ if not VALID_DELAYS[arg] then
+ return api.send_message(message.chat.id,
+ 'Invalid delay. Valid values: off, 10, 30, 60, 300'
+ )
+ end
+
+ -- Use redis.set directly for persistent storage (session.set_setting uses setex which requires TTL > 0)
+ ctx.redis.set(string.format('cache:setting:%s:autodelete_delay', tostring(message.chat.id)), arg)
+ return api.send_message(message.chat.id,
+ string.format('Bot responses will now be auto-deleted after %s.', HUMAN_LABELS[arg] or (arg .. ' seconds'))
+ )
+end
+
+return plugin
diff --git a/src/plugins/admin/ban.lua b/src/plugins/admin/ban.lua
index f2ad125..853facb 100644
--- a/src/plugins/admin/ban.lua
+++ b/src/plugins/admin/ban.lua
@@ -1,89 +1,89 @@
--[[
mattata v2.0 - Ban Plugin
]]
local plugin = {}
plugin.name = 'ban'
plugin.category = 'admin'
plugin.description = 'Ban users from a group'
plugin.commands = { 'ban', 'b' }
plugin.help = '/ban [user] [reason] - Bans a user from the current chat.'
plugin.group_only = true
plugin.admin_only = true
local function resolve_target(api, message, ctx)
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then return nil, nil end
-- strip 'for' prefix from reason
if reason and reason:lower():match('^for ') then
reason = reason:sub(5)
end
-- resolve username to id
if tonumber(user_id) == nil then
user_id = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. user_id:lower())
end
return tonumber(user_id), reason
end
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, reason = resolve_target(api, message, ctx)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to ban, either by replying to their message or providing a username/ID.')
end
if user_id == api.info.id then return end
-- check target isn't an admin
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t ban an admin or moderator.')
end
-- attempt ban
local success = api.ban_chat_member(message.chat.id, user_id)
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to ban users. Please make sure I\'m an admin with ban rights.')
end
-- log to database
pcall(function()
- ctx.db.call('sp_insert_ban', { message.chat.id, user_id, message.from.id, reason })
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'ban', reason })
+ ctx.db.call('sp_insert_ban', table.pack(message.chat.id, user_id, message.from.id, reason))
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'ban', reason))
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and ('\nReason: ' .. tools.escape_html(reason)) or ''
local output = string.format(
'<a href="tg://user?id=%d">%s</a> has banned <a href="tg://user?id=%d">%s</a>.%s',
message.from.id, admin_name, user_id, target_name, reason_text
)
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
-- clean up messages
if message.reply then
pcall(function() api.delete_message(message.chat.id, message.reply.message_id) end)
end
pcall(function() api.delete_message(message.chat.id, message.message_id) end)
end
return plugin
diff --git a/src/plugins/admin/blocklist.lua b/src/plugins/admin/blocklist.lua
index 450e3b3..6d6802d 100644
--- a/src/plugins/admin/blocklist.lua
+++ b/src/plugins/admin/blocklist.lua
@@ -1,108 +1,108 @@
--[[
mattata v2.0 - Blocklist Plugin
]]
local plugin = {}
plugin.name = 'blocklist'
plugin.category = 'admin'
plugin.description = 'Manage the group blocklist'
plugin.commands = { 'blocklist', 'block', 'unblock' }
plugin.help = '/blocklist - List blocked users. /block <user> [reason] - Block a user. /unblock <user> - Unblock a user.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
-- /blocklist with no args: list blocked users
if message.command == 'blocklist' and not message.args then
local result = ctx.db.call('sp_get_blocklist', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No users are blocklisted in this group.')
end
local output = '<b>Blocklisted users:</b>\n\n'
for _, row in ipairs(result) do
local info = api.get_chat(row.user_id)
local name = info and info.result and tools.escape_html(info.result.first_name) or tostring(row.user_id)
local reason_text = row.reason and (' - ' .. tools.escape_html(row.reason)) or ''
output = output .. string.format('- <a href="tg://user?id=%s">%s</a> [%s]%s\n', row.user_id, name, row.user_id, reason_text)
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
-- /block or /blocklist add
if message.command == 'block' or (message.command == 'blocklist' and message.args and message.args:match('^add')) then
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.command == 'blocklist' and message.args:gsub('^add%s*', '') or message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input:match('^(%S+)')
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to block.')
end
if tonumber(user_id) == nil then
user_id = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. user_id:lower())
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'I couldn\'t find that user.')
end
if user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'You can\'t blocklist an admin.')
end
ctx.db.call('sp_upsert_blocklist_entry', { message.chat.id, user_id, reason })
-- also set redis key for fast lookup
ctx.redis.set('group_blocklist:' .. message.chat.id .. ':' .. user_id, '1')
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been added to the blocklist.',
user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
-- /unblock or /blocklist remove
if message.command == 'unblock' or (message.command == 'blocklist' and message.args and message.args:match('^remove')) then
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
local input = message.command == 'blocklist' and message.args:gsub('^remove%s*', '') or message.args
user_id = input:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to unblock.')
end
ctx.db.call('sp_delete_blocklist_entry', { message.chat.id, user_id })
ctx.redis.del('group_blocklist:' .. message.chat.id .. ':' .. user_id)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been removed from the blocklist.',
user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
api.send_message(message.chat.id, 'Usage: /block <user> [reason] | /unblock <user> | /blocklist')
end
return plugin
diff --git a/src/plugins/admin/channel.lua b/src/plugins/admin/channel.lua
index 7b4735c..0d0fba3 100644
--- a/src/plugins/admin/channel.lua
+++ b/src/plugins/admin/channel.lua
@@ -1,60 +1,54 @@
--[[
mattata v2.0 - Channel Plugin
]]
local plugin = {}
plugin.name = 'channel'
plugin.category = 'admin'
plugin.description = 'Connect a channel to the group'
plugin.commands = { 'channel' }
plugin.help = '/channel <channel_id|@channel|off> - Connects a channel to this group.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if not message.args then
- local result = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'linked_channel'",
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'linked_channel' })
if result and #result > 0 and result[1].value then
local channel_info = api.get_chat(tonumber(result[1].value))
local channel_name = channel_info and channel_info.result and channel_info.result.title or result[1].value
return api.send_message(message.chat.id, string.format(
'This group is linked to channel: <b>%s</b> (<code>%s</code>)\nUse /channel off to disconnect.',
require('telegram-bot-lua.tools').escape_html(channel_name), result[1].value
- ), 'html')
+ ), { parse_mode = 'html' })
end
return api.send_message(message.chat.id, 'No channel is linked. Use /channel <channel_id|@channel> to link one.')
end
local arg = message.args:lower()
if arg == 'off' or arg == 'disable' or arg == 'none' then
- ctx.db.execute(
- "DELETE FROM chat_settings WHERE chat_id = $1 AND key = 'linked_channel'",
- { message.chat.id }
- )
+ ctx.db.call('sp_delete_chat_setting', { message.chat.id, 'linked_channel' })
return api.send_message(message.chat.id, 'Channel has been disconnected from this group.')
end
-- Resolve channel
local channel_id = message.args
if tonumber(channel_id) == nil then
-- Try to resolve by username
local chat_info = api.get_chat(channel_id)
if not chat_info or not chat_info.result then
return api.send_message(message.chat.id, 'I couldn\'t find that channel. Make sure I\'m an admin there.')
end
channel_id = tostring(chat_info.result.id)
end
ctx.db.upsert('chat_settings', {
chat_id = message.chat.id,
key = 'linked_channel',
value = channel_id
}, { 'chat_id', 'key' }, { 'value' })
- api.send_message(message.chat.id, string.format('Channel <code>%s</code> has been linked to this group.', channel_id), 'html')
+ api.send_message(message.chat.id, string.format('Channel <code>%s</code> has been linked to this group.', channel_id), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/customcaptcha.lua b/src/plugins/admin/customcaptcha.lua
new file mode 100644
index 0000000..63e02e5
--- /dev/null
+++ b/src/plugins/admin/customcaptcha.lua
@@ -0,0 +1,203 @@
+--[[
+ 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' })
+ 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/admin/demote.lua b/src/plugins/admin/demote.lua
index a3bc5a4..f0d3aed 100644
--- a/src/plugins/admin/demote.lua
+++ b/src/plugins/admin/demote.lua
@@ -1,51 +1,52 @@
--[[
mattata v2.0 - Demote Plugin
]]
local plugin = {}
plugin.name = 'demote'
plugin.category = 'admin'
plugin.description = 'Remove moderator status from a user'
plugin.commands = { 'demote' }
plugin.help = '/demote [user] - Removes moderator status from a user.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to demote, either by replying to their message or providing a username/ID.')
end
if not permissions.is_group_mod(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is not a moderator.')
end
ctx.db.call('sp_reset_member_role', { message.chat.id, user_id })
+ require('src.core.session').invalidate_admin_status(message.chat.id, user_id)
pcall(function()
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'demote', nil })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'demote', nil))
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has demoted <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/federation/delfed.lua b/src/plugins/admin/federation/delfed.lua
index 4b7bf46..8153ad4 100644
--- a/src/plugins/admin/federation/delfed.lua
+++ b/src/plugins/admin/federation/delfed.lua
@@ -1,124 +1,116 @@
--[[
mattata v2.0 - Federation: delfed
Deletes a federation. Only the federation owner can delete it.
Requires confirmation via inline callback.
]]
local tools = require('telegram-bot-lua.tools')
-local json = require('dkjson')
local plugin = {}
plugin.name = 'delfed'
plugin.category = 'admin'
plugin.description = 'Delete a federation you own.'
plugin.commands = { 'delfed' }
plugin.help = '/delfed <federation_id> - Delete a federation you own.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local fed_id = message.args
if not fed_id or fed_id == '' then
return api.send_message(
message.chat.id,
'Please specify the federation ID.\nUsage: <code>/delfed &lt;federation_id&gt;</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
fed_id = fed_id:match('^(%S+)')
local fed = ctx.db.call('sp_get_federation', { fed_id })
if not fed or #fed == 0 then
return api.send_message(
message.chat.id,
- 'Federation not found. Please check the ID and try again.',
- 'html'
+ 'Federation not found. Please check the ID and try again.'
)
end
fed = fed[1]
if fed.owner_id ~= message.from.id then
return api.send_message(
message.chat.id,
- 'Only the federation owner can delete it.',
- 'html'
+ 'Only the federation owner can delete it.'
)
end
- local callback_data_yes = json.encode({ plugin = 'delfed', action = 'confirm', fed_id = fed.id })
- local callback_data_no = json.encode({ plugin = 'delfed', action = 'cancel' })
-
local keyboard = {
inline_keyboard = { {
- { text = 'Yes, delete it', callback_data = callback_data_yes },
- { text = 'No, cancel', callback_data = callback_data_no }
+ { text = 'Yes, delete it', callback_data = 'delfed:confirm:' .. fed.id },
+ { text = 'No, cancel', callback_data = 'delfed:cancel' }
} }
}
return api.send_message(
message.chat.id,
string.format(
'Are you sure you want to delete the federation <b>%s</b>?\n\nThis will remove all bans, chats, and admins associated with it. This action cannot be undone.',
tools.escape_html(fed.name)
),
- 'html',
- nil, nil, nil, nil,
- json.encode(keyboard)
+ { parse_mode = 'html', reply_markup = keyboard }
)
end
function plugin.on_callback_query(api, callback_query, message, ctx)
- local data = json.decode(callback_query.data)
- if not data or data.plugin ~= 'delfed' then
- return
- end
+ local data = callback_query.data
+ if not data then return end
- if callback_query.from.id ~= message.reply_to_message_from_id and callback_query.from.id ~= (message.from and message.from.id) then
- return api.answer_callback_query(callback_query.id, 'This button is not for you.')
+ -- Verify the button was pressed by the original command user
+ if message.from and callback_query.from.id ~= message.from.id then
+ return api.answer_callback_query(callback_query.id, { text = 'This button is not for you.' })
end
- if data.action == 'cancel' then
- api.answer_callback_query(callback_query.id, 'Deletion cancelled.')
+ if data == 'cancel' then
+ api.answer_callback_query(callback_query.id, { text = 'Deletion cancelled.' })
return api.edit_message_text(
message.chat.id,
message.message_id,
'Federation deletion cancelled.',
- 'html'
+ { parse_mode = 'html' }
)
end
- if data.action == 'confirm' then
- local fed = ctx.db.call('sp_get_federation_owner', { data.fed_id })
+ local fed_id = data:match('^confirm:(.+)$')
+ if fed_id then
+ local fed = ctx.db.call('sp_get_federation_owner', { fed_id })
if not fed or #fed == 0 then
- api.answer_callback_query(callback_query.id, 'Federation no longer exists.')
+ api.answer_callback_query(callback_query.id, { text = 'Federation no longer exists.' })
return api.edit_message_text(
message.chat.id,
message.message_id,
'This federation no longer exists.',
- 'html'
+ { parse_mode = 'html' }
)
end
if fed[1].owner_id ~= callback_query.from.id then
- return api.answer_callback_query(callback_query.id, 'Only the federation owner can delete it.')
+ return api.answer_callback_query(callback_query.id, { text = 'Only the federation owner can delete it.' })
end
- ctx.db.call('sp_delete_federation', { data.fed_id })
+ ctx.db.call('sp_delete_federation', { fed_id })
- api.answer_callback_query(callback_query.id, 'Federation deleted.')
+ api.answer_callback_query(callback_query.id, { text = 'Federation deleted.' })
return api.edit_message_text(
message.chat.id,
message.message_id,
string.format(
'Federation <b>%s</b> has been deleted.',
tools.escape_html(fed[1].name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
end
return plugin
diff --git a/src/plugins/admin/federation/fadmins.lua b/src/plugins/admin/federation/fadmins.lua
index 126a494..2ac3387 100644
--- a/src/plugins/admin/federation/fadmins.lua
+++ b/src/plugins/admin/federation/fadmins.lua
@@ -1,55 +1,55 @@
--[[
mattata v2.0 - Federation: fadmins
Lists all admins of the federation the current chat belongs to.
Shows the owner separately from promoted admins.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fadmins'
plugin.category = 'admin'
plugin.description = 'List federation admins.'
plugin.commands = { 'fadmins' }
plugin.help = '/fadmins - List all admins of this federation.'
plugin.group_only = true
plugin.admin_only = false
local function get_chat_federation(db, chat_id)
local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
- 'html'
+ { parse_mode = 'html' }
)
end
local output = string.format(
'<b>Federation Admins</b>\nFederation: <b>%s</b>\n\n<b>Owner:</b>\n<code>%s</code>',
tools.escape_html(fed.name),
fed.owner_id
)
local admins = ctx.db.call('sp_get_federation_admins', { fed.id })
if admins and #admins > 0 then
output = output .. string.format('\n\n<b>Admins (%d):</b>', #admins)
for i, admin in ipairs(admins) do
output = output .. string.format('\n%d. <code>%s</code>', i, admin.user_id)
end
else
output = output .. '\n\nNo promoted admins.'
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/federation/fallowlist.lua b/src/plugins/admin/federation/fallowlist.lua
index a456051..f68ba42 100644
--- a/src/plugins/admin/federation/fallowlist.lua
+++ b/src/plugins/admin/federation/fallowlist.lua
@@ -1,106 +1,106 @@
--[[
mattata v2.0 - Federation: fallowlist
Manages the federation allowlist. Allowlisted users are exempt from
federation bans. Only the federation owner or admins can manage it.
Toggles the user on/off the allowlist.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fallowlist'
plugin.category = 'admin'
plugin.description = 'Toggle a user on the federation allowlist.'
plugin.commands = { 'fallowlist' }
plugin.help = '/fallowlist [user] - Toggle a user on/off the federation allowlist.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
local function is_fed_admin(db, fed_id, user_id)
local result = db.call('sp_check_federation_admin', { fed_id, user_id })
return result and #result > 0
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
- 'html'
+ { parse_mode = 'html' }
)
end
local from_id = message.from.id
if fed.owner_id ~= from_id and not is_fed_admin(ctx.db, fed.id, from_id) then
return api.send_message(
message.chat.id,
'Only the federation owner or a federation admin can manage the allowlist.',
- 'html'
+ { parse_mode = 'html' }
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to toggle on the allowlist by replying to their message or providing a user ID/username.\nUsage: <code>/fallowlist [user]</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
local existing = ctx.db.call('sp_check_federation_allowlist', { fed.id, target_id })
if existing and #existing > 0 then
ctx.db.call('sp_delete_federation_allowlist', { fed.id, target_id })
ctx.redis.del(string.format('fallowlist:%s:%s', fed.id, target_id))
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been removed from the federation allowlist.',
tools.escape_html(target_name)
),
- 'html'
+ { parse_mode = 'html' }
)
else
ctx.db.call('sp_insert_federation_allowlist', { fed.id, target_id })
ctx.redis.del(string.format('fallowlist:%s:%s', fed.id, target_id))
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been added to the federation allowlist.',
tools.escape_html(target_name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
end
return plugin
diff --git a/src/plugins/admin/federation/fban.lua b/src/plugins/admin/federation/fban.lua
index 19db91b..6f20627 100644
--- a/src/plugins/admin/federation/fban.lua
+++ b/src/plugins/admin/federation/fban.lua
@@ -1,157 +1,157 @@
--[[
mattata v2.0 - Federation: fban
Bans a user across all chats in the federation.
Only the federation owner or a federation admin can issue an fban.
Allowlisted users are exempt.
]]
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local plugin = {}
plugin.name = 'fban'
plugin.category = 'admin'
plugin.description = 'Ban a user across the federation.'
plugin.commands = { 'fban' }
plugin.help = '/fban [user] [reason] - Ban a user in all chats belonging to this federation.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
local function is_fed_admin(db, fed_id, user_id)
local result = db.call('sp_check_federation_admin', { fed_id, user_id })
return result and #result > 0
end
local function is_allowlisted(db, fed_id, user_id)
local result = db.call('sp_check_federation_allowlist', { fed_id, user_id })
return result and #result > 0
end
function plugin.on_message(api, message, ctx)
if message.chat.type ~= 'private' and not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to enforce federation bans.')
end
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
- 'html'
+ { parse_mode = 'html' }
)
end
local from_id = message.from.id
if fed.owner_id ~= from_id and not is_fed_admin(ctx.db, fed.id, from_id) then
return api.send_message(
message.chat.id,
'Only the federation owner or a federation admin can use this command.',
- 'html'
+ { parse_mode = 'html' }
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to ban by replying to their message or providing a user ID/username.\nUsage: <code>/fban [user] [reason]</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
-- don't allow banning the federation owner
if target_id == fed.owner_id then
return api.send_message(
message.chat.id,
'You cannot federation-ban the federation owner.',
- 'html'
+ { parse_mode = 'html' }
)
end
if is_allowlisted(ctx.db, fed.id, target_id) then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> is on the federation allowlist and cannot be banned.',
tools.escape_html(target_name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
local reason
if message.reply and message.reply.from and message.args and message.args ~= '' then
reason = message.args
elseif message.args and message.args ~= '' then
reason = message.args:match('^%S+%s+(.*)')
end
local existing_ban = ctx.db.call('sp_check_federation_ban_exists', { fed.id, target_id })
if existing_ban and #existing_ban > 0 then
if reason then
ctx.db.call('sp_update_federation_ban', { reason, from_id, fed.id, target_id })
end
else
ctx.db.call('sp_insert_federation_ban', { fed.id, target_id, reason, from_id })
end
ctx.redis.del(string.format('fban:%s:%s', fed.id, target_id))
local chats = ctx.db.call('sp_get_federation_chats', { fed.id })
local success_count = 0
local fail_count = 0
if chats then
for _, chat in ipairs(chats) do
local ok = api.ban_chat_member(chat.chat_id, target_id)
if ok then
success_count = success_count + 1
else
fail_count = fail_count + 1
end
end
end
local output = string.format(
'<b>Federation Ban</b>\nFederation: <b>%s</b>\nUser: <b>%s</b> (<code>%s</code>)\nBanned by: %s',
tools.escape_html(fed.name),
tools.escape_html(target_name),
target_id,
tools.escape_html(message.from.first_name)
)
if reason then
output = output .. string.format('\nReason: %s', tools.escape_html(reason))
end
output = output .. string.format('\nBanned in %d/%d chats.', success_count, success_count + fail_count)
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/federation/fbaninfo.lua b/src/plugins/admin/federation/fbaninfo.lua
index acde86b..776ac55 100644
--- a/src/plugins/admin/federation/fbaninfo.lua
+++ b/src/plugins/admin/federation/fbaninfo.lua
@@ -1,92 +1,92 @@
--[[
mattata v2.0 - Federation: fbaninfo
Checks if a user is banned in any federation the current chat belongs to.
Shows the ban reason, who banned them, and when.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fbaninfo'
plugin.category = 'admin'
plugin.description = 'Check federation ban info for a user.'
plugin.commands = { 'fbaninfo' }
plugin.help = '/fbaninfo [user] - Check if a user is banned in this federation.'
plugin.group_only = false
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
function plugin.on_message(api, message, ctx)
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
target_id = message.from.id
target_name = message.from.first_name
end
local bans
if ctx.is_group then
bans = ctx.db.call('sp_get_fban_info_group', { target_id, message.chat.id })
else
bans = ctx.db.call('sp_get_fban_info_all', { target_id })
end
if not bans or #bans == 0 then
local scope = ctx.is_group and 'this federation' or 'any federation'
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> (<code>%s</code>) is not banned in %s.',
tools.escape_html(target_name),
target_id,
scope
),
- 'html'
+ { parse_mode = 'html' }
)
end
local output = string.format(
'<b>Federation Ban Info</b>\nUser: <b>%s</b> (<code>%s</code>)\n',
tools.escape_html(target_name),
target_id
)
for i, ban in ipairs(bans) do
output = output .. string.format(
'\n<b>%d.</b> Federation: <b>%s</b>\n ID: <code>%s</code>',
i,
tools.escape_html(ban.name),
tools.escape_html(ban.id)
)
if ban.reason then
output = output .. string.format('\n Reason: %s', tools.escape_html(ban.reason))
end
if ban.banned_by then
output = output .. string.format('\n Banned by: <code>%s</code>', ban.banned_by)
end
if ban.banned_at then
output = output .. string.format('\n Date: %s', tools.escape_html(tostring(ban.banned_at)))
end
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/federation/fdemote.lua b/src/plugins/admin/federation/fdemote.lua
index b026455..28c7277 100644
--- a/src/plugins/admin/federation/fdemote.lua
+++ b/src/plugins/admin/federation/fdemote.lua
@@ -1,94 +1,94 @@
--[[
mattata v2.0 - Federation: fdemote
Demotes a federation admin. Only the federation owner can demote.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fdemote'
plugin.category = 'admin'
plugin.description = 'Demote a federation admin.'
plugin.commands = { 'fdemote' }
plugin.help = '/fdemote [user] - Demote a federation admin.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
- 'html'
+ { parse_mode = 'html' }
)
end
if fed.owner_id ~= message.from.id then
return api.send_message(
message.chat.id,
'Only the federation owner can demote admins.',
- 'html'
+ { parse_mode = 'html' }
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to demote by replying to their message or providing a user ID/username.\nUsage: <code>/fdemote [user]</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
local existing = ctx.db.call('sp_check_federation_admin', { fed.id, target_id })
if not existing or #existing == 0 then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> is not a federation admin.',
tools.escape_html(target_name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
ctx.db.call('sp_delete_federation_admin', { fed.id, target_id })
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been demoted from federation admin in <b>%s</b>.',
tools.escape_html(target_name),
tools.escape_html(fed.name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/admin/federation/feds.lua b/src/plugins/admin/federation/feds.lua
index 3db3b63..9f54580 100644
--- a/src/plugins/admin/federation/feds.lua
+++ b/src/plugins/admin/federation/feds.lua
@@ -1,83 +1,83 @@
--[[
mattata v2.0 - Federation: feds / fedinfo
Shows info about a specific federation by ID, or the federation
the current chat belongs to.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'feds'
plugin.category = 'admin'
plugin.description = 'Show federation info.'
plugin.commands = { 'feds', 'fedinfo' }
plugin.help = '/feds [federation_id] - Show info about a federation.\n/fedinfo [federation_id] - Alias for /feds.'
plugin.group_only = false
plugin.admin_only = false
local function get_chat_federation(db, chat_id)
local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed_id = message.args and message.args:match('^(%S+)')
local fed
if fed_id and fed_id ~= '' then
local result = ctx.db.call('sp_get_federation', { fed_id })
if not result or #result == 0 then
return api.send_message(
message.chat.id,
'Federation not found. Please check the ID and try again.',
- 'html'
+ { parse_mode = 'html' }
)
end
fed = result[1]
elseif ctx.is_group then
fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation. Provide a federation ID to look up.\nUsage: <code>/feds &lt;federation_id&gt;</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
local full = ctx.db.call('sp_get_federation', { fed.id })
if full and #full > 0 then
fed.created_at = full[1].created_at
end
else
return api.send_message(
message.chat.id,
'Please specify a federation ID.\nUsage: <code>/feds &lt;federation_id&gt;</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
local counts = ctx.db.call('sp_get_federation_counts', { fed.id })
local counts_row = (counts and counts[1]) or {}
local admins = tonumber(counts_row.admin_count) or 0
local chats = tonumber(counts_row.chat_count) or 0
local bans = tonumber(counts_row.ban_count) or 0
local output = string.format(
'<b>Federation Info</b>\n\nName: <b>%s</b>\nID: <code>%s</code>\nOwner: <code>%s</code>\nAdmins: %d\nChats: %d\nBans: %d',
tools.escape_html(fed.name),
tools.escape_html(fed.id),
fed.owner_id,
admins,
chats,
bans
)
if fed.created_at then
output = output .. string.format('\nCreated: %s', tools.escape_html(tostring(fed.created_at)))
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/federation/fpromote.lua b/src/plugins/admin/federation/fpromote.lua
index e6651de..9079925 100644
--- a/src/plugins/admin/federation/fpromote.lua
+++ b/src/plugins/admin/federation/fpromote.lua
@@ -1,102 +1,102 @@
--[[
mattata v2.0 - Federation: fpromote
Promotes a user to federation admin. Only the federation owner can promote.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fpromote'
plugin.category = 'admin'
plugin.description = 'Promote a user to federation admin.'
plugin.commands = { 'fpromote' }
plugin.help = '/fpromote [user] - Promote a user to federation admin.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
- 'html'
+ { parse_mode = 'html' }
)
end
if fed.owner_id ~= message.from.id then
return api.send_message(
message.chat.id,
'Only the federation owner can promote admins.',
- 'html'
+ { parse_mode = 'html' }
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to promote by replying to their message or providing a user ID/username.\nUsage: <code>/fpromote [user]</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
if target_id == fed.owner_id then
return api.send_message(
message.chat.id,
'The federation owner cannot be promoted as an admin.',
- 'html'
+ { parse_mode = 'html' }
)
end
local existing = ctx.db.call('sp_check_federation_admin', { fed.id, target_id })
if existing and #existing > 0 then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> is already a federation admin.',
tools.escape_html(target_name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
ctx.db.call('sp_insert_federation_admin', { fed.id, target_id, message.from.id })
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been promoted to federation admin in <b>%s</b>.',
tools.escape_html(target_name),
tools.escape_html(fed.name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/admin/federation/joinfed.lua b/src/plugins/admin/federation/joinfed.lua
index 25245ec..7cec44c 100644
--- a/src/plugins/admin/federation/joinfed.lua
+++ b/src/plugins/admin/federation/joinfed.lua
@@ -1,73 +1,73 @@
--[[
mattata v2.0 - Federation: joinfed
Joins the current chat to a federation. Requires group admin.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'joinfed'
plugin.category = 'admin'
plugin.description = 'Join this chat to a federation.'
plugin.commands = { 'joinfed' }
plugin.help = '/joinfed <federation_id> - Join this chat to the specified federation.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local fed_id = message.args
if not fed_id or fed_id == '' then
return api.send_message(
message.chat.id,
'Please specify the federation ID.\nUsage: <code>/joinfed &lt;federation_id&gt;</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
fed_id = fed_id:match('^(%S+)')
local chat_id = message.chat.id
local existing = ctx.db.call('sp_get_chat_federation_joined', { chat_id })
if existing and #existing > 0 then
return api.send_message(
chat_id,
string.format(
'This chat is already part of the federation <b>%s</b>.\nUse /leavefed to leave it first.',
tools.escape_html(existing[1].name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
local fed = ctx.db.call('sp_get_federation_basic', { fed_id })
if not fed or #fed == 0 then
return api.send_message(
chat_id,
'Federation not found. Please check the ID and try again.',
- 'html'
+ { parse_mode = 'html' }
)
end
fed = fed[1]
local result = ctx.db.call('sp_join_federation', { fed.id, chat_id, message.from.id })
if not result then
return api.send_message(
chat_id,
'Failed to join the federation. Please try again later.',
- 'html'
+ { parse_mode = 'html' }
)
end
return api.send_message(
chat_id,
string.format(
'This chat has joined the federation <b>%s</b>.',
tools.escape_html(fed.name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/admin/federation/leavefed.lua b/src/plugins/admin/federation/leavefed.lua
index 82882c2..e84bb24 100644
--- a/src/plugins/admin/federation/leavefed.lua
+++ b/src/plugins/admin/federation/leavefed.lua
@@ -1,44 +1,44 @@
--[[
mattata v2.0 - Federation: leavefed
Removes the current chat from its federation. Requires group admin.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'leavefed'
plugin.category = 'admin'
plugin.description = 'Remove this chat from its federation.'
plugin.commands = { 'leavefed' }
plugin.help = '/leavefed - Remove this chat from its current federation.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local chat_id = message.chat.id
local existing = ctx.db.call('sp_get_chat_federation_joined', { chat_id })
if not existing or #existing == 0 then
return api.send_message(
chat_id,
'This chat is not part of any federation.',
- 'html'
+ { parse_mode = 'html' }
)
end
local fed = existing[1]
ctx.db.call('sp_leave_federation', { fed.id, chat_id })
return api.send_message(
chat_id,
string.format(
'This chat has left the federation <b>%s</b>.',
tools.escape_html(fed.name)
),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/admin/federation/myfeds.lua b/src/plugins/admin/federation/myfeds.lua
index 7fb5c2d..c0ac2aa 100644
--- a/src/plugins/admin/federation/myfeds.lua
+++ b/src/plugins/admin/federation/myfeds.lua
@@ -1,70 +1,70 @@
--[[
mattata v2.0 - Federation: myfeds
Lists all federations the user owns or is admin of.
Shows federation name, ID, chat count, and ban count.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'myfeds'
plugin.category = 'admin'
plugin.description = 'List your federations.'
plugin.commands = { 'myfeds' }
plugin.help = '/myfeds - List all federations you own or are an admin of.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local user_id = message.from.id
local owned = ctx.db.call('sp_get_owned_federations', { user_id })
local admin_of = ctx.db.call('sp_get_admin_federations', { user_id })
local has_owned = owned and #owned > 0
local has_admin = admin_of and #admin_of > 0
if not has_owned and not has_admin then
return api.send_message(
message.chat.id,
'You do not own or administrate any federations.',
- 'html'
+ { parse_mode = 'html' }
)
end
local output = '<b>Your Federations</b>\n'
if has_owned then
output = output .. string.format('\n<b>Owned (%d):</b>', #owned)
for i, fed in ipairs(owned) do
output = output .. string.format(
'\n%d. <b>%s</b>\n ID: <code>%s</code>\n Chats: %d | Bans: %d',
i,
tools.escape_html(fed.name),
tools.escape_html(fed.id),
tonumber(fed.chat_count) or 0,
tonumber(fed.ban_count) or 0
)
end
end
if has_admin then
output = output .. string.format('\n\n<b>Admin of (%d):</b>', #admin_of)
for i, fed in ipairs(admin_of) do
output = output .. string.format(
'\n%d. <b>%s</b>\n ID: <code>%s</code>\n Chats: %d | Bans: %d',
i,
tools.escape_html(fed.name),
tools.escape_html(fed.id),
tonumber(fed.chat_count) or 0,
tonumber(fed.ban_count) or 0
)
end
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/federation/newfed.lua b/src/plugins/admin/federation/newfed.lua
index 09d53e1..6db82fc 100644
--- a/src/plugins/admin/federation/newfed.lua
+++ b/src/plugins/admin/federation/newfed.lua
@@ -1,66 +1,66 @@
--[[
mattata v2.0 - Federation: newfed
Creates a new federation. Any user can create up to 5 federations.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'newfed'
plugin.category = 'admin'
plugin.description = 'Create a new federation.'
plugin.commands = { 'newfed' }
plugin.help = '/newfed <name> - Create a new federation with the given name.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local name = message.args
if not name or name == '' then
return api.send_message(
message.chat.id,
'Please specify a name for the federation.\nUsage: <code>/newfed &lt;name&gt;</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
if #name > 128 then
return api.send_message(
message.chat.id,
'Federation name must be 128 characters or fewer.',
- 'html'
+ { parse_mode = 'html' }
)
end
local user_id = message.from.id
local existing = ctx.db.call('sp_count_user_federations', { user_id })
if existing and existing[1] and tonumber(existing[1].count) >= 5 then
return api.send_message(
message.chat.id,
'You already own 5 federations, which is the maximum allowed.',
- 'html'
+ { parse_mode = 'html' }
)
end
local result = ctx.db.call('sp_create_federation', { name, user_id })
if not result or #result == 0 then
return api.send_message(
message.chat.id,
'Failed to create the federation. Please try again later.',
- 'html'
+ { parse_mode = 'html' }
)
end
local fed_id = result[1].id
local output = string.format(
'Federation <b>%s</b> created successfully!\n\nFederation ID: <code>%s</code>\n\nUse <code>/joinfed %s</code> in a group to add it to this federation.',
tools.escape_html(name),
tools.escape_html(fed_id),
tools.escape_html(fed_id)
)
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/federation/unfban.lua b/src/plugins/admin/federation/unfban.lua
index 3b6427b..b2ec110 100644
--- a/src/plugins/admin/federation/unfban.lua
+++ b/src/plugins/admin/federation/unfban.lua
@@ -1,126 +1,126 @@
--[[
mattata v2.0 - Federation: unfban
Unbans a user from the federation and all its chats.
Only the federation owner or a federation admin can unfban.
]]
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local plugin = {}
plugin.name = 'unfban'
plugin.category = 'admin'
plugin.description = 'Unban a user from the federation.'
plugin.commands = { 'unfban' }
plugin.help = '/unfban [user] - Unban a user from all chats in this federation.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
local function is_fed_admin(db, fed_id, user_id)
local result = db.call('sp_check_federation_admin', { fed_id, user_id })
return result and #result > 0
end
function plugin.on_message(api, message, ctx)
if message.chat.type ~= 'private' and not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to enforce federation unbans.')
end
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
- 'html'
+ { parse_mode = 'html' }
)
end
local from_id = message.from.id
if fed.owner_id ~= from_id and not is_fed_admin(ctx.db, fed.id, from_id) then
return api.send_message(
message.chat.id,
'Only the federation owner or a federation admin can use this command.',
- 'html'
+ { parse_mode = 'html' }
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to unban by replying to their message or providing a user ID/username.\nUsage: <code>/unfban [user]</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
local ban = ctx.db.call('sp_check_federation_ban_exists', { fed.id, target_id })
if not ban or #ban == 0 then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> (<code>%s</code>) is not banned in this federation.',
tools.escape_html(target_name),
target_id
),
- 'html'
+ { parse_mode = 'html' }
)
end
ctx.db.call('sp_delete_federation_ban', { fed.id, target_id })
ctx.redis.del(string.format('fban:%s:%s', fed.id, target_id))
local chats = ctx.db.call('sp_get_federation_chats', { fed.id })
local success_count = 0
local fail_count = 0
if chats then
for _, chat in ipairs(chats) do
local ok = api.unban_chat_member(chat.chat_id, target_id)
if ok then
success_count = success_count + 1
else
fail_count = fail_count + 1
end
end
end
local output = string.format(
'<b>Federation Unban</b>\nFederation: <b>%s</b>\nUser: <b>%s</b> (<code>%s</code>)\nUnbanned by: %s\nUnbanned in %d/%d chats.',
tools.escape_html(fed.name),
tools.escape_html(target_name),
target_id,
tools.escape_html(message.from.first_name),
success_count,
success_count + fail_count
)
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/filter.lua b/src/plugins/admin/filter.lua
index 25b74a9..d32a0ff 100644
--- a/src/plugins/admin/filter.lua
+++ b/src/plugins/admin/filter.lua
@@ -1,63 +1,90 @@
--[[
mattata v2.0 - Filter Plugin
]]
local plugin = {}
plugin.name = 'filter'
plugin.category = 'admin'
plugin.description = 'Add content filters to the group'
plugin.commands = { 'filter', 'addfilter' }
plugin.help = '/filter <pattern> [action] - Adds a filter. Actions: delete (default), warn, ban, kick.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
- return api.send_message(message.chat.id, 'Usage: /filter <pattern> [action]\nActions: delete (default), warn, ban, kick, mute', 'html')
+ return api.send_message(message.chat.id, 'Usage: /filter <pattern> [action]\nActions: delete (default), warn, ban, kick, mute', { parse_mode = 'html' })
end
+ local VALID_ACTIONS = { delete = true, warn = true, ban = true, kick = true, mute = true }
+
local pattern, action
- if message.args:match('^(.+)%s+(delete|warn|ban|kick|mute)$') then
- pattern, action = message.args:match('^(.+)%s+(delete|warn|ban|kick|mute)$')
+ local last_word = message.args:match('(%S+)$')
+ if last_word and VALID_ACTIONS[last_word:lower()] and message.args:match('^(.+)%s+%S+$') then
+ pattern, action = message.args:match('^(.+)%s+(%S+)$')
+ action = action:lower()
else
pattern = message.args
action = 'delete'
end
pattern = pattern:match('^%s*(.-)%s*$') -- trim
if pattern == '' then
return api.send_message(message.chat.id, 'Please provide a pattern to filter.')
end
- -- validate regex pattern
+ -- validate pattern syntax
local ok = pcall(string.match, '', pattern)
if not ok then
return api.send_message(message.chat.id, 'Invalid pattern. Please provide a valid Lua pattern.')
end
+ -- reject patterns that could cause catastrophic backtracking
+ if #pattern > 128 then
+ return api.send_message(message.chat.id, 'Pattern too long (max 128 characters).')
+ end
+ local wq_count = 0
+ do
+ local i = 1
+ while i <= #pattern do
+ if pattern:sub(i, i) == '%' then
+ i = i + 2
+ elseif pattern:sub(i, i) == '.' and i < #pattern then
+ local nc = pattern:sub(i + 1, i + 1)
+ if nc == '+' or nc == '*' or nc == '-' then wq_count = wq_count + 1 end
+ i = i + 1
+ else
+ i = i + 1
+ end
+ end
+ end
+ if wq_count > 3 then
+ return api.send_message(message.chat.id, 'Pattern too complex (too many wildcard repetitions).')
+ end
+
-- check for duplicate
local existing = ctx.db.call('sp_get_filter', { message.chat.id, pattern })
if existing and #existing > 0 then
-- update the action if filter already exists
ctx.db.call('sp_update_filter_action', { action, message.chat.id, pattern })
require('src.core.session').invalidate_cached_list(message.chat.id, 'filters')
return api.send_message(message.chat.id, string.format(
'Filter <code>%s</code> updated with action: <b>%s</b>.',
tools.escape_html(pattern), action
- ), 'html')
+ ), { parse_mode = 'html' })
end
ctx.db.call('sp_insert_filter', { message.chat.id, pattern, action, message.from.id })
-- invalidate filter cache
require('src.core.session').invalidate_cached_list(message.chat.id, 'filters')
api.send_message(message.chat.id, string.format(
'Filter added: <code>%s</code> (action: <b>%s</b>)',
tools.escape_html(pattern), action
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/groups.lua b/src/plugins/admin/groups.lua
index c1450fc..5924dbe 100644
--- a/src/plugins/admin/groups.lua
+++ b/src/plugins/admin/groups.lua
@@ -1,54 +1,54 @@
--[[
mattata v2.0 - Groups Plugin
]]
local plugin = {}
plugin.name = 'groups'
plugin.category = 'admin'
plugin.description = 'List known groups the bot is in'
plugin.commands = { 'groups' }
plugin.help = '/groups [search] - Lists groups the bot is aware of.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local search = message.args and message.args:lower() or nil
local result
if search then
result = ctx.db.call('sp_search_groups', { '%' .. search .. '%' })
else
result = ctx.db.call('sp_list_groups', {})
end
if not result or #result == 0 then
if search then
return api.send_message(message.chat.id, 'No groups found matching that search.')
end
return api.send_message(message.chat.id, 'No groups found in the database.')
end
local output = '<b>Known groups'
if search then
output = output .. ' matching "' .. tools.escape_html(search) .. '"'
end
output = output .. ':</b>\n\n'
for i, row in ipairs(result) do
local title = tools.escape_html(row.title or 'Unknown')
if row.username then
output = output .. string.format('%d. <a href="https://t.me/%s">%s</a>\n', i, row.username, title)
else
output = output .. string.format('%d. %s (<code>%s</code>)\n', i, title, row.chat_id)
end
end
if #result == 50 then
output = output .. '\n<i>Showing first 50 results. Use /groups <search> to narrow down.</i>'
end
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/import.lua b/src/plugins/admin/import.lua
index 977c611..6800ecd 100644
--- a/src/plugins/admin/import.lua
+++ b/src/plugins/admin/import.lua
@@ -1,154 +1,133 @@
--[[
mattata v2.0 - Import Plugin
]]
local plugin = {}
plugin.name = 'import'
plugin.category = 'admin'
plugin.description = 'Import settings from another chat'
plugin.commands = { 'import' }
plugin.help = '/import <chat_id> - Imports settings, filters, triggers, and rules from another chat.'
plugin.group_only = true
plugin.admin_only = true
plugin.global_admin_only = true
function plugin.on_message(api, message, ctx)
if not message.args then
return api.send_message(message.chat.id, 'Usage: /import <chat_id>\n\nImports settings, filters, triggers, rules, and welcome messages from another chat.')
end
local source_id = tonumber(message.args)
if not source_id then
return api.send_message(message.chat.id, 'Please provide a valid chat ID.')
end
if source_id == message.chat.id then
return api.send_message(message.chat.id, 'You can\'t import from the same chat.')
end
+ -- Verify the calling user is a member of the source chat
+ local member = api.get_chat_member(source_id, message.from.id)
+ if not member or not member.result or member.result.status == 'left' or member.result.status == 'kicked' then
+ return api.send_message(message.chat.id, 'You must be a member of the source chat to import from it.')
+ end
+
local imported = {}
-- Import chat_settings
- local settings = ctx.db.execute(
- 'SELECT key, value FROM chat_settings WHERE chat_id = $1',
- { source_id }
- )
+ local settings = ctx.db.call('sp_get_all_chat_settings', { source_id })
if settings and #settings > 0 then
for _, s in ipairs(settings) do
ctx.db.upsert('chat_settings', {
chat_id = message.chat.id,
key = s.key,
value = s.value
}, { 'chat_id', 'key' }, { 'value' })
end
table.insert(imported, #settings .. ' settings')
end
-- Import filters
- local filters = ctx.db.execute(
- 'SELECT pattern, action, response FROM filters WHERE chat_id = $1',
- { source_id }
- )
+ local filters = ctx.db.call('sp_get_filters_full', { source_id })
if filters and #filters > 0 then
for _, f in ipairs(filters) do
- local existing = ctx.db.execute(
- 'SELECT 1 FROM filters WHERE chat_id = $1 AND pattern = $2',
- { message.chat.id, f.pattern }
- )
+ local existing = ctx.db.call('sp_get_filter', { message.chat.id, f.pattern })
if not existing or #existing == 0 then
ctx.db.insert('filters', {
chat_id = message.chat.id,
pattern = f.pattern,
action = f.action,
response = f.response,
created_by = message.from.id
})
end
end
table.insert(imported, #filters .. ' filters')
end
-- Import triggers
- local triggers = ctx.db.execute(
- 'SELECT pattern, response, is_media, file_id FROM triggers WHERE chat_id = $1',
- { source_id }
- )
+ local triggers = ctx.db.call('sp_get_triggers', { source_id })
if triggers and #triggers > 0 then
for _, t in ipairs(triggers) do
- local existing = ctx.db.execute(
- 'SELECT 1 FROM triggers WHERE chat_id = $1 AND pattern = $2',
- { message.chat.id, t.pattern }
- )
+ local existing = ctx.db.call('sp_check_trigger_exists', { message.chat.id, t.pattern })
if not existing or #existing == 0 then
ctx.db.insert('triggers', {
chat_id = message.chat.id,
pattern = t.pattern,
response = t.response,
is_media = t.is_media,
file_id = t.file_id,
created_by = message.from.id
})
end
end
table.insert(imported, #triggers .. ' triggers')
end
-- Import rules
- local rules = ctx.db.execute(
- 'SELECT rules_text FROM rules WHERE chat_id = $1',
- { source_id }
- )
+ local rules = ctx.db.call('sp_get_rules', { source_id })
if rules and #rules > 0 then
ctx.db.upsert('rules', {
chat_id = message.chat.id,
rules_text = rules[1].rules_text
}, { 'chat_id' }, { 'rules_text' })
table.insert(imported, 'rules')
end
-- Import welcome message
- local welcome = ctx.db.execute(
- 'SELECT message, parse_mode FROM welcome_messages WHERE chat_id = $1',
- { source_id }
- )
+ local welcome = ctx.db.call('sp_get_welcome_message_full', { source_id })
if welcome and #welcome > 0 then
ctx.db.upsert('welcome_messages', {
chat_id = message.chat.id,
message = welcome[1].message,
parse_mode = welcome[1].parse_mode
}, { 'chat_id' }, { 'message', 'parse_mode' })
table.insert(imported, 'welcome message')
end
-- Import allowed links
- local links = ctx.db.execute(
- 'SELECT link FROM allowed_links WHERE chat_id = $1',
- { source_id }
- )
+ local links = ctx.db.call('sp_get_allowed_links', { source_id })
if links and #links > 0 then
for _, l in ipairs(links) do
- local existing = ctx.db.execute(
- 'SELECT 1 FROM allowed_links WHERE chat_id = $1 AND link = $2',
- { message.chat.id, l.link }
- )
+ local existing = ctx.db.call('sp_check_allowed_link', { message.chat.id, l.link })
if not existing or #existing == 0 then
ctx.db.insert('allowed_links', {
chat_id = message.chat.id,
link = l.link
})
end
end
table.insert(imported, #links .. ' allowed links')
end
if #imported == 0 then
return api.send_message(message.chat.id, 'No settings found to import from that chat.')
end
api.send_message(message.chat.id, string.format(
'Successfully imported from <code>%d</code>:\n- %s',
source_id, table.concat(imported, '\n- ')
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/init.lua b/src/plugins/admin/init.lua
index 74c699e..59bd731 100644
--- a/src/plugins/admin/init.lua
+++ b/src/plugins/admin/init.lua
@@ -1,63 +1,67 @@
--[[
mattata v2.0 - Admin Plugin Category
]]
return {
plugins = {
'administration',
'ban',
'unban',
'kick',
'mute',
'unmute',
'warn',
'tempban',
'tempmute',
'promote',
'demote',
'trust',
'untrust',
'report',
'staff',
'purge',
'rules',
'setwelcome',
'setcaptcha',
'antispam',
'filter',
'unfilter',
'wordfilter',
'antilink',
'logchat',
'setgrouplang',
'link',
'addalias',
'triggers',
'addtrigger',
'nodelete',
'channel',
'save',
'import',
'allowlist',
'blocklist',
'groups',
'join_captcha',
'pin',
'allowedlinks',
'allowlink',
+ 'slowmode',
+ 'autodelete',
+ 'topic',
+ 'customcaptcha',
-- Federation sub-package
'federation.newfed',
'federation.delfed',
'federation.joinfed',
'federation.leavefed',
'federation.fban',
'federation.unfban',
'federation.fbaninfo',
'federation.fpromote',
'federation.fdemote',
'federation.fadmins',
'federation.fallowlist',
'federation.myfeds',
'federation.feds'
}
}
diff --git a/src/plugins/admin/join_captcha.lua b/src/plugins/admin/join_captcha.lua
index 8344f7e..dc43704 100644
--- a/src/plugins/admin/join_captcha.lua
+++ b/src/plugins/admin/join_captcha.lua
@@ -1,178 +1,190 @@
--[[
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())
+ -- Seed from /dev/urandom for unpredictable captchas (os.time() has 1s granularity)
+ local f = io.open('/dev/urandom', 'rb')
+ if f then
+ local b = f:read(4)
+ f:close()
+ if b then
+ local seed = 0
+ for i = 1, #b do seed = seed * 256 + b:byte(i) end
+ math.randomseed(seed)
+ end
+ else
+ math.randomseed(os.time() + os.clock() * 1000)
+ end
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 }
- )
+ local enabled = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'captcha_enabled' })
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_result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'captcha_timeout' })
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, {
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_add_web_page_previews = false,
+ can_invite_users = false,
+ can_change_info = false,
+ can_pin_messages = false,
+ can_manage_topics = 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))
+ local sent = api.send_message(message.chat.id, text, { parse_mode = 'html', reply_markup = 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.')
+ return api.answer_callback_query(callback_query.id, { text = '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.')
+ return api.answer_callback_query(callback_query.id, { text = 'This captcha has expired.' })
end
if selected == captcha.text then
-- Correct answer - unrestrict user
api.restrict_chat_member(chat_id, user_id, {
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_add_web_page_previews = true,
+ can_invite_users = true,
+ can_change_info = false,
+ can_pin_messages = false,
+ can_manage_topics = false
})
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.')
+ ), { parse_mode = 'html' })
+ api.answer_callback_query(callback_query.id, { text = 'Correct! Welcome to the group.' })
else
-- Wrong answer
- api.answer_callback_query(callback_query.id, 'Wrong answer. Try again!')
+ api.answer_callback_query(callback_query.id, { text = 'Wrong answer. Try again!' })
end
end
return plugin
diff --git a/src/plugins/admin/kick.lua b/src/plugins/admin/kick.lua
index 132ddea..227d526 100644
--- a/src/plugins/admin/kick.lua
+++ b/src/plugins/admin/kick.lua
@@ -1,72 +1,72 @@
--[[
mattata v2.0 - Kick Plugin
]]
local plugin = {}
plugin.name = 'kick'
plugin.category = 'admin'
plugin.description = 'Kick users from a group'
plugin.commands = { 'kick' }
plugin.help = '/kick [user] [reason] - Kicks a user from the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to kick.')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t kick an admin or moderator.')
end
-- kick = ban + immediate unban
local success = api.ban_chat_member(message.chat.id, user_id)
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to kick users.')
end
api.unban_chat_member(message.chat.id, user_id)
pcall(function()
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'kick', reason })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'kick', reason))
end)
if reason and reason:lower():match('^for ') then reason = reason:sub(5) end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and ('\nReason: ' .. tools.escape_html(reason)) or ''
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has kicked <a href="tg://user?id=%d">%s</a>.%s',
message.from.id, admin_name, user_id, target_name, reason_text
- ), 'html')
+ ), { parse_mode = 'html' })
if message.reply then
pcall(function() api.delete_message(message.chat.id, message.reply.message_id) end)
end
pcall(function() api.delete_message(message.chat.id, message.message_id) end)
end
return plugin
diff --git a/src/plugins/admin/link.lua b/src/plugins/admin/link.lua
index d657ed0..d026ca0 100644
--- a/src/plugins/admin/link.lua
+++ b/src/plugins/admin/link.lua
@@ -1,68 +1,65 @@
--[[
mattata v2.0 - Link Plugin
]]
local plugin = {}
plugin.name = 'link'
plugin.category = 'admin'
plugin.description = 'Get or set the group invite link'
plugin.commands = { 'link' }
plugin.help = '/link - Gets the group invite link. Admins can use /link set to generate a new one.'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local permissions = require('src.core.permissions')
if message.args and message.args:lower() == 'set' then
-- Only admins can set the link
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, 'Only admins can generate a new invite link.')
end
local result = api.export_chat_invite_link(message.chat.id)
if not result or not result.result then
return api.send_message(message.chat.id, 'I couldn\'t generate an invite link. Make sure I have the right permissions.')
end
ctx.db.upsert('chat_settings', {
chat_id = message.chat.id,
key = 'invite_link',
value = result.result
}, { 'chat_id', 'key' }, { 'value' })
return api.send_message(message.chat.id, 'Invite link updated: ' .. result.result)
end
-- Try to get stored link first
- local stored = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'invite_link'",
- { message.chat.id }
- )
+ local stored = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'invite_link' })
if stored and #stored > 0 and stored[1].value then
return api.send_message(message.chat.id, stored[1].value)
end
-- Try to get chat info which may contain invite link
local chat = api.get_chat(message.chat.id)
if chat and chat.result and chat.result.invite_link then
return api.send_message(message.chat.id, chat.result.invite_link)
end
-- Try to export one if we're admin
if permissions.is_group_admin(api, message.chat.id, api.info.id) then
local result = api.export_chat_invite_link(message.chat.id)
if result and result.result then
ctx.db.upsert('chat_settings', {
chat_id = message.chat.id,
key = 'invite_link',
value = result.result
}, { 'chat_id', 'key' }, { 'value' })
return api.send_message(message.chat.id, result.result)
end
end
api.send_message(message.chat.id, 'No invite link is available. An admin can use /link set to generate one.')
end
return plugin
diff --git a/src/plugins/admin/logchat.lua b/src/plugins/admin/logchat.lua
index 1a22955..1bf254d 100644
--- a/src/plugins/admin/logchat.lua
+++ b/src/plugins/admin/logchat.lua
@@ -1,48 +1,48 @@
--[[
mattata v2.0 - Log Chat Plugin
]]
local plugin = {}
plugin.name = 'logchat'
plugin.category = 'admin'
plugin.description = 'Set a log chat for admin actions'
plugin.commands = { 'logchat' }
plugin.help = '/logchat <chat_id|off> - Sets the log chat for admin actions.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if not message.args then
local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'log_chat' })
if result and #result > 0 and result[1].value then
return api.send_message(message.chat.id, string.format(
'Admin actions are being logged to <code>%s</code>.\nUse /logchat off to disable.',
result[1].value
- ), 'html')
+ ), { parse_mode = 'html' })
end
return api.send_message(message.chat.id, 'No log chat is set. Use /logchat <chat_id> to set one.')
end
local arg = message.args:lower()
if arg == 'off' or arg == 'disable' or arg == 'none' then
ctx.db.call('sp_delete_chat_setting', { message.chat.id, 'log_chat' })
return api.send_message(message.chat.id, 'Log chat has been disabled.')
end
local log_chat_id = tonumber(message.args)
if not log_chat_id then
return api.send_message(message.chat.id, 'Please provide a valid chat ID or "off" to disable.')
end
-- verify bot can send to the log chat
- local test = api.send_message(log_chat_id, 'This chat has been set as the log chat for admin actions.', nil, nil, nil, nil, nil)
+ local test = api.send_message(log_chat_id, 'This chat has been set as the log chat for admin actions.')
if not test then
return api.send_message(message.chat.id, 'I can\'t send messages to that chat. Make sure I\'m a member there.')
end
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'log_chat', tostring(log_chat_id) })
- api.send_message(message.chat.id, string.format('Log chat set to <code>%d</code>.', log_chat_id), 'html')
+ api.send_message(message.chat.id, string.format('Log chat set to <code>%d</code>.', log_chat_id), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/mute.lua b/src/plugins/admin/mute.lua
index 94620e6..3e74395 100644
--- a/src/plugins/admin/mute.lua
+++ b/src/plugins/admin/mute.lua
@@ -1,79 +1,83 @@
--[[
mattata v2.0 - Mute Plugin
]]
local plugin = {}
plugin.name = 'mute'
plugin.category = 'admin'
plugin.description = 'Mute users in a group'
plugin.commands = { 'mute' }
plugin.help = '/mute [user] [reason] - Mutes a user in the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Restrict Members" admin permission to use this command.')
end
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to mute.')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t mute an admin or moderator.')
end
local 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_add_web_page_previews = false,
+ can_invite_users = false,
+ can_change_info = false,
+ can_pin_messages = false,
+ can_manage_topics = false
}
local success = api.restrict_chat_member(message.chat.id, user_id, perms)
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to mute users.')
end
pcall(function()
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'mute', reason })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'mute', reason))
end)
if reason and reason:lower():match('^for ') then reason = reason:sub(5) end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and (', for ' .. tools.escape_html(reason)) or ''
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has muted <a href="tg://user?id=%d">%s</a>%s.',
message.from.id, admin_name, user_id, target_name, reason_text
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/nodelete.lua b/src/plugins/admin/nodelete.lua
index 096adea..385db7f 100644
--- a/src/plugins/admin/nodelete.lua
+++ b/src/plugins/admin/nodelete.lua
@@ -1,52 +1,52 @@
--[[
mattata v2.0 - No Delete Plugin
]]
local plugin = {}
plugin.name = 'nodelete'
plugin.category = 'admin'
plugin.description = 'Toggle whether a plugin\'s commands are auto-deleted'
plugin.commands = { 'nodelete' }
plugin.help = '/nodelete <plugin> - Toggle whether a plugin\'s command messages are auto-deleted.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
-- List current no-delete plugins
local no_delete = ctx.redis.smembers('chat:' .. message.chat.id .. ':no_delete')
if not no_delete or #no_delete == 0 then
return api.send_message(message.chat.id, 'No plugins are exempt from auto-deletion.\nUsage: /nodelete <plugin_name>')
end
local output = '<b>Plugins exempt from auto-deletion:</b>\n\n'
for _, name in ipairs(no_delete) do
output = output .. '- <code>' .. tools.escape_html(name) .. '</code>\n'
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
local plugin_name = message.args:lower():match('^(%S+)$')
if not plugin_name then
return api.send_message(message.chat.id, 'Usage: /nodelete <plugin_name>')
end
local key = 'chat:' .. message.chat.id .. ':no_delete'
local is_set = ctx.redis.sismember(key, plugin_name)
if is_set and is_set ~= false and is_set ~= 0 then
ctx.redis.srem(key, plugin_name)
api.send_message(message.chat.id, string.format(
'Commands from <code>%s</code> will now be auto-deleted.',
tools.escape_html(plugin_name)
- ), 'html')
+ ), { parse_mode = 'html' })
else
ctx.redis.sadd(key, plugin_name)
api.send_message(message.chat.id, string.format(
'Commands from <code>%s</code> will no longer be auto-deleted.',
tools.escape_html(plugin_name)
- ), 'html')
+ ), { parse_mode = 'html' })
end
end
return plugin
diff --git a/src/plugins/admin/pin.lua b/src/plugins/admin/pin.lua
index d8fc2eb..c51ff89 100644
--- a/src/plugins/admin/pin.lua
+++ b/src/plugins/admin/pin.lua
@@ -1,72 +1,63 @@
--[[
mattata v2.0 - Pin Plugin
]]
local plugin = {}
plugin.name = 'pin'
plugin.category = 'admin'
plugin.description = 'Pin and unpin messages'
plugin.commands = { 'pin', 'unpin' }
plugin.help = '/pin - Pins the replied-to message. /unpin - Unpins the current pinned message.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local permissions = require('src.core.permissions')
if not permissions.can_pin(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Pin Messages" admin permission to use this command.')
end
if message.command == 'pin' then
if not message.reply then
return api.send_message(message.chat.id, 'Please reply to the message you want to pin.')
end
-- Check for silent pin flag
local disable_notification = true
if message.args and (message.args:lower() == 'loud' or message.args:lower() == 'notify') then
disable_notification = false
end
- local success = api.pin_chat_message(message.chat.id, message.reply.message_id, disable_notification)
+ local success = api.pin_chat_message(message.chat.id, message.reply.message_id, { disable_notification = disable_notification })
if not success then
return api.send_message(message.chat.id, 'I couldn\'t pin that message. Make sure I have the right permissions.')
end
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- action = 'pin',
- reason = 'Pinned message ' .. message.reply.message_id
- })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, nil, 'pin', 'Pinned message ' .. message.reply.message_id))
end)
-- Delete the command message
pcall(function() api.delete_message(message.chat.id, message.message_id) end)
elseif message.command == 'unpin' then
local success
if message.reply then
- success = api.unpin_chat_message(message.chat.id, message.reply.message_id)
+ success = api.unpin_chat_message(message.chat.id, { message_id = message.reply.message_id })
else
success = api.unpin_chat_message(message.chat.id)
end
if not success then
return api.send_message(message.chat.id, 'I couldn\'t unpin the message. Make sure I have the right permissions.')
end
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- action = 'unpin'
- })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, nil, 'unpin', nil))
end)
api.send_message(message.chat.id, 'Message unpinned.')
end
end
return plugin
diff --git a/src/plugins/admin/promote.lua b/src/plugins/admin/promote.lua
index 078c7d7..0d80c43 100644
--- a/src/plugins/admin/promote.lua
+++ b/src/plugins/admin/promote.lua
@@ -1,52 +1,53 @@
--[[
mattata v2.0 - Promote Plugin
]]
local plugin = {}
plugin.name = 'promote'
plugin.category = 'admin'
plugin.description = 'Promote a user to moderator'
plugin.commands = { 'promote' }
plugin.help = '/promote [user] - Promotes a user to moderator in the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to promote, either by replying to their message or providing a username/ID.')
end
if user_id == api.info.id then return end
if permissions.is_group_mod(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is already a moderator.')
end
ctx.db.call('sp_set_member_role', { message.chat.id, user_id, 'moderator' })
+ require('src.core.session').invalidate_admin_status(message.chat.id, user_id)
pcall(function()
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'promote', nil })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'promote', nil))
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has promoted <a href="tg://user?id=%d">%s</a> to moderator.',
message.from.id, admin_name, user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/purge.lua b/src/plugins/admin/purge.lua
index 48f78d4..a8d1263 100644
--- a/src/plugins/admin/purge.lua
+++ b/src/plugins/admin/purge.lua
@@ -1,71 +1,71 @@
--[[
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
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 + #batch
else
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) })
+ 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), 'html')
+ 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 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/admin/report.lua b/src/plugins/admin/report.lua
index c8c44da..6d7d3d9 100644
--- a/src/plugins/admin/report.lua
+++ b/src/plugins/admin/report.lua
@@ -1,60 +1,60 @@
--[[
mattata v2.0 - Report Plugin
]]
local plugin = {}
plugin.name = 'report'
plugin.category = 'admin'
plugin.description = 'Report a user to group admins'
plugin.commands = { 'report' }
plugin.help = '/report - Reports the replied-to user to all group admins.'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.reply or not message.reply.from then
return api.send_message(message.chat.id, 'Please reply to the message of the user you want to report.')
end
local reported_id = message.reply.from.id
if reported_id == message.from.id then
return api.send_message(message.chat.id, 'You can\'t report yourself.')
end
if reported_id == api.info.id then
return api.send_message(message.chat.id, 'You can\'t report me.')
end
-- Get chat administrators
local admins = api.get_chat_administrators(message.chat.id)
if not admins or not admins.result then
return api.send_message(message.chat.id, 'I couldn\'t retrieve the list of admins.')
end
local mentions = {}
for _, admin in ipairs(admins.result) do
if not admin.user.is_bot then
table.insert(mentions, string.format(
'<a href="tg://user?id=%d">%s</a>',
admin.user.id,
tools.escape_html(admin.user.first_name)
))
end
end
local reporter_name = tools.escape_html(message.from.first_name)
local reported_name = tools.escape_html(message.reply.from.first_name)
local reason = message.args and ('\nReason: ' .. tools.escape_html(message.args)) or ''
local output = string.format(
'<a href="tg://user?id=%d">%s</a> has reported <a href="tg://user?id=%d">%s</a> to the admins.%s\n\n%s',
message.from.id, reporter_name,
reported_id, reported_name,
reason,
table.concat(mentions, ', ')
)
- api.send_message(message.chat.id, output, 'html', false, false, message.reply.message_id)
+ api.send_message(message.chat.id, output, { parse_mode = 'html', reply_parameters = { message_id = message.reply.message_id } })
end
return plugin
diff --git a/src/plugins/admin/rules.lua b/src/plugins/admin/rules.lua
index acecc8d..8598618 100644
--- a/src/plugins/admin/rules.lua
+++ b/src/plugins/admin/rules.lua
@@ -1,37 +1,37 @@
--[[
mattata v2.0 - Rules Plugin
]]
local plugin = {}
plugin.name = 'rules'
plugin.category = 'admin'
plugin.description = 'Display group rules'
plugin.commands = { 'rules' }
plugin.help = '/rules - Displays the group rules. Admins can set rules with /setrules <text>.'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
-- if /rules is used with args and user is admin, set rules
if message.args and (ctx.is_admin or ctx.is_global_admin) then
ctx.db.call('sp_upsert_rules', { message.chat.id, message.args })
return api.send_message(message.chat.id, 'The rules have been updated.')
end
-- retrieve rules
local result = ctx.db.call('sp_get_rules', { message.chat.id })
if not result or #result == 0 or not result[1].rules_text then
return api.send_message(message.chat.id, 'No rules have been set for this group. An admin can set them with /rules <text>.')
end
local output = string.format(
'<b>Rules for %s:</b>\n\n%s',
tools.escape_html(message.chat.title or 'this chat'),
result[1].rules_text
)
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/save.lua b/src/plugins/admin/save.lua
index 78a1f2d..61cc434 100644
--- a/src/plugins/admin/save.lua
+++ b/src/plugins/admin/save.lua
@@ -1,108 +1,108 @@
--[[
mattata v2.0 - Save/Get Notes Plugin
]]
local plugin = {}
plugin.name = 'save'
plugin.category = 'admin'
plugin.description = 'Save and retrieve notes'
plugin.commands = { 'save', 'get' }
plugin.help = '/save <name> - Saves replied-to message as a note. /get <name> - Retrieves a saved note.'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if message.command == 'get' then
if not message.args then
-- list all saved notes
local notes = ctx.db.call('sp_list_notes', { message.chat.id })
if not notes or #notes == 0 then
return api.send_message(message.chat.id, 'No notes saved. An admin can save notes with /save <name> in reply to a message.')
end
local output = '<b>Saved notes:</b>\n\n'
for _, note in ipairs(notes) do
output = output .. '- <code>' .. tools.escape_html(note.note_name) .. '</code>\n'
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
local name = message.args:lower():match('^(%S+)')
local note = ctx.db.call('sp_get_note', { message.chat.id, name })
if not note or #note == 0 then
- return api.send_message(message.chat.id, string.format('Note <code>%s</code> not found.', tools.escape_html(name)), 'html')
+ return api.send_message(message.chat.id, string.format('Note <code>%s</code> not found.', tools.escape_html(name)), { parse_mode = 'html' })
end
local n = note[1]
if n.content_type == 'photo' and n.file_id then
- api.send_photo(message.chat.id, n.file_id, n.content)
+ api.send_photo(message.chat.id, n.file_id, { caption = n.content })
elseif n.content_type == 'document' and n.file_id then
- api.send_document(message.chat.id, n.file_id, n.content)
+ api.send_document(message.chat.id, n.file_id, { caption = n.content })
elseif n.content_type == 'video' and n.file_id then
- api.send_video(message.chat.id, n.file_id, nil, nil, nil, n.content)
+ api.send_video(message.chat.id, n.file_id, { caption = n.content })
elseif n.content_type == 'audio' and n.file_id then
- api.send_audio(message.chat.id, n.file_id, n.content)
+ api.send_audio(message.chat.id, n.file_id, { caption = n.content })
elseif n.content_type == 'sticker' and n.file_id then
api.send_sticker(message.chat.id, n.file_id)
else
- api.send_message(message.chat.id, n.content, 'html')
+ api.send_message(message.chat.id, n.content, { parse_mode = 'html' })
end
return
end
-- /save requires admin
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, 'Only admins can save notes.')
end
if not message.args then
return api.send_message(message.chat.id, 'Usage: /save <name> in reply to a message.')
end
local name = message.args:lower():match('^(%S+)')
if not name then
return api.send_message(message.chat.id, 'Please provide a name for the note.')
end
local content = ''
local content_type = 'text'
local file_id = nil
if message.reply then
content = message.reply.text or message.reply.caption or ''
if message.reply.photo then
content_type = 'photo'
file_id = message.reply.photo[#message.reply.photo].file_id
elseif message.reply.document then
content_type = 'document'
file_id = message.reply.document.file_id
elseif message.reply.video then
content_type = 'video'
file_id = message.reply.video.file_id
elseif message.reply.audio then
content_type = 'audio'
file_id = message.reply.audio.file_id
elseif message.reply.sticker then
content_type = 'sticker'
file_id = message.reply.sticker.file_id
end
else
-- if no reply, save the text after the note name
local _, rest = message.args:match('^(%S+)%s+(.+)$')
if rest then
content = rest
else
return api.send_message(message.chat.id, 'Please reply to a message or provide text after the note name.')
end
end
- ctx.db.call('sp_upsert_note', { message.chat.id, name, content, content_type, file_id, message.from.id })
+ ctx.db.call('sp_upsert_note', table.pack(message.chat.id, name, content, content_type, file_id, message.from.id))
api.send_message(message.chat.id, string.format(
'Note <code>%s</code> has been saved. Use /get %s to retrieve it.',
tools.escape_html(name), tools.escape_html(name)
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/setcaptcha.lua b/src/plugins/admin/setcaptcha.lua
index b458c1f..20d5fd0 100644
--- a/src/plugins/admin/setcaptcha.lua
+++ b/src/plugins/admin/setcaptcha.lua
@@ -1,68 +1,62 @@
--[[
mattata v2.0 - Set Captcha Plugin
]]
local plugin = {}
plugin.name = 'setcaptcha'
plugin.category = 'admin'
plugin.description = 'Configure captcha settings for new members'
plugin.commands = { 'setcaptcha' }
plugin.help = '/setcaptcha <on|off|timeout <seconds>> - Configure captcha settings.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if not message.args then
-- Show current captcha status
- local enabled = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'captcha_enabled'",
- { message.chat.id }
- )
- local timeout = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'captcha_timeout'",
- { message.chat.id }
- )
+ local enabled = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'captcha_enabled' })
+ local timeout = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'captcha_timeout' })
local status = (enabled and #enabled > 0 and enabled[1].value == 'true') and 'enabled' or 'disabled'
local timeout_val = (timeout and #timeout > 0) and timeout[1].value or '300'
return api.send_message(message.chat.id, string.format(
'<b>Captcha settings:</b>\nStatus: %s\nTimeout: %s seconds\n\n'
.. 'Usage:\n<code>/setcaptcha on</code> - Enable captcha\n'
.. '<code>/setcaptcha off</code> - Disable captcha\n'
.. '<code>/setcaptcha timeout &lt;seconds&gt;</code> - Set timeout',
status, timeout_val
- ), 'html')
+ ), { parse_mode = 'html' })
end
local args = message.args:lower()
if args == 'on' or args == 'enable' then
ctx.db.upsert('chat_settings', {
chat_id = message.chat.id,
key = 'captcha_enabled',
value = 'true'
}, { 'chat_id', 'key' }, { 'value' })
return api.send_message(message.chat.id, 'Captcha has been enabled for this group.')
elseif args == 'off' or args == 'disable' then
ctx.db.upsert('chat_settings', {
chat_id = message.chat.id,
key = 'captcha_enabled',
value = 'false'
}, { 'chat_id', 'key' }, { 'value' })
return api.send_message(message.chat.id, 'Captcha has been disabled for this group.')
elseif args:match('^timeout%s+(%d+)$') then
local seconds = args:match('^timeout%s+(%d+)$')
seconds = tonumber(seconds)
if seconds < 30 or seconds > 3600 then
return api.send_message(message.chat.id, 'Timeout must be between 30 and 3600 seconds.')
end
ctx.db.upsert('chat_settings', {
chat_id = message.chat.id,
key = 'captcha_timeout',
value = tostring(seconds)
}, { 'chat_id', 'key' }, { 'value' })
return api.send_message(message.chat.id, string.format('Captcha timeout set to %d seconds.', seconds))
else
return api.send_message(message.chat.id, 'Usage: /setcaptcha <on|off|timeout <seconds>>')
end
end
return plugin
diff --git a/src/plugins/admin/setgrouplang.lua b/src/plugins/admin/setgrouplang.lua
index ca6ce66..171c0e7 100644
--- a/src/plugins/admin/setgrouplang.lua
+++ b/src/plugins/admin/setgrouplang.lua
@@ -1,102 +1,104 @@
--[[
mattata v2.0 - Set Group Language Plugin
]]
local plugin = {}
plugin.name = 'setgrouplang'
plugin.category = 'admin'
plugin.description = 'Set the group language'
plugin.commands = { 'setgrouplang' }
plugin.help = '/setgrouplang [language_code] - Sets the group language. Shows available languages if no code given.'
plugin.group_only = true
plugin.admin_only = true
local LANGUAGES = {
{ code = 'en_gb', name = 'English (UK)' },
{ code = 'en_us', name = 'English (US)' },
{ code = 'es_es', name = 'Spanish' },
{ code = 'pt_br', name = 'Portuguese (BR)' },
{ code = 'de_de', name = 'German' },
{ code = 'fr_fr', name = 'French' },
{ code = 'it_it', name = 'Italian' },
{ code = 'ru_ru', name = 'Russian' },
{ code = 'ar_sa', name = 'Arabic' },
{ code = 'tr_tr', name = 'Turkish' },
{ code = 'nl_nl', name = 'Dutch' },
{ code = 'pl_pl', name = 'Polish' },
{ code = 'id_id', name = 'Indonesian' },
{ code = 'uk_ua', name = 'Ukrainian' },
{ code = 'he_il', name = 'Hebrew' },
{ code = 'fa_ir', name = 'Persian' }
}
function plugin.on_message(api, message, ctx)
if not message.args then
-- show inline keyboard with available languages
local keyboard = { inline_keyboard = {} }
local row = {}
for i, lang in ipairs(LANGUAGES) do
table.insert(row, {
text = lang.name,
callback_data = 'setgrouplang:' .. lang.code
})
if #row == 2 or i == #LANGUAGES then
table.insert(keyboard.inline_keyboard, row)
row = {}
end
end
- local json = require('dkjson')
- return api.send_message(message.chat.id, '<b>Select the group language:</b>', 'html', false, false, nil, json.encode(keyboard))
+ return api.send_message(message.chat.id, '<b>Select the group language:</b>', { parse_mode = 'html', reply_markup = keyboard })
end
local lang_code = message.args:lower():match('^(%S+)$')
if not lang_code then
return api.send_message(message.chat.id, 'Please provide a valid language code.')
end
-- validate the language code
local valid = false
for _, lang in ipairs(LANGUAGES) do
if lang.code == lang_code then
valid = true
break
end
end
if not valid then
return api.send_message(message.chat.id, 'Invalid language code. Use /setgrouplang to see available options.')
end
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'group_language', lang_code })
ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'force_group_language', 'true' })
- api.send_message(message.chat.id, string.format('Group language set to <b>%s</b>.', lang_code), 'html')
+ api.send_message(message.chat.id, string.format('Group language set to <b>%s</b>.', lang_code), { parse_mode = 'html' })
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local permissions = require('src.core.permissions')
if not permissions.is_group_admin(api, message.chat.id, callback_query.from.id) then
- return api.answer_callback_query(callback_query.id, 'Only admins can change the group language.')
+ return api.answer_callback_query(callback_query.id, { text = 'Only admins can change the group language.' })
end
local lang_code = callback_query.data
if not lang_code then return end
- ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'group_language', lang_code })
- ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'force_group_language', 'true' })
-
- -- find language name
- local lang_name = lang_code
+ -- Validate the language code against the allowed list
+ local lang_name = nil
for _, lang in ipairs(LANGUAGES) do
if lang.code == lang_code then
lang_name = lang.name
break
end
end
+ if not lang_name then
+ return api.answer_callback_query(callback_query.id, { text = 'Invalid language.' })
+ end
+
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'group_language', lang_code })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'force_group_language', 'true' })
api.edit_message_text(message.chat.id, message.message_id, string.format(
'Group language set to <b>%s</b> (%s).', lang_name, lang_code
- ), 'html')
- api.answer_callback_query(callback_query.id, 'Language updated!')
+ ), { parse_mode = 'html' })
+ api.answer_callback_query(callback_query.id, { text = 'Language updated!' })
end
return plugin
diff --git a/src/plugins/admin/setwelcome.lua b/src/plugins/admin/setwelcome.lua
index 8d7d60b..77efb9a 100644
--- a/src/plugins/admin/setwelcome.lua
+++ b/src/plugins/admin/setwelcome.lua
@@ -1,37 +1,37 @@
--[[
mattata v2.0 - Set Welcome Plugin
]]
local plugin = {}
plugin.name = 'setwelcome'
plugin.category = 'admin'
plugin.description = 'Set the welcome message for new members'
plugin.commands = { 'setwelcome', 'welcome' }
plugin.help = '/setwelcome <message> - Sets the welcome message. Placeholders: $name, $title, $id, $username, $mention'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if message.command == 'welcome' and not message.args then
-- show current welcome message
local result = ctx.db.call('sp_get_welcome_message', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No welcome message has been set. Use /setwelcome <message> to set one.')
end
- return api.send_message(message.chat.id, '<b>Current welcome message:</b>\n\n' .. result[1].message, 'html')
+ return api.send_message(message.chat.id, '<b>Current welcome message:</b>\n\n' .. result[1].message, { parse_mode = 'html' })
end
if not message.args then
return api.send_message(message.chat.id,
'Please provide the welcome message text.\n\n'
.. 'Placeholders: <code>$name</code>, <code>$title</code>, '
.. '<code>$id</code>, <code>$username</code>, <code>$mention</code>',
- 'html')
+ { parse_mode = 'html' })
end
ctx.db.call('sp_upsert_welcome_message', { message.chat.id, message.args })
api.send_message(message.chat.id, 'The welcome message has been updated.')
end
return plugin
diff --git a/src/plugins/admin/slowmode.lua b/src/plugins/admin/slowmode.lua
new file mode 100644
index 0000000..c285d72
--- /dev/null
+++ b/src/plugins/admin/slowmode.lua
@@ -0,0 +1,90 @@
+--[[
+ mattata v2.0 - Slowmode Plugin
+]]
+
+local plugin = {}
+plugin.name = 'slowmode'
+plugin.category = 'admin'
+plugin.description = 'Set the group slow mode delay'
+plugin.commands = { 'slowmode' }
+plugin.help = '/slowmode <off|10s|30s|1m|5m|15m|1h> - Set slow mode delay for the group.'
+plugin.group_only = true
+plugin.admin_only = true
+
+local VALID_DELAYS = {
+ ['0'] = 0,
+ ['off'] = 0,
+ ['10'] = 10,
+ ['10s'] = 10,
+ ['30'] = 30,
+ ['30s'] = 30,
+ ['60'] = 60,
+ ['1m'] = 60,
+ ['300'] = 300,
+ ['5m'] = 300,
+ ['900'] = 900,
+ ['15m'] = 900,
+ ['3600'] = 3600,
+ ['1h'] = 3600
+}
+
+local HUMAN_LABELS = {
+ [0] = 'disabled',
+ [10] = '10 seconds',
+ [30] = '30 seconds',
+ [60] = '1 minute',
+ [300] = '5 minutes',
+ [900] = '15 minutes',
+ [3600] = '1 hour'
+}
+
+function plugin.on_message(api, message, ctx)
+ if not message.args then
+ return api.send_message(message.chat.id,
+ '<b>Slow mode</b>\n\n'
+ .. 'Usage: <code>/slowmode &lt;delay&gt;</code>\n\n'
+ .. 'Valid values:\n'
+ .. '<code>off</code> - Disable slow mode\n'
+ .. '<code>10s</code> - 10 seconds\n'
+ .. '<code>30s</code> - 30 seconds\n'
+ .. '<code>1m</code> - 1 minute\n'
+ .. '<code>5m</code> - 5 minutes\n'
+ .. '<code>15m</code> - 15 minutes\n'
+ .. '<code>1h</code> - 1 hour',
+ { parse_mode = 'html' }
+ )
+ end
+
+ local arg = message.args:lower():gsub('%s+', '')
+ local delay = VALID_DELAYS[arg]
+ if not delay then
+ return api.send_message(message.chat.id,
+ 'Invalid delay. Valid values: off, 10s, 30s, 1m, 5m, 15m, 1h'
+ )
+ end
+
+ local config = require('telegram-bot-lua.config')
+ local result = api.request(config.endpoint .. api.token .. '/setChatSlowModeDelay', {
+ chat_id = message.chat.id,
+ slow_mode_delay = delay
+ })
+ if not result or not result.result then
+ return api.send_message(message.chat.id,
+ 'I couldn\'t set slow mode. Make sure I have the right permissions.'
+ )
+ end
+
+ pcall(function()
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, nil, 'slowmode', 'Set to ' .. delay .. 's'))
+ end)
+
+ if delay == 0 then
+ return api.send_message(message.chat.id, 'Slow mode disabled.')
+ end
+
+ return api.send_message(message.chat.id,
+ string.format('Slow mode set to %s.', HUMAN_LABELS[delay] or (delay .. ' seconds'))
+ )
+end
+
+return plugin
diff --git a/src/plugins/admin/staff.lua b/src/plugins/admin/staff.lua
index 85af291..1b0bd74 100644
--- a/src/plugins/admin/staff.lua
+++ b/src/plugins/admin/staff.lua
@@ -1,74 +1,74 @@
--[[
mattata v2.0 - Staff Plugin
]]
local plugin = {}
plugin.name = 'staff'
plugin.category = 'admin'
plugin.description = 'List group staff (admins and moderators)'
plugin.commands = { 'staff', 'admins', 'mods' }
plugin.help = '/staff - Lists all admins and moderators in the current chat. Aliases: /admins, /mods'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
-- get telegram admins
local admins = api.get_chat_administrators(message.chat.id)
if not admins or not admins.result then
return api.send_message(message.chat.id, 'I couldn\'t retrieve the admin list.')
end
local output = '<b>Staff for ' .. tools.escape_html(message.chat.title or 'this chat') .. '</b>\n\n'
-- creator
local creator_text = ''
for _, admin in ipairs(admins.result) do
if admin.status == 'creator' then
local name = tools.escape_html(admin.user.first_name)
creator_text = string.format(
'<a href="tg://user?id=%d">%s</a>',
admin.user.id, name
)
break
end
end
if creator_text ~= '' then
output = output .. '<b>Owner:</b>\n' .. creator_text .. '\n\n'
end
-- admins
local admin_list = {}
for _, admin in ipairs(admins.result) do
if admin.status == 'administrator' and not admin.user.is_bot then
local name = tools.escape_html(admin.user.first_name)
table.insert(admin_list, string.format(
'- <a href="tg://user?id=%d">%s</a>',
admin.user.id, name
))
end
end
if #admin_list > 0 then
output = output .. '<b>Admins (' .. #admin_list .. '):</b>\n' .. table.concat(admin_list, '\n') .. '\n\n'
end
-- moderators (from database)
local mods = ctx.db.call('sp_get_moderators', { message.chat.id })
if mods and #mods > 0 then
local mod_list = {}
for _, mod in ipairs(mods) do
local info = api.get_chat(mod.user_id)
local name = info and info.result and tools.escape_html(info.result.first_name) or tostring(mod.user_id)
table.insert(mod_list, string.format(
'- <a href="tg://user?id=%s">%s</a>',
mod.user_id, name
))
end
output = output .. '<b>Moderators (' .. #mod_list .. '):</b>\n' .. table.concat(mod_list, '\n') .. '\n'
end
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/tempban.lua b/src/plugins/admin/tempban.lua
index 8382066..e861c13 100644
--- a/src/plugins/admin/tempban.lua
+++ b/src/plugins/admin/tempban.lua
@@ -1,85 +1,85 @@
--[[
mattata v2.0 - Tempban Plugin
]]
local plugin = {}
plugin.name = 'tempban'
plugin.category = 'admin'
plugin.description = 'Temporarily ban users'
plugin.commands = { 'tempban', 'tban' }
plugin.help = '/tempban [user] <duration> - Temporarily bans a user. Duration format: 1h, 2d, 1w.'
plugin.group_only = true
plugin.admin_only = true
local function parse_duration(str)
if not str then return nil end
local total = 0
for num, unit in str:gmatch('(%d+)(%a)') do
num = tonumber(num)
if unit == 's' then total = total + num
elseif unit == 'm' then total = total + num * 60
elseif unit == 'h' then total = total + num * 3600
elseif unit == 'd' then total = total + num * 86400
elseif unit == 'w' then total = total + num * 604800
end
end
return total > 0 and total or nil
end
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, duration_str
if message.reply and message.reply.from then
user_id = message.reply.from.id
duration_str = message.args
elseif message.args then
user_id, duration_str = message.args:match('^(%S+)%s+(.+)$')
if not user_id then
user_id = message.args
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user and duration. Example: /tempban @user 2h')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
local duration = parse_duration(duration_str)
if not duration or duration < 60 then
return api.send_message(message.chat.id, 'Please provide a valid duration (minimum 1 minute). Example: 1h, 2d, 1w')
end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t ban an admin.')
end
local until_date = os.time() + duration
- local success = api.ban_chat_member(message.chat.id, user_id, until_date)
+ local success = api.ban_chat_member(message.chat.id, user_id, { until_date = until_date })
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to ban users.')
end
pcall(function()
ctx.db.call('sp_insert_tempban', { message.chat.id, user_id, message.from.id, os.date('!%Y-%m-%d %H:%M:%S', until_date) })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has temporarily banned <a href="tg://user?id=%d">%s</a> for %s.',
message.from.id, admin_name, user_id, target_name, duration_str or 'unknown'
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/tempmute.lua b/src/plugins/admin/tempmute.lua
index 4def7bb..59fdf38 100644
--- a/src/plugins/admin/tempmute.lua
+++ b/src/plugins/admin/tempmute.lua
@@ -1,78 +1,92 @@
--[[
mattata v2.0 - Tempmute Plugin
]]
local plugin = {}
plugin.name = 'tempmute'
plugin.category = 'admin'
plugin.description = 'Temporarily mute users'
plugin.commands = { 'tempmute', 'tmute' }
plugin.help = '/tempmute [user] <duration> - Temporarily mutes a user. Duration format: 1h, 2d.'
plugin.group_only = true
plugin.admin_only = true
local function parse_duration(str)
if not str then return nil end
local total = 0
for num, unit in str:gmatch('(%d+)(%a)') do
num = tonumber(num)
if unit == 's' then total = total + num
elseif unit == 'm' then total = total + num * 60
elseif unit == 'h' then total = total + num * 3600
elseif unit == 'd' then total = total + num * 86400
elseif unit == 'w' then total = total + num * 604800
end
end
return total > 0 and total or nil
end
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Restrict Members" admin permission to use this command.')
end
local user_id, duration_str
if message.reply and message.reply.from then
user_id = message.reply.from.id
duration_str = message.args
elseif message.args then
user_id, duration_str = message.args:match('^(%S+)%s+(.+)$')
if not user_id then user_id = message.args end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user and duration.')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
local duration = parse_duration(duration_str)
if not duration or duration < 60 then
return api.send_message(message.chat.id, 'Please provide a valid duration (min 1 minute).')
end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t mute an admin.')
end
- local until_date = os.time() + duration
- local perms = { can_send_messages = false }
- local success = api.restrict_chat_member(message.chat.id, user_id, perms, until_date)
+ local 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 success = api.restrict_chat_member(message.chat.id, user_id, perms, { until_date = os.time() + duration })
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to mute users.')
end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has temporarily muted <a href="tg://user?id=%d">%s</a> for %s.',
message.from.id, admin_name, user_id, target_name, duration_str or 'unknown'
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/topic.lua b/src/plugins/admin/topic.lua
new file mode 100644
index 0000000..1af6ae8
--- /dev/null
+++ b/src/plugins/admin/topic.lua
@@ -0,0 +1,116 @@
+--[[
+ mattata v2.0 - Topic Plugin
+ Forum topic management for supergroups with topics enabled.
+]]
+
+local plugin = {}
+plugin.name = 'topic'
+plugin.category = 'admin'
+plugin.description = 'Manage forum topics in supergroups'
+plugin.commands = { 'topic' }
+plugin.help = '/topic <create <name>|close|reopen|delete> - Manage forum topics.'
+plugin.group_only = true
+plugin.admin_only = true
+
+local tools = require('telegram-bot-lua.tools')
+
+function plugin.on_message(api, message, ctx)
+ if not message.args then
+ return api.send_message(message.chat.id,
+ '<b>Topic management</b>\n\n'
+ .. '<code>/topic create &lt;name&gt;</code> - Create a new topic\n'
+ .. '<code>/topic close</code> - Close the current topic\n'
+ .. '<code>/topic reopen</code> - Reopen a closed topic\n'
+ .. '<code>/topic delete</code> - Delete the current topic\n\n'
+ .. 'Close, reopen, and delete must be used inside a topic thread.',
+ { parse_mode = 'html' }
+ )
+ end
+
+ local action, rest = message.args:match('^(%S+)%s*(.*)')
+ if not action then
+ return api.send_message(message.chat.id, 'Usage: /topic <create <name>|close|reopen|delete>')
+ end
+
+ action = action:lower()
+
+ if action == 'create' then
+ local name = rest and rest:match('^%s*(.+)%s*$')
+ if not name or name == '' then
+ return api.send_message(message.chat.id, 'Usage: /topic create <name>')
+ end
+
+ local result = api.create_forum_topic(message.chat.id, name)
+ if not result or not result.result then
+ return api.send_message(message.chat.id,
+ 'I couldn\'t create the topic. Make sure the group has topics enabled and I have the right permissions.'
+ )
+ end
+
+ pcall(function()
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, nil, 'topic', 'Created topic: ' .. name))
+ end)
+
+ return api.send_message(message.chat.id,
+ string.format('Topic <b>%s</b> has been created.', tools.escape_html(name)),
+ { parse_mode = 'html' }
+ )
+
+ elseif action == 'close' then
+ if not message.is_topic or not message.thread_id then
+ return api.send_message(message.chat.id, 'This command must be used inside a topic thread.')
+ end
+
+ local result = api.close_forum_topic(message.chat.id, message.thread_id)
+ if not result or not result.result then
+ return api.send_message(message.chat.id,
+ 'I couldn\'t close this topic. Make sure I have the right permissions.'
+ )
+ end
+
+ pcall(function()
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, nil, 'topic', 'Closed topic ' .. message.thread_id))
+ end)
+
+ return api.send_message(message.chat.id, 'This topic has been closed.')
+
+ elseif action == 'reopen' then
+ if not message.is_topic or not message.thread_id then
+ return api.send_message(message.chat.id, 'This command must be used inside a topic thread.')
+ end
+
+ local result = api.reopen_forum_topic(message.chat.id, message.thread_id)
+ if not result or not result.result then
+ return api.send_message(message.chat.id,
+ 'I couldn\'t reopen this topic. Make sure I have the right permissions.'
+ )
+ end
+
+ pcall(function()
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, nil, 'topic', 'Reopened topic ' .. message.thread_id))
+ end)
+
+ return api.send_message(message.chat.id, 'This topic has been reopened.')
+
+ elseif action == 'delete' then
+ if not message.is_topic or not message.thread_id then
+ return api.send_message(message.chat.id, 'This command must be used inside a topic thread.')
+ end
+
+ local result = api.delete_forum_topic(message.chat.id, message.thread_id)
+ if not result or not result.result then
+ return api.send_message(message.chat.id,
+ 'I couldn\'t delete this topic. Make sure I have the right permissions.'
+ )
+ end
+
+ pcall(function()
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, nil, 'topic', 'Deleted topic ' .. message.thread_id))
+ end)
+
+ else
+ return api.send_message(message.chat.id, 'Unknown action. Usage: /topic <create <name>|close|reopen|delete>')
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/triggers.lua b/src/plugins/admin/triggers.lua
index f61da86..d701c41 100644
--- a/src/plugins/admin/triggers.lua
+++ b/src/plugins/admin/triggers.lua
@@ -1,37 +1,37 @@
--[[
mattata v2.0 - Triggers Plugin
]]
local plugin = {}
plugin.name = 'triggers'
plugin.category = 'admin'
plugin.description = 'List all triggers in the group'
plugin.commands = { 'triggers' }
plugin.help = '/triggers - Lists all triggers set for this group.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local triggers = ctx.db.call('sp_get_triggers_full', { message.chat.id })
if not triggers or #triggers == 0 then
return api.send_message(message.chat.id, 'No triggers are set. Use /addtrigger <pattern> <response> to add one.')
end
local output = '<b>Triggers for this group:</b>\n\n'
for i, t in ipairs(triggers) do
output = output .. string.format(
'%d. <code>%s</code> -> %s\n',
i,
tools.escape_html(t.pattern),
tools.escape_html(t.response:sub(1, 50)) .. (#t.response > 50 and '...' or '')
)
end
output = output .. string.format('\n<i>Total: %d trigger(s)</i>', #triggers)
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/trust.lua b/src/plugins/admin/trust.lua
index 7874812..f689694 100644
--- a/src/plugins/admin/trust.lua
+++ b/src/plugins/admin/trust.lua
@@ -1,52 +1,52 @@
--[[
mattata v2.0 - Trust Plugin
]]
local plugin = {}
plugin.name = 'trust'
plugin.category = 'admin'
plugin.description = 'Trust a user in the group'
plugin.commands = { 'trust' }
plugin.help = '/trust [user] - Marks a user as trusted in the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to trust, either by replying to their message or providing a username/ID.')
end
if user_id == api.info.id then return end
if permissions.is_trusted(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is already trusted.')
end
ctx.db.call('sp_set_member_role', { message.chat.id, user_id, 'trusted' })
pcall(function()
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'trust', nil })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'trust', nil))
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has trusted <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/unban.lua b/src/plugins/admin/unban.lua
index 6de7563..4aeebe0 100644
--- a/src/plugins/admin/unban.lua
+++ b/src/plugins/admin/unban.lua
@@ -1,49 +1,49 @@
--[[
mattata v2.0 - Unban Plugin
]]
local plugin = {}
plugin.name = 'unban'
plugin.category = 'admin'
plugin.description = 'Unban users from a group'
plugin.commands = { 'unban' }
plugin.help = '/unban [user] - Unbans a user from the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args and message.args ~= '' then
local input = message.args:match('^@?(%S+)')
user_id = tonumber(input) or ctx.redis.get('username:' .. input:lower())
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to unban.')
end
user_id = tonumber(user_id)
local success = api.unban_chat_member(message.chat.id, user_id)
if not success then
return api.send_message(message.chat.id, 'I couldn\'t unban that user.')
end
pcall(function()
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'unban', nil })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'unban', nil))
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has unbanned <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/unfilter.lua b/src/plugins/admin/unfilter.lua
index 2d98cd7..341072e 100644
--- a/src/plugins/admin/unfilter.lua
+++ b/src/plugins/admin/unfilter.lua
@@ -1,56 +1,56 @@
--[[
mattata v2.0 - Unfilter Plugin
]]
local plugin = {}
plugin.name = 'unfilter'
plugin.category = 'admin'
plugin.description = 'Remove a content filter from the group'
plugin.commands = { 'unfilter', 'delfilter' }
plugin.help = '/unfilter <pattern> - Removes a filter. Alias: /delfilter'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
-- list existing filters
local filters = ctx.db.call('sp_get_filters_ordered', { message.chat.id })
if not filters or #filters == 0 then
return api.send_message(message.chat.id, 'There are no filters set for this group.')
end
local output = '<b>Active filters:</b>\n\n'
for i, f in ipairs(filters) do
output = output .. string.format('%d. <code>%s</code> [%s]\n', i, tools.escape_html(f.pattern), f.action)
end
output = output .. '\nUse /unfilter <pattern> to remove a filter.'
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
local pattern = message.args:match('^%s*(.-)%s*$')
local result = ctx.db.call('sp_delete_filter_by_pattern', { message.chat.id, pattern })
if result and result[1] and tonumber(result[1].count) > 0 then
api.send_message(message.chat.id, string.format(
'Filter <code>%s</code> has been removed.',
tools.escape_html(pattern)
- ), 'html')
+ ), { parse_mode = 'html' })
else
-- try by index number
local index = tonumber(pattern)
if index then
local filters = ctx.db.call('sp_get_filters_ordered', { message.chat.id })
if filters and filters[index] then
ctx.db.call('sp_delete_filter_by_id', { filters[index].id })
return api.send_message(message.chat.id, string.format(
'Filter <code>%s</code> has been removed.',
tools.escape_html(filters[index].pattern)
- ), 'html')
+ ), { parse_mode = 'html' })
end
end
api.send_message(message.chat.id, 'That filter doesn\'t exist. Use /unfilter without arguments to see all filters.')
end
end
return plugin
diff --git a/src/plugins/admin/unmute.lua b/src/plugins/admin/unmute.lua
index a9af102..8c5f2da 100644
--- a/src/plugins/admin/unmute.lua
+++ b/src/plugins/admin/unmute.lua
@@ -1,58 +1,62 @@
--[[
mattata v2.0 - Unmute Plugin
]]
local plugin = {}
plugin.name = 'unmute'
plugin.category = 'admin'
plugin.description = 'Unmute users in a group'
plugin.commands = { 'unmute' }
plugin.help = '/unmute [user] - Unmutes a user in the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Restrict Members" admin permission to use this command.')
end
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args and message.args ~= '' then
local input = message.args:match('^@?(%S+)')
user_id = tonumber(input) or ctx.redis.get('username:' .. input:lower())
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to unmute.')
end
user_id = tonumber(user_id)
local 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_add_web_page_previews = true,
+ can_invite_users = true,
+ can_change_info = false,
+ can_pin_messages = false,
+ can_manage_topics = false
}
local success = api.restrict_chat_member(message.chat.id, user_id, perms)
if not success then
return api.send_message(message.chat.id, 'I couldn\'t unmute that user.')
end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has unmuted <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/untrust.lua b/src/plugins/admin/untrust.lua
index 6e283f4..1f2a6b7 100644
--- a/src/plugins/admin/untrust.lua
+++ b/src/plugins/admin/untrust.lua
@@ -1,51 +1,51 @@
--[[
mattata v2.0 - Untrust Plugin
]]
local plugin = {}
plugin.name = 'untrust'
plugin.category = 'admin'
plugin.description = 'Remove trusted status from a user'
plugin.commands = { 'untrust' }
plugin.help = '/untrust [user] - Removes trusted status from a user.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to untrust, either by replying to their message or providing a username/ID.')
end
if not permissions.is_trusted(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is not trusted.')
end
ctx.db.call('sp_reset_member_role', { message.chat.id, user_id })
pcall(function()
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'untrust', nil })
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'untrust', nil))
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has removed trusted status from <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/admin/warn.lua b/src/plugins/admin/warn.lua
index 6b4b2eb..d5d053b 100644
--- a/src/plugins/admin/warn.lua
+++ b/src/plugins/admin/warn.lua
@@ -1,131 +1,131 @@
--[[
mattata v2.0 - Warn Plugin
Warning system with configurable max warnings and auto-ban.
]]
local plugin = {}
plugin.name = 'warn'
plugin.category = 'admin'
plugin.description = 'Warn users with auto-ban threshold'
plugin.commands = { 'warn' }
plugin.help = '/warn [user] [reason] - Warns a user. After reaching max warnings, user is banned.'
plugin.group_only = true
plugin.admin_only = true
local DEFAULT_MAX_WARNINGS = 3
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to warn.')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t warn an admin or moderator.')
end
-- increment warning count
local hash = string.format('chat:%s:%s', message.chat.id, user_id)
local amount = ctx.redis.hincrby(hash, 'warnings', 1)
local max_warnings = tonumber(ctx.session.get_setting(message.chat.id, 'max warnings')) or DEFAULT_MAX_WARNINGS
-- auto-ban if threshold reached
if tonumber(amount) >= max_warnings then
api.ban_chat_member(message.chat.id, user_id)
end
-- log to database
pcall(function()
- ctx.db.call('sp_insert_warning', { message.chat.id, user_id, message.from.id, reason })
- ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'warn', reason })
+ ctx.db.call('sp_insert_warning', table.pack(message.chat.id, user_id, message.from.id, reason))
+ ctx.db.call('sp_log_admin_action', table.pack(message.chat.id, message.from.id, user_id, 'warn', reason))
end)
if reason and reason:lower():match('^for ') then reason = reason:sub(5) end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and (', for ' .. tools.escape_html(reason)) or ''
local output
if tonumber(amount) >= max_warnings then
output = string.format(
'<a href="tg://user?id=%d">%s</a> has warned <a href="tg://user?id=%d">%s</a>%s.\n<b>%d/%d warnings reached - user has been banned.</b>',
message.from.id, admin_name, user_id, target_name, reason_text, amount, max_warnings
)
else
output = string.format(
'<a href="tg://user?id=%d">%s</a> has warned <a href="tg://user?id=%d">%s</a>%s. [%d/%d]',
message.from.id, admin_name, user_id, target_name, reason_text, amount, max_warnings
)
end
local keyboard = api.inline_keyboard():row(
api.row():callback_data_button(
'Reset Warnings', string.format('warn:reset:%s:%s', message.chat.id, user_id)
):callback_data_button(
'Remove 1', string.format('warn:remove:%s:%s', message.chat.id, user_id)
)
)
- api.send_message(message.chat.id, output, 'html', true, false, nil, keyboard)
+ api.send_message(message.chat.id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
if message.reply then
pcall(function() api.delete_message(message.chat.id, message.reply.message_id) end)
end
pcall(function() api.delete_message(message.chat.id, message.message_id) end)
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if callback_query.data:match('^reset:%-?%d+:%d+$') then
local chat_id, user_id = callback_query.data:match('^reset:(%-?%d+):(%d+)$')
if not permissions.is_group_admin(api, tonumber(chat_id), callback_query.from.id) then
- return api.answer_callback_query(callback_query.id, 'You need to be an admin.')
+ return api.answer_callback_query(callback_query.id, { text = 'You need to be an admin.' })
end
ctx.redis.hdel(string.format('chat:%s:%s', chat_id, user_id), 'warnings')
local name = callback_query.from.username and ('@' .. callback_query.from.username) or tools.escape_html(callback_query.from.first_name)
return api.edit_message_text(message.chat.id, message.message_id,
- '<pre>Warnings reset by ' .. name .. '!</pre>', 'html')
+ '<pre>Warnings reset by ' .. name .. '!</pre>', { parse_mode = 'html' })
elseif callback_query.data:match('^remove:%-?%d+:%d+$') then
local chat_id, user_id = callback_query.data:match('^remove:(%-?%d+):(%d+)$')
if not permissions.is_group_admin(api, tonumber(chat_id), callback_query.from.id) then
- return api.answer_callback_query(callback_query.id, 'You need to be an admin.')
+ return api.answer_callback_query(callback_query.id, { text = 'You need to be an admin.' })
end
local hash = string.format('chat:%s:%s', chat_id, user_id)
local amount = ctx.redis.hincrby(hash, 'warnings', -1)
if tonumber(amount) < 0 then
ctx.redis.hincrby(hash, 'warnings', 1)
- return api.answer_callback_query(callback_query.id, 'No warnings to remove!')
+ return api.answer_callback_query(callback_query.id, { text = 'No warnings to remove!' })
end
local max_warnings = tonumber(ctx.session.get_setting(tonumber(chat_id), 'max warnings')) or DEFAULT_MAX_WARNINGS
local name = callback_query.from.username and ('@' .. callback_query.from.username) or tools.escape_html(callback_query.from.first_name)
return api.edit_message_text(message.chat.id, message.message_id,
- string.format('<pre>Warning removed by %s! [%s/%s]</pre>', name, amount, max_warnings), 'html')
+ string.format('<pre>Warning removed by %s! [%s/%s]</pre>', name, amount, max_warnings), { parse_mode = 'html' })
end
end
return plugin
diff --git a/src/plugins/admin/wordfilter.lua b/src/plugins/admin/wordfilter.lua
index 5b5bf7c..ac117e7 100644
--- a/src/plugins/admin/wordfilter.lua
+++ b/src/plugins/admin/wordfilter.lua
@@ -1,103 +1,110 @@
--[[
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')
+ ), { parse_mode = '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 ctx.is_global_admin or ctx:check_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()
+ -- Skip patterns that could cause catastrophic backtracking
+ if #f.pattern > 128 then goto continue end
+ local ok, matched = pcall(function()
return text:match(f.pattern:lower())
end)
- if match and text:match(f.pattern:lower()) then
+ if ok and matched 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')
+ ), { parse_mode = '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, {
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_add_web_page_previews = false,
+ can_invite_users = false,
+ can_change_info = false,
+ can_pin_messages = false,
+ can_manage_topics = false
}, { until_date = os.time() + 3600 })
end
return
end
+ ::continue::
end
end
return plugin
diff --git a/src/plugins/ai/ai.lua b/src/plugins/ai/ai.lua
index 1659897..3f0427a 100644
--- a/src/plugins/ai/ai.lua
+++ b/src/plugins/ai/ai.lua
@@ -1,262 +1,242 @@
--[[
mattata v2.0 - AI Plugin
AI-powered chat using OpenAI or Anthropic API.
Must be enabled via AI_ENABLED=true in configuration.
]]
local plugin = {}
plugin.name = 'ai'
plugin.category = 'ai'
plugin.description = 'Chat with an AI assistant'
plugin.commands = { 'ai', 'ask' }
plugin.help = '/ai <prompt> - Send a prompt to the AI assistant and receive a response.'
local MAX_HISTORY = 10
local SYSTEM_PROMPT = 'You are a helpful assistant embedded in a Telegram bot called mattata. '
.. 'Be concise and direct in your responses. '
.. 'Use Telegram-compatible formatting (bold, italic, code) when helpful.'
-- Build a Redis key for conversation history
local function history_key(chat_id, user_id)
return string.format('ai:history:%s:%s', tostring(chat_id), tostring(user_id))
end
-- Retrieve recent conversation history from Redis
local function get_history(redis, chat_id, user_id)
local json = require('dkjson')
local key = history_key(chat_id, user_id)
local raw = redis.lrange(key, 0, MAX_HISTORY * 2 - 1)
local messages = {}
if raw and #raw > 0 then
for _, entry in ipairs(raw) do
local msg = json.decode(entry)
if msg then
table.insert(messages, msg)
end
end
end
return messages
end
-- Append a message to conversation history
local function push_history(redis, chat_id, user_id, role, content)
local json = require('dkjson')
local key = history_key(chat_id, user_id)
redis.rpush(key, json.encode({ role = role, content = content }))
-- Trim to keep only recent messages
redis.ltrim(key, -(MAX_HISTORY * 2), -1)
-- Auto-expire after 1 hour of inactivity
redis.expire(key, 3600)
end
-- Call OpenAI Chat Completions API
local function call_openai(api_key, model, messages)
- local https = require('ssl.https')
+ local http = require('src.core.http')
local json = require('dkjson')
- local ltn12 = require('ltn12')
local request_body = json.encode({
model = model,
messages = messages,
max_tokens = 1024
})
- local response_body = {}
- local res, code = https.request({
- url = 'https://api.openai.com/v1/chat/completions',
- method = 'POST',
- sink = ltn12.sink.table(response_body),
- source = ltn12.source.string(request_body),
- headers = {
- ['Authorization'] = 'Bearer ' .. api_key,
- ['Content-Type'] = 'application/json',
- ['Content-Length'] = tostring(#request_body)
- }
+ local body, code = http.post('https://api.openai.com/v1/chat/completions', request_body, 'application/json', {
+ ['Authorization'] = 'Bearer ' .. api_key
})
- if not res or code ~= 200 then
+ if code ~= 200 then
return nil, 'OpenAI API request failed (HTTP ' .. tostring(code) .. ').'
end
- local data = json.decode(table.concat(response_body))
+ local data = json.decode(body)
if not data or not data.choices or #data.choices == 0 then
return nil, 'No response from OpenAI.'
end
return data.choices[1].message and data.choices[1].message.content or nil
end
-- Call Anthropic Messages API
local function call_anthropic(api_key, model, messages)
- local https = require('ssl.https')
+ local http = require('src.core.http')
local json = require('dkjson')
- local ltn12 = require('ltn12')
-- Convert from OpenAI message format; extract system prompt
local system_text = nil
local api_messages = {}
for _, msg in ipairs(messages) do
if msg.role == 'system' then
system_text = msg.content
else
table.insert(api_messages, { role = msg.role, content = msg.content })
end
end
local request_body = json.encode({
model = model,
max_tokens = 1024,
system = system_text or SYSTEM_PROMPT,
messages = api_messages
})
- local response_body = {}
- local res, code = https.request({
- url = 'https://api.anthropic.com/v1/messages',
- method = 'POST',
- sink = ltn12.sink.table(response_body),
- source = ltn12.source.string(request_body),
- headers = {
- ['x-api-key'] = api_key,
- ['anthropic-version'] = '2023-06-01',
- ['Content-Type'] = 'application/json',
- ['Content-Length'] = tostring(#request_body)
- }
+ local body, code = http.post('https://api.anthropic.com/v1/messages', request_body, 'application/json', {
+ ['x-api-key'] = api_key,
+ ['anthropic-version'] = '2023-06-01'
})
- if not res or code ~= 200 then
+ if code ~= 200 then
return nil, 'Anthropic API request failed (HTTP ' .. tostring(code) .. ').'
end
- local data = json.decode(table.concat(response_body))
+ local data = json.decode(body)
if not data or not data.content or #data.content == 0 then
return nil, 'No response from Anthropic.'
end
return data.content[1].text
end
-- Main dispatch: pick provider and call
local function get_ai_response(ai_config, messages)
if ai_config.anthropic_key then
return call_anthropic(ai_config.anthropic_key, ai_config.anthropic_model, messages)
elseif ai_config.openai_key then
return call_openai(ai_config.openai_key, ai_config.openai_model, messages)
end
return nil, 'No AI API key has been configured.'
end
function plugin.on_message(api, message, ctx)
local ai_config = ctx.config.ai()
if not ai_config.enabled then
return api.send_message(message.chat.id, 'The AI feature is currently disabled.')
end
local input = message.args
-- If replying to a message, prepend the quoted text for context
if (not input or input == '') and message.reply and message.reply.text and message.reply.text ~= '' then
input = message.reply.text
end
if not input or input == '' then
- return api.send_message(message.chat.id, 'Please provide a prompt, e.g. <code>/ai What is the capital of France?</code>', 'html')
+ return api.send_message(message.chat.id, 'Please provide a prompt, e.g. <code>/ai What is the capital of France?</code>', { parse_mode = 'html' })
end
-- Send typing action while processing
api.send_chat_action(message.chat.id, 'typing')
-- Build message history
local history = get_history(ctx.redis, message.chat.id, message.from.id)
local messages = {
{ role = 'system', content = SYSTEM_PROMPT }
}
for _, msg in ipairs(history) do
table.insert(messages, msg)
end
table.insert(messages, { role = 'user', content = input })
local response, err = get_ai_response(ai_config, messages)
if not response then
return api.send_message(message.chat.id, err or 'Failed to get a response from the AI.')
end
-- Store conversation turn
push_history(ctx.redis, message.chat.id, message.from.id, 'user', input)
push_history(ctx.redis, message.chat.id, message.from.id, 'assistant', response)
-- Truncate if response exceeds Telegram's 4096 character limit
if #response > 4096 then
response = response:sub(1, 4090) .. '\n...'
end
- return api.send_message(message.chat.id, response, nil, true, false, message.message_id)
+ return api.send_message(message.chat.id, response, { link_preview_options = { is_disabled = true }, reply_parameters = { message_id = message.message_id } })
end
-- Respond to @mentions and DMs passively if AI is enabled
function plugin.on_new_message(api, message, ctx)
local ai_config = ctx.config.ai()
if not ai_config.enabled then
return
end
-- Skip if this was already handled as a command
if message.text and message.text:match('^[/!#]') then
return
end
local text = message.text or ''
local is_mention = false
local is_dm = message.chat and message.chat.type == 'private'
-- Check for @bot_username mentions in entities
if message.entities then
for _, entity in ipairs(message.entities) do
if entity.type == 'mention' then
local mention = text:sub(entity.offset + 1, entity.offset + entity.length)
if mention:lower() == '@' .. api.info.username:lower() then
is_mention = true
-- Strip the mention from the input
text = text:sub(1, entity.offset) .. text:sub(entity.offset + entity.length + 1)
text = text:match('^%s*(.-)%s*$') -- trim
break
end
end
end
end
if not is_mention and not is_dm then
return
end
if text == '' then
return
end
-- Send typing action
api.send_chat_action(message.chat.id, 'typing')
local history = get_history(ctx.redis, message.chat.id, message.from.id)
local messages = {
{ role = 'system', content = SYSTEM_PROMPT }
}
for _, msg in ipairs(history) do
table.insert(messages, msg)
end
table.insert(messages, { role = 'user', content = text })
local response, _ = get_ai_response(ai_config, messages)
if not response then
return
end
push_history(ctx.redis, message.chat.id, message.from.id, 'user', text)
push_history(ctx.redis, message.chat.id, message.from.id, 'assistant', response)
if #response > 4096 then
response = response:sub(1, 4090) .. '\n...'
end
- return api.send_message(message.chat.id, response, nil, true, false, message.message_id)
+ return api.send_message(message.chat.id, response, { link_preview_options = { is_disabled = true }, reply_parameters = { message_id = message.message_id } })
end
return plugin
diff --git a/src/plugins/fun/catfact.lua b/src/plugins/fun/catfact.lua
index 1321f93..b3ffa15 100644
--- a/src/plugins/fun/catfact.lua
+++ b/src/plugins/fun/catfact.lua
@@ -1,42 +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 https = require('ssl.https')
- local json = require('dkjson')
- local ltn12 = require('ltn12')
+ local http = require('src.core.http')
- local response_body = {}
- local res, code = https.request({
- url = 'https://catfact.ninja/fact',
- method = 'GET',
- headers = {
- ['Accept'] = 'application/json'
- },
- sink = ltn12.sink.table(response_body)
- })
+ local data, code = http.get_json('https://catfact.ninja/fact')
- if not res or code ~= 200 then
+ if not data then
return api.send_message(message.chat.id, 'Failed to fetch a cat fact. Try again later.')
end
-
- local body = table.concat(response_body)
- local data, _ = json.decode(body)
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, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/fun/dice.lua b/src/plugins/fun/dice.lua
index ed5d2d9..d675f01 100644
--- a/src/plugins/fun/dice.lua
+++ b/src/plugins/fun/dice.lua
@@ -1,39 +1,39 @@
--[[
mattata v2.0 - Dice Plugin
Roll dice using Telegram's native dice animations.
]]
local plugin = {}
plugin.name = 'dice'
plugin.category = 'fun'
plugin.description = 'Roll dice or play other Telegram dice games'
plugin.commands = { 'dice', 'roll' }
plugin.help = '/dice [type] - Roll a die. Types: dice (default), basketball, darts, football, bowling, slots.'
local EMOJI_MAP = {
['dice'] = '\xF0\x9F\x8E\xB2', -- U+1F3B2
['basketball'] = '\xF0\x9F\x8F\x80', -- U+1F3C0
['darts'] = '\xF0\x9F\x8E\xAF', -- U+1F3AF
['football'] = '\xE2\x9A\xBD', -- U+26BD
['bowling'] = '\xF0\x9F\x8E\xB3', -- U+1F3B3
['slots'] = '\xF0\x9F\x8E\xB0', -- U+1F3B0
}
function plugin.on_message(api, message, ctx)
local input = message.args and message.args:lower() or 'dice'
local emoji = EMOJI_MAP[input]
if not emoji then
local valid = {}
for k, _ in pairs(EMOJI_MAP) do
table.insert(valid, k)
end
table.sort(valid)
return api.send_message(
message.chat.id,
'Invalid dice type. Valid types: ' .. table.concat(valid, ', ')
)
end
- return api.send_dice(message.chat.id, emoji, false, message.message_id)
+ return api.send_dice(message.chat.id, { emoji = emoji, reply_parameters = { message_id = message.message_id } })
end
return plugin
diff --git a/src/plugins/fun/doge.lua b/src/plugins/fun/doge.lua
index 6fbbb85..415a039 100644
--- a/src/plugins/fun/doge.lua
+++ b/src/plugins/fun/doge.lua
@@ -1,63 +1,63 @@
--[[
mattata v2.0 - Doge Plugin
Generates random doge-speak from input words.
]]
local plugin = {}
plugin.name = 'doge'
plugin.category = 'fun'
plugin.description = 'Generate doge-speak from text'
plugin.commands = { 'doge' }
plugin.help = '/doge <text> - Generate doge-speak from the given words.'
local PREFIXES = {
'such', 'very', 'much', 'so', 'many', 'how', 'amaze', 'wow',
'excite', 'plz', 'concern', 'what', 'nice', 'great', 'most'
}
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 words for the doge to speak.')
end
-- Split input into words
local words = {}
for word in input:gmatch('%S+') do
table.insert(words, word:lower())
end
if #words == 0 then
return api.send_message(message.chat.id, 'wow. such empty. much nothing.')
end
math.randomseed(os.time() + os.clock() * 1000)
local lines = {}
-- Generate doge lines for each word (or up to 8)
local count = math.min(#words, 8)
local used_prefixes = {}
for i = 1, count do
local prefix
repeat
prefix = PREFIXES[math.random(#PREFIXES)]
until not used_prefixes[prefix] or i > #PREFIXES
used_prefixes[prefix] = true
-- Random indentation for the classic doge look
local padding = string.rep(' ', math.random(0, 12))
table.insert(lines, padding .. prefix .. ' ' .. words[i])
end
-- Always end with wow
local padding = string.rep(' ', math.random(0, 16))
table.insert(lines, padding .. 'wow')
local output = '<pre>' .. table.concat(lines, '\n') .. '</pre>'
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/fun/game.lua b/src/plugins/fun/game.lua
index 74f1003..b9bb926 100644
--- a/src/plugins/fun/game.lua
+++ b/src/plugins/fun/game.lua
@@ -1,208 +1,208 @@
--[[
mattata v2.0 - Game Plugin
Tic-tac-toe with inline keyboard buttons. Two players take turns.
Game state stored in Redis as JSON.
]]
local plugin = {}
plugin.name = 'game'
plugin.category = 'fun'
plugin.description = 'Play tic-tac-toe with another user'
plugin.commands = { 'game', 'tictactoe' }
plugin.help = '/game - Start a tic-tac-toe game. Another user clicks a cell to join as O.'
local json = require('dkjson')
local EMPTY = ' '
local X = 'X'
local O = 'O'
-- Symbols for display on buttons
local DISPLAY = {
[EMPTY] = '\xE2\xAC\x9C', -- white square
[X] = '\xE2\x9D\x8C', -- cross mark
[O] = '\xE2\xAD\x95', -- hollow circle
}
local function game_key(chat_id, message_id)
return 'ttt:' .. chat_id .. ':' .. message_id
end
local function new_board()
return {
EMPTY, EMPTY, EMPTY,
EMPTY, EMPTY, EMPTY,
EMPTY, EMPTY, EMPTY
}
end
local WIN_LINES = {
{1, 2, 3}, {4, 5, 6}, {7, 8, 9}, -- rows
{1, 4, 7}, {2, 5, 8}, {3, 6, 9}, -- columns
{1, 5, 9}, {3, 5, 7} -- diagonals
}
local function check_winner(board)
for _, line in ipairs(WIN_LINES) do
local a, b, c = board[line[1]], board[line[2]], board[line[3]]
if a ~= EMPTY and a == b and b == c then
return a
end
end
-- Check for draw
for _, cell in ipairs(board) do
if cell == EMPTY then
return nil -- game still in progress
end
end
return 'draw'
end
local function build_keyboard(api, board, game_over)
local keyboard = api.inline_keyboard()
for row = 0, 2 do
local r = api.row()
for col = 1, 3 do
local idx = row * 3 + col
local label = DISPLAY[board[idx]]
if game_over then
r:callback_data_button(label, 'game:noop')
else
r:callback_data_button(label, 'game:move:' .. idx)
end
end
keyboard:row(r)
end
return keyboard
end
local function format_status(game_state, winner)
local tools = require('telegram-bot-lua.tools')
local x_name = tools.escape_html(game_state.x_name or 'Player X')
local o_name = tools.escape_html(game_state.o_name or '???')
if winner == 'draw' then
return string.format(
'<b>Tic-Tac-Toe</b>\n%s %s vs %s %s\n\nIt\'s a draw!',
DISPLAY[X], x_name, DISPLAY[O], o_name
)
elseif winner == X then
return string.format(
'<b>Tic-Tac-Toe</b>\n%s %s vs %s %s\n\n%s %s wins!',
DISPLAY[X], x_name, DISPLAY[O], o_name, DISPLAY[X], x_name
)
elseif winner == O then
return string.format(
'<b>Tic-Tac-Toe</b>\n%s %s vs %s %s\n\n%s %s wins!',
DISPLAY[X], x_name, DISPLAY[O], o_name, DISPLAY[O], o_name
)
else
local turn_name = game_state.turn == X and x_name or o_name
local turn_symbol = DISPLAY[game_state.turn]
if not game_state.o_id then
return string.format(
'<b>Tic-Tac-Toe</b>\n%s %s vs %s ???\n\n%s is waiting for an opponent. Click a cell to join!',
DISPLAY[X], x_name, DISPLAY[O], x_name
)
end
return string.format(
'<b>Tic-Tac-Toe</b>\n%s %s vs %s %s\n\n%s %s\'s turn',
DISPLAY[X], x_name, DISPLAY[O], o_name, turn_symbol, turn_name
)
end
end
function plugin.on_message(api, message, ctx)
local board = new_board()
local game_state = {
board = board,
turn = X,
x_id = message.from.id,
x_name = message.from.first_name or 'Player X',
o_id = nil,
o_name = nil
}
local status = format_status(game_state, nil)
local keyboard = build_keyboard(api, board, false)
- local result = api.send_message(message.chat.id, status, 'html', true, false, nil, keyboard)
+ local result = api.send_message(message.chat.id, status, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
if result and result.result and result.result.message_id then
local key = game_key(message.chat.id, result.result.message_id)
ctx.redis.setex(key, 3600, json.encode(game_state))
end
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local data = callback_query.data
if data == 'noop' then
return api.answer_callback_query(callback_query.id)
end
local idx = tonumber(data:match('^move:(%d+)$'))
if not idx or idx < 1 or idx > 9 then
- return api.answer_callback_query(callback_query.id, 'Invalid move.')
+ return api.answer_callback_query(callback_query.id, { text = 'Invalid move.' })
end
local key = game_key(message.chat.id, message.message_id)
local raw = ctx.redis.get(key)
if not raw then
- return api.answer_callback_query(callback_query.id, 'This game has expired.')
+ return api.answer_callback_query(callback_query.id, { text = 'This game has expired.' })
end
local game_state, _ = json.decode(raw)
if not game_state then
- return api.answer_callback_query(callback_query.id, 'Failed to load game state.')
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to load game state.' })
end
local user_id = callback_query.from.id
local user_name = callback_query.from.first_name or 'Unknown'
-- If no opponent yet, the first person who clicks (that isn't X) becomes O
if not game_state.o_id then
if user_id == game_state.x_id then
- return api.answer_callback_query(callback_query.id, 'Waiting for an opponent to join. Another user must click a cell.')
+ return api.answer_callback_query(callback_query.id, { text = 'Waiting for an opponent to join. Another user must click a cell.' })
end
game_state.o_id = user_id
game_state.o_name = user_name
end
-- Check it's this user's turn
local expected_id = game_state.turn == X and game_state.x_id or game_state.o_id
if user_id ~= expected_id then
if user_id ~= game_state.x_id and user_id ~= game_state.o_id then
- return api.answer_callback_query(callback_query.id, 'You are not a player in this game.')
+ return api.answer_callback_query(callback_query.id, { text = 'You are not a player in this game.' })
end
- return api.answer_callback_query(callback_query.id, 'It\'s not your turn.')
+ return api.answer_callback_query(callback_query.id, { text = 'It\'s not your turn.' })
end
-- Check cell is empty
if game_state.board[idx] ~= EMPTY then
- return api.answer_callback_query(callback_query.id, 'That cell is already taken.')
+ return api.answer_callback_query(callback_query.id, { text = 'That cell is already taken.' })
end
-- Make the move
game_state.board[idx] = game_state.turn
local winner = check_winner(game_state.board)
if winner then
-- Game over
local status = format_status(game_state, winner)
local keyboard = build_keyboard(api, game_state.board, true)
ctx.redis.del(key)
api.answer_callback_query(callback_query.id)
- return api.edit_message_text(message.chat.id, message.message_id, status, 'html', true, keyboard)
+ return api.edit_message_text(message.chat.id, message.message_id, status, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
end
-- Switch turns
game_state.turn = game_state.turn == X and O or X
local status = format_status(game_state, nil)
local keyboard = build_keyboard(api, game_state.board, false)
ctx.redis.setex(key, 3600, json.encode(game_state))
api.answer_callback_query(callback_query.id)
- return api.edit_message_text(message.chat.id, message.message_id, status, 'html', true, keyboard)
+ return api.edit_message_text(message.chat.id, message.message_id, status, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
end
return plugin
diff --git a/src/plugins/fun/init.lua b/src/plugins/fun/init.lua
index 1090021..2e1d8dc 100644
--- a/src/plugins/fun/init.lua
+++ b/src/plugins/fun/init.lua
@@ -1,21 +1,23 @@
--[[
mattata v2.0 - Fun Plugin Category
]]
return {
plugins = {
'slap',
'dice',
'flip',
'mock',
'aesthetic',
'doge',
'copypasta',
'pun',
'fact',
'catfact',
'inspirobot',
'quote',
- 'game'
+ 'game',
+ 'poll',
+ 'reactions'
}
}
diff --git a/src/plugins/fun/inspirobot.lua b/src/plugins/fun/inspirobot.lua
index c56829a..536a794 100644
--- a/src/plugins/fun/inspirobot.lua
+++ b/src/plugins/fun/inspirobot.lua
@@ -1,36 +1,30 @@
--[[
mattata v2.0 - InspiroBot Plugin
Fetches a random AI-generated inspirational image from inspirobot.me.
]]
local plugin = {}
plugin.name = 'inspirobot'
plugin.category = 'fun'
plugin.description = 'Get a random AI-generated inspirational image'
plugin.commands = { 'inspirobot', 'ib' }
plugin.help = '/inspirobot - Get a random AI-generated inspirational poster from InspiroBot.'
function plugin.on_message(api, message, ctx)
- local https = require('ssl.https')
- local ltn12 = require('ltn12')
+ local http = require('src.core.http')
- local response_body = {}
- local res, code = https.request({
- url = 'https://inspirobot.me/api?generate=true',
- method = 'GET',
- sink = ltn12.sink.table(response_body)
- })
+ local body, code = http.get('https://inspirobot.me/api?generate=true')
- if not res or code ~= 200 then
+ if code ~= 200 then
return api.send_message(message.chat.id, 'Failed to fetch an inspirational image. Try again later.')
end
- local image_url = table.concat(response_body):gsub('%s+', '')
+ local image_url = body:gsub('%s+', '')
if not image_url or image_url == '' or not image_url:match('^https?://') then
return api.send_message(message.chat.id, 'Received an invalid response from InspiroBot. Try again later.')
end
- return api.send_photo(message.chat.id, image_url, nil, false, message.message_id)
+ return api.send_photo(message.chat.id, image_url, { reply_parameters = { message_id = message.message_id } })
end
return plugin
diff --git a/src/plugins/fun/poll.lua b/src/plugins/fun/poll.lua
new file mode 100644
index 0000000..96bf55a
--- /dev/null
+++ b/src/plugins/fun/poll.lua
@@ -0,0 +1,65 @@
+--[[
+ mattata v2.0 - Poll Plugin
+ Quick poll creation via /poll and /vote commands.
+]]
+
+local plugin = {}
+plugin.name = 'poll'
+plugin.category = 'fun'
+plugin.description = 'Create quick polls in groups'
+plugin.commands = { 'poll', 'vote' }
+plugin.help = '/poll <question> | <option 1> | <option 2> [| ...] - Create a non-anonymous poll.\n'
+ .. '/vote <question> | <option 1> | <option 2> [| ...] - Create an anonymous poll.'
+plugin.group_only = true
+
+function plugin.on_message(api, message, ctx)
+ if not message.args or message.args == '' then
+ return api.send_message(message.chat.id,
+ '<b>Usage:</b>\n'
+ .. '<code>/poll Question? | Option 1 | Option 2</code> - Non-anonymous poll\n'
+ .. '<code>/vote Question? | Option 1 | Option 2</code> - Anonymous poll\n\n'
+ .. 'Separate the question and options with <code>|</code>. You can have 2-10 options.',
+ { parse_mode = 'html' }
+ )
+ end
+
+ -- Split on | and trim whitespace
+ local parts = {}
+ for part in message.args:gmatch('[^|]+') do
+ local trimmed = part:match('^%s*(.-)%s*$')
+ if trimmed and trimmed ~= '' then
+ parts[#parts + 1] = trimmed
+ end
+ end
+
+ if #parts < 3 then
+ return api.send_message(message.chat.id, 'You need a question and at least 2 options, separated by |.')
+ end
+
+ local question = parts[1]
+ if #question > 300 then
+ return api.send_message(message.chat.id, 'The question must be 300 characters or fewer.')
+ end
+
+ local options = {}
+ for i = 2, #parts do
+ if #parts[i] > 100 then
+ return api.send_message(message.chat.id,
+ string.format('Option %d is too long. Each option must be 100 characters or fewer.', i - 1)
+ )
+ end
+ options[#options + 1] = { text = parts[i] }
+ end
+
+ if #options > 10 then
+ return api.send_message(message.chat.id, 'You can have a maximum of 10 options.')
+ end
+
+ local is_anonymous = message.command == 'vote'
+
+ return api.send_poll(message.chat.id, question, options, {
+ is_anonymous = is_anonymous
+ })
+end
+
+return plugin
diff --git a/src/plugins/fun/quote.lua b/src/plugins/fun/quote.lua
index a6d9a25..5e467d0 100644
--- a/src/plugins/fun/quote.lua
+++ b/src/plugins/fun/quote.lua
@@ -1,62 +1,62 @@
--[[
mattata v2.0 - Quote Plugin
Save and retrieve random quotes per chat. Stores in Redis set quotes:{chat_id}.
]]
local plugin = {}
plugin.name = 'quote'
plugin.category = 'fun'
plugin.description = 'Save and retrieve random quotes'
-plugin.commands = { 'quote', 'q', 'save' }
-plugin.help = '/save - Save the replied message as a quote.\n/quote - Retrieve a random saved quote from this chat.'
+plugin.commands = { 'quote', 'q', 'addquote' }
+plugin.help = '/addquote - Save the replied message as a quote.\n/quote - Retrieve a random saved quote from this chat.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local json = require('dkjson')
local redis = ctx.redis
local chat_id = message.chat.id
local key = 'quotes:' .. chat_id
- if message.command == 'save' then
+ if message.command == 'addquote' then
-- Save a quote from a reply
if not message.reply then
- return api.send_message(chat_id, 'Please use /save in reply to a message you want to save as a quote.')
+ return api.send_message(chat_id, 'Please use /addquote in reply to a message you want to save as a quote.')
end
local quote_text = message.reply.text
if not quote_text or quote_text == '' then
return api.send_message(chat_id, 'The replied message has no text to save.')
end
local author = message.reply.from and message.reply.from.first_name or 'Unknown'
local quote_data = json.encode({
text = quote_text,
author = author,
author_id = message.reply.from and message.reply.from.id,
saved_by = message.from.first_name,
saved_at = os.time()
})
redis.sadd(key, quote_data)
return api.send_message(chat_id, 'Quote saved successfully.')
end
-- Retrieve a random quote
local quotes = redis.smembers(key)
if not quotes or #quotes == 0 then
- return api.send_message(chat_id, 'No quotes saved in this chat yet. Use /save in reply to a message to save one.')
+ return api.send_message(chat_id, 'No quotes saved in this chat yet. Use /addquote in reply to a message to save one.')
end
math.randomseed(os.time() + os.clock() * 1000)
local raw = quotes[math.random(#quotes)]
local quote, _ = json.decode(raw)
if not quote then
return api.send_message(chat_id, 'Failed to read quote data.')
end
local output = string.format(
'\xE2\x80\x9C%s\xE2\x80\x9D\n\n\xE2\x80\x94 %s',
tools.escape_html(quote.text),
tools.escape_html(quote.author)
)
- return api.send_message(chat_id, output, 'html')
+ return api.send_message(chat_id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/fun/reactions.lua b/src/plugins/fun/reactions.lua
new file mode 100644
index 0000000..ef47cba
--- /dev/null
+++ b/src/plugins/fun/reactions.lua
@@ -0,0 +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' })
+ 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' })
+ 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/fun/slap.lua b/src/plugins/fun/slap.lua
index 124d6da..c9f9b4c 100644
--- a/src/plugins/fun/slap.lua
+++ b/src/plugins/fun/slap.lua
@@ -1,37 +1,37 @@
--[[
mattata v2.0 - Slap Plugin
Slap users with random messages using templates.
]]
local plugin = {}
plugin.name = 'slap'
plugin.category = 'fun'
plugin.description = 'Slap a user with a random object'
plugin.commands = { 'slap' }
plugin.help = '/slap [user] - Slap a user with a random message. Use in reply to target the replied user.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local slaps = require('src.data.slaps')
local sender_name = message.from.first_name or 'Unknown'
local target_name
if message.reply and message.reply.from then
target_name = message.reply.from.first_name or 'Unknown'
elseif message.args and message.args ~= '' then
target_name = message.args
else
-- Slap yourself if no target
target_name = sender_name
sender_name = api.info.first_name
end
math.randomseed(os.time() + os.clock() * 1000)
local template = slaps[math.random(#slaps)]
local output = template:gsub('{ME}', tools.escape_html(sender_name)):gsub('{THEM}', tools.escape_html(target_name))
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/media/cats.lua b/src/plugins/media/cats.lua
index 2fed34a..cb3edc4 100644
--- a/src/plugins/media/cats.lua
+++ b/src/plugins/media/cats.lua
@@ -1,50 +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 https = require('ssl.https')
- local json = require('dkjson')
- local ltn12 = require('ltn12')
+ local http = require('src.core.http')
- local response_body = {}
- local res, code = https.request({
- url = 'https://api.thecatapi.com/v1/images/search',
- method = 'GET',
- sink = ltn12.sink.table(response_body),
- headers = {
- ['Accept'] = 'application/json'
- }
- })
+ local data, code = http.get_json('https://api.thecatapi.com/v1/images/search')
- if not res or code ~= 200 then
+ if not data then
return api.send_message(message.chat.id, 'Failed to fetch a cat image. Please try again later.')
end
-
- local body = table.concat(response_body)
- local data, _ = json.decode(body)
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 930e5a4..54e8559 100644
--- a/src/plugins/media/gif.lua
+++ b/src/plugins/media/gif.lua
@@ -1,66 +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 https = require('ssl.https')
- local json = require('dkjson')
+ local http = require('src.core.http')
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')
+ 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 response_body = {}
- local res, code = https.request({
- url = api_url,
- method = 'GET',
- sink = ltn12.sink.table(response_body),
- headers = {
- ['Accept'] = 'application/json'
- }
- })
+ local data, code = http.get_json(api_url)
- if not res or code ~= 200 then
+ if not data 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
diff --git a/src/plugins/media/init.lua b/src/plugins/media/init.lua
index a471ff3..e607d33 100644
--- a/src/plugins/media/init.lua
+++ b/src/plugins/media/init.lua
@@ -1,14 +1,15 @@
--[[
mattata v2.0 - Media Plugin Category
]]
return {
plugins = {
'cats',
'gif',
'youtube',
'spotify',
'itunes',
- 'sticker'
+ 'sticker',
+ 'qr'
}
}
diff --git a/src/plugins/media/itunes.lua b/src/plugins/media/itunes.lua
index 9045c17..0df1b99 100644
--- a/src/plugins/media/itunes.lua
+++ b/src/plugins/media/itunes.lua
@@ -1,93 +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 https = require('ssl.https')
- local json = require('dkjson')
+ local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
- local ltn12 = require('ltn12')
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>.', 'html')
+ 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 response_body = {}
- local res, code = https.request({
- url = api_url,
- method = 'GET',
- sink = ltn12.sink.table(response_body),
- headers = {
- ['Accept'] = 'application/json'
- }
- })
+ local data, code = http.get_json(api_url)
- if not res or code ~= 200 then
+ if not data then
return api.send_message(message.chat.id, 'Failed to search iTunes. 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 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, output, 'html')
+ 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, 'html', true)
+ 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/qr.lua b/src/plugins/media/qr.lua
new file mode 100644
index 0000000..3e40ecf
--- /dev/null
+++ b/src/plugins/media/qr.lua
@@ -0,0 +1,52 @@
+--[[
+ mattata v2.0 - QR Code Plugin
+ Generates QR codes from text or URLs using the goqr.me API.
+]]
+
+local plugin = {}
+plugin.name = 'qr'
+plugin.category = 'media'
+plugin.description = 'Generate a QR code from text or a URL'
+plugin.commands = { 'qr', 'qrcode' }
+plugin.help = '/qr <text or URL> - Generate a QR code. Also works as a reply to a message.'
+
+local MAX_INPUT = 2000
+
+local function url_encode(str)
+ return str:gsub('([^%w%-%.%_%~])', function(c)
+ return string.format('%%%02X', string.byte(c))
+ end)
+end
+
+function plugin.on_message(api, message, ctx)
+ local input = message.args
+
+ -- If no args, try to use the replied message text
+ if (not input or input == '') and message.reply then
+ input = message.reply.text or message.reply.caption
+ end
+
+ if not input or input == '' then
+ return api.send_message(
+ message.chat.id,
+ 'Please provide text or a URL to encode.\nUsage: <code>/qr hello world</code>\nOr reply to a message with <code>/qr</code>',
+ { parse_mode = 'html' }
+ )
+ end
+
+ if #input > MAX_INPUT then
+ return api.send_message(
+ message.chat.id,
+ string.format('Input is too long (%d characters). Maximum is %d.', #input, MAX_INPUT)
+ )
+ end
+
+ local qr_url = 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=' .. url_encode(input)
+ local result = api.send_photo(message.chat.id, qr_url)
+ if not result or not result.result then
+ return api.send_message(message.chat.id, 'Failed to generate QR code. Please try again.')
+ end
+ return result
+end
+
+return plugin
diff --git a/src/plugins/media/spotify.lua b/src/plugins/media/spotify.lua
index be0e7c9..45df4cc 100644
--- a/src/plugins/media/spotify.lua
+++ b/src/plugins/media/spotify.lua
@@ -1,153 +1,128 @@
--[[
mattata v2.0 - Spotify Plugin
Searches Spotify for tracks using the client credentials flow.
]]
local plugin = {}
plugin.name = 'spotify'
plugin.category = 'media'
plugin.description = 'Search Spotify for tracks'
plugin.commands = { 'spotify' }
plugin.help = '/spotify <query> - Search Spotify for a track and return song info with a link.'
-- Cache the access token in-memory to avoid re-authenticating on every request
local cached_token = nil
local token_expires = 0
local function get_access_token(config)
- local https = require('ssl.https')
+ local http = require('src.core.http')
local json = require('dkjson')
- local ltn12 = require('ltn12')
local mime = require('mime')
-- Return cached token if still valid
if cached_token and os.time() < token_expires then
return cached_token
end
local client_id = config.get('SPOTIFY_CLIENT_ID')
local client_secret = config.get('SPOTIFY_CLIENT_SECRET')
if not client_id or not client_secret then
return nil, 'Spotify credentials have not been configured.'
end
local credentials = mime.b64(client_id .. ':' .. client_secret)
local request_body = 'grant_type=client_credentials'
- local response_body = {}
- local res, code = https.request({
- url = 'https://accounts.spotify.com/api/token',
- method = 'POST',
- sink = ltn12.sink.table(response_body),
- source = ltn12.source.string(request_body),
- headers = {
- ['Authorization'] = 'Basic ' .. credentials,
- ['Content-Type'] = 'application/x-www-form-urlencoded',
- ['Content-Length'] = tostring(#request_body)
- }
+ local body, code = http.post('https://accounts.spotify.com/api/token', request_body, 'application/x-www-form-urlencoded', {
+ ['Authorization'] = 'Basic ' .. credentials
})
- if not res or code ~= 200 then
+ if code ~= 200 then
return nil, 'Failed to authenticate with Spotify.'
end
- local data = json.decode(table.concat(response_body))
+ local data = json.decode(body)
if not data or not data.access_token then
return nil, 'Failed to parse Spotify auth response.'
end
cached_token = data.access_token
- -- Expire 60 seconds early to be safe
token_expires = os.time() + (data.expires_in or 3600) - 60
return cached_token
end
function plugin.on_message(api, message, ctx)
- local https = require('ssl.https')
+ local http = require('src.core.http')
local json = require('dkjson')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
- local ltn12 = require('ltn12')
if not message.args or message.args == '' then
- return api.send_message(message.chat.id, 'Please specify a search query, e.g. <code>/spotify bohemian rhapsody</code>.', 'html')
+ return api.send_message(message.chat.id, 'Please specify a search query, e.g. <code>/spotify bohemian rhapsody</code>.', { parse_mode = 'html' })
end
local token, err = get_access_token(ctx.config)
if not token then
return api.send_message(message.chat.id, err or 'Failed to authenticate with Spotify.')
end
local query = url.escape(message.args)
local search_url = string.format(
'https://api.spotify.com/v1/search?q=%s&type=track&limit=1',
query
)
- local response_body = {}
- local res, code = https.request({
- url = search_url,
- method = 'GET',
- sink = ltn12.sink.table(response_body),
- headers = {
- ['Authorization'] = 'Bearer ' .. token,
- ['Accept'] = 'application/json'
- }
+ local body, code = http.get(search_url, {
+ ['Authorization'] = 'Bearer ' .. token,
+ ['Accept'] = 'application/json'
})
if code == 401 then
-- Token expired, clear cache and retry once
cached_token = nil
token_expires = 0
token, err = get_access_token(ctx.config)
if not token then
return api.send_message(message.chat.id, err or 'Failed to re-authenticate with Spotify.')
end
- response_body = {}
- res, code = https.request({
- url = search_url,
- method = 'GET',
- sink = ltn12.sink.table(response_body),
- headers = {
- ['Authorization'] = 'Bearer ' .. token,
- ['Accept'] = 'application/json'
- }
+ body, code = http.get(search_url, {
+ ['Authorization'] = 'Bearer ' .. token,
+ ['Accept'] = 'application/json'
})
end
- if not res or code ~= 200 then
+ if code ~= 200 then
return api.send_message(message.chat.id, 'Failed to search Spotify. Please try again later.')
end
- local body = table.concat(response_body)
local data = json.decode(body)
if not data or not data.tracks or not data.tracks.items or #data.tracks.items == 0 then
return api.send_message(message.chat.id, 'No tracks found for that query.')
end
local track = data.tracks.items[1]
local track_name = track.name or 'Unknown'
local track_url = track.external_urls and track.external_urls.spotify or ''
local album_name = track.album and track.album.name or 'Unknown'
-- Build artist list
local artists = {}
if track.artists then
for _, artist in ipairs(track.artists) do
table.insert(artists, artist.name or 'Unknown')
end
end
local artist_str = #artists > 0 and table.concat(artists, ', ') or 'Unknown'
local output = string.format(
'<b>%s</b>\nArtist: %s\nAlbum: %s\n<a href="%s">Listen on Spotify</a>',
tools.escape_html(track_name),
tools.escape_html(artist_str),
tools.escape_html(album_name),
tools.escape_html(track_url)
)
- return api.send_message(message.chat.id, output, 'html', true)
+ 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/sticker.lua b/src/plugins/media/sticker.lua
index e452f96..9b74d3b 100644
--- a/src/plugins/media/sticker.lua
+++ b/src/plugins/media/sticker.lua
@@ -1,140 +1,140 @@
--[[
mattata v2.0 - Sticker Plugin
Manage stickers: get file IDs, add to or remove from the bot's sticker pack.
]]
local plugin = {}
plugin.name = 'sticker'
plugin.category = 'media'
plugin.description = 'Sticker management utilities'
plugin.commands = { 'sticker', 'addsticker', 'delsticker' }
plugin.help = table.concat({
'/sticker - Reply to a sticker to get its file ID.',
'/addsticker <emoji> - Reply to a sticker or image to add it to the bot\'s sticker pack.',
'/delsticker - Reply to a sticker to remove it from the bot\'s sticker pack.'
}, '\n')
local function get_sticker_set_name(bot_username)
return 'pack_by_' .. bot_username
end
local function handle_sticker(api, message)
if not message.reply or not message.reply.sticker then
return api.send_message(message.chat.id, 'Please reply to a sticker to get its file ID.')
end
local sticker = message.reply.sticker
local lines = {
'<b>Sticker Info</b>',
'File ID: <code>' .. sticker.file_id .. '</code>',
'Unique ID: <code>' .. sticker.file_unique_id .. '</code>',
'Emoji: ' .. (sticker.emoji or 'N/A'),
'Set: ' .. (sticker.set_name or 'N/A'),
'Animated: ' .. (sticker.is_animated and 'Yes' or 'No'),
'Video: ' .. (sticker.is_video and 'Yes' or 'No'),
string.format('Size: %dx%d', sticker.width or 0, sticker.height or 0)
}
- return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
local function handle_addsticker(api, message)
if not message.reply then
- return api.send_message(message.chat.id, 'Please reply to a sticker or image with an emoji, e.g. <code>/addsticker [emoji]</code>.', 'html')
+ return api.send_message(message.chat.id, 'Please reply to a sticker or image with an emoji, e.g. <code>/addsticker [emoji]</code>.', { parse_mode = 'html' })
end
local emoji = message.args and message.args:match('^(%S+)') or nil
if not emoji then
-- Default emoji if none provided
emoji = message.reply.sticker and message.reply.sticker.emoji or '\xF0\x9F\x98\x80'
end
local bot_username = api.info.username
local set_name = get_sticker_set_name(bot_username)
local user_id = message.from.id
local sticker_input
if message.reply.sticker then
-- Use the sticker file directly
sticker_input = message.reply.sticker.file_id
elseif message.reply.photo then
-- Use the largest photo size
local photos = message.reply.photo
sticker_input = photos[#photos].file_id
elseif message.reply.document and message.reply.document.mime_type and message.reply.document.mime_type:match('^image/') then
sticker_input = message.reply.document.file_id
else
return api.send_message(message.chat.id, 'Please reply to a sticker or image.')
end
-- Build the sticker input for the API
local sticker_data = {
sticker = sticker_input,
emoji_list = { emoji },
format = 'static'
}
-- Check if the sticker from the reply is animated/video and set format accordingly
if message.reply.sticker then
if message.reply.sticker.is_animated then
sticker_data.format = 'animated'
elseif message.reply.sticker.is_video then
sticker_data.format = 'video'
end
end
-- Try to add to existing set first
local success = api.add_sticker_to_set(user_id, set_name, sticker_data)
if success and success.result then
return api.send_message(message.chat.id, string.format(
'Sticker added to <a href="https://t.me/addstickers/%s">the pack</a>.',
set_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
-- Set might not exist yet, try to create it
local title = api.info.first_name .. '\'s Pack'
local create_result = api.create_new_sticker_set(user_id, set_name, title, { sticker_data })
if create_result and create_result.result then
return api.send_message(message.chat.id, string.format(
'Sticker pack created! <a href="https://t.me/addstickers/%s">View pack</a>.',
set_name
- ), 'html')
+ ), { parse_mode = 'html' })
end
return api.send_message(message.chat.id, 'Failed to add the sticker. Make sure you have started a private chat with me first.')
end
local function handle_delsticker(api, message)
if not message.reply or not message.reply.sticker then
return api.send_message(message.chat.id, 'Please reply to a sticker to remove it from its pack.')
end
local sticker = message.reply.sticker
local bot_username = api.info.username
local set_name = get_sticker_set_name(bot_username)
-- Only allow deleting from the bot's own pack
if sticker.set_name ~= set_name then
return api.send_message(message.chat.id, 'That sticker is not from the bot\'s sticker pack.')
end
local success = api.delete_sticker_from_set(sticker.file_id)
if success and success.result then
return api.send_message(message.chat.id, 'Sticker removed from the pack.')
end
return api.send_message(message.chat.id, 'Failed to remove the sticker.')
end
function plugin.on_message(api, message, ctx)
if message.command == 'sticker' then
return handle_sticker(api, message)
elseif message.command == 'addsticker' then
return handle_addsticker(api, message)
elseif message.command == 'delsticker' then
return handle_delsticker(api, message)
end
end
return plugin
diff --git a/src/plugins/media/youtube.lua b/src/plugins/media/youtube.lua
index 4f31c0e..d7116c1 100644
--- a/src/plugins/media/youtube.lua
+++ b/src/plugins/media/youtube.lua
@@ -1,105 +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 https = require('ssl.https')
- local json = require('dkjson')
+ local http = require('src.core.http')
local url = require('socket.url')
local tools = require('telegram-bot-lua.tools')
- local ltn12 = require('ltn12')
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>.', 'html')
+ 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 response_body = {}
- local res, code = https.request({
- url = search_url,
- method = 'GET',
- sink = ltn12.sink.table(response_body),
- headers = {
- ['Accept'] = 'application/json'
- }
- })
+ local data, code = http.get_json(search_url)
- if not res or code ~= 200 then
+ if not data then
return api.send_message(message.chat.id, 'Failed to search YouTube. Please try again later.')
end
-
- local body = table.concat(response_body)
- local data, _ = json.decode(body)
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_body = {}
- local stats_res, stats_code = https.request({
- url = stats_url,
- method = 'GET',
- sink = ltn12.sink.table(stats_body),
- headers = {
- ['Accept'] = 'application/json'
- }
- })
+ local stats_data, stats_code = http.get_json(stats_url)
local views = 'N/A'
- if stats_res and stats_code == 200 then
- local stats_data = json.decode(table.concat(stats_body))
+ 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, 'html', true)
+ 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/about.lua b/src/plugins/utility/about.lua
index 2fec74e..c1c1dd0 100644
--- a/src/plugins/utility/about.lua
+++ b/src/plugins/utility/about.lua
@@ -1,22 +1,24 @@
--[[
mattata v2.0 - About Plugin
]]
local plugin = {}
plugin.name = 'about'
plugin.category = 'utility'
plugin.description = 'View information about the bot'
plugin.commands = { 'about' }
plugin.help = '/about - View information about the bot.'
plugin.permanent = true
function plugin.on_message(api, message, ctx)
local config = require('src.core.config')
+ local owner_id = config.get_list('BOT_ADMINS')[1] or '221714512'
+ local github_url = config.get('GITHUB_URL', 'https://github.com/wrxck/mattata')
local output = string.format(
- 'Created by <a href="tg://user?id=221714512">Matt</a>. Powered by <code>mattata v%s</code>. Source code available <a href="https://github.com/wrxck/mattata">on GitHub</a>.',
- config.VERSION
+ 'Created by <a href="tg://user?id=%s">Matt</a>. Powered by <code>mattata v%s</code>. Source code available <a href="%s">on GitHub</a>.',
+ owner_id, config.VERSION, github_url
)
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/afk.lua b/src/plugins/utility/afk.lua
index 100f0fe..7428fc5 100644
--- a/src/plugins/utility/afk.lua
+++ b/src/plugins/utility/afk.lua
@@ -1,149 +1,149 @@
--[[
mattata v2.0 - AFK Plugin
Tracks AFK status for users. Notifies when an AFK user is mentioned.
Automatically marks users as returned when they send a message.
Uses ctx.session for AFK state management (backed by Redis).
]]
local plugin = {}
plugin.name = 'afk'
plugin.category = 'utility'
plugin.description = 'Set and track AFK status'
plugin.commands = { 'afk' }
plugin.help = '/afk [reason] - Mark yourself as AFK. Send any message to return.'
local tools = require('telegram-bot-lua.tools')
-- Format a time difference into a human-readable string
local function format_time_ago(seconds)
if seconds < 60 then
return 'just now'
elseif seconds < 3600 then
local mins = math.floor(seconds / 60)
return mins .. ' minute' .. (mins == 1 and '' or 's') .. ' ago'
elseif seconds < 86400 then
local hours = math.floor(seconds / 3600)
local mins = math.floor((seconds % 3600) / 60)
local result = hours .. ' hour' .. (hours == 1 and '' or 's')
if mins > 0 then
result = result .. ', ' .. mins .. ' min'
end
return result .. ' ago'
else
local days = math.floor(seconds / 86400)
local hours = math.floor((seconds % 86400) / 3600)
local result = days .. ' day' .. (days == 1 and '' or 's')
if hours > 0 then
result = result .. ', ' .. hours .. 'h'
end
return result .. ' ago'
end
end
-- Check if a user is mentioned in the message (by @username or text_mention entity)
local function get_mentioned_user_ids(message, redis)
local mentioned = {}
if not message.entities then
return mentioned
end
for _, entity in ipairs(message.entities) do
if entity.type == 'mention' and message.text then
-- Extract @username
local username = message.text:sub(entity.offset + 1, entity.offset + entity.length)
username = username:gsub('^@', ''):lower()
-- Look up user ID from username cache
local user_id = redis.get('username:' .. username)
if user_id then
table.insert(mentioned, tonumber(user_id))
end
elseif entity.type == 'text_mention' and entity.user then
table.insert(mentioned, entity.user.id)
end
end
return mentioned
end
-- /afk command handler
function plugin.on_message(api, message, ctx)
local note = message.args
if note and note == '' then
note = nil
end
ctx.session.set_afk(message.from.id, note)
local output
if note then
output = string.format(
'<b>%s</b> is now AFK: <i>%s</i>',
tools.escape_html(message.from.first_name),
tools.escape_html(note)
)
else
output = string.format(
'<b>%s</b> is now AFK.',
tools.escape_html(message.from.first_name)
)
end
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
-- Passive handler: runs on every message (not just commands)
function plugin.on_new_message(api, message, ctx)
if not message.from then return end
local session = ctx.session
local redis = ctx.redis
-- Check if the sender was AFK and auto-return them
-- Skip if they just sent the /afk command itself
if message.command ~= 'afk' then
local afk_data = session.get_afk(message.from.id)
if afk_data then
session.clear_afk(message.from.id)
local elapsed = os.time() - (afk_data.since or os.time())
local output = string.format(
'<b>%s</b> is no longer AFK (was away for %s).',
tools.escape_html(message.from.first_name),
format_time_ago(elapsed)
)
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
end
-- Check if any mentioned user is AFK
local mentioned_ids = get_mentioned_user_ids(message, redis)
for _, user_id in ipairs(mentioned_ids) do
-- Don't notify about yourself
if user_id ~= message.from.id then
local afk_data = session.get_afk(user_id)
if afk_data then
-- Rate-limit: only notify once per AFK user per chat per conversation
local replied_key = string.format('afk:%d:replied:%d:%d', user_id, message.chat.id, message.from.id)
local already_replied = redis.get(replied_key)
if not already_replied then
redis.setex(replied_key, 300, '1') -- 5 minute cooldown
local elapsed = os.time() - (afk_data.since or os.time())
local output
if afk_data.note then
output = string.format(
'That user is currently AFK (%s): <i>%s</i>',
format_time_ago(elapsed),
tools.escape_html(afk_data.note)
)
else
output = string.format(
'That user is currently AFK (%s).',
format_time_ago(elapsed)
)
end
- api.send_message(message.chat.id, output, 'html')
+ api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
end
end
end
end
return plugin
diff --git a/src/plugins/utility/base64.lua b/src/plugins/utility/base64.lua
index 302e96f..7cfa0a6 100644
--- a/src/plugins/utility/base64.lua
+++ b/src/plugins/utility/base64.lua
@@ -1,55 +1,55 @@
--[[
mattata v2.0 - Base64 Plugin
Encode and decode base64 strings.
]]
local plugin = {}
plugin.name = 'base64'
plugin.category = 'utility'
plugin.description = 'Encode or decode base64 strings'
plugin.commands = { 'base64', 'b64', 'dbase64', 'db64' }
plugin.help = '/base64 <text> - Encode text to base64.\n/dbase64 <text> - Decode base64 to text.'
local mime = require('mime')
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local input = message.args
-- Also support replying to a message
if (not input or input == '') and message.reply and message.reply.text then
input = message.reply.text
end
if not input or input == '' then
return api.send_message(message.chat.id, 'Please provide text to encode or decode.')
end
local is_decode = (message.command == 'dbase64' or message.command == 'db64')
if is_decode then
-- Decode base64
local ok, decoded = pcall(mime.unb64, input)
if not ok or not decoded then
return api.send_message(message.chat.id, 'Invalid base64 input. Please check the string and try again.')
end
return api.send_message(
message.chat.id,
string.format('<b>Decoded:</b>\n<code>%s</code>', tools.escape_html(decoded)),
- 'html'
+ { parse_mode = 'html' }
)
else
-- Encode to base64
local encoded = mime.b64(input)
if not encoded then
return api.send_message(message.chat.id, 'Failed to encode that text.')
end
return api.send_message(
message.chat.id,
string.format('<b>Encoded:</b>\n<code>%s</code>', tools.escape_html(encoded)),
- 'html'
+ { parse_mode = 'html' }
)
end
end
return plugin
diff --git a/src/plugins/utility/bookmark.lua b/src/plugins/utility/bookmark.lua
new file mode 100644
index 0000000..74a2986
--- /dev/null
+++ b/src/plugins/utility/bookmark.lua
@@ -0,0 +1,69 @@
+--[[
+ mattata v2.0 - Bookmark Plugin
+ Reply to any message with /bookmark to save it to your DMs.
+]]
+
+local plugin = {}
+plugin.name = 'bookmark'
+plugin.category = 'utility'
+plugin.description = 'Bookmark messages by saving them to your DMs'
+plugin.commands = { 'bookmark', 'bm' }
+plugin.help = '/bookmark - Reply to a message to save it to your DMs.'
+plugin.group_only = true
+
+local tools = require('telegram-bot-lua.tools')
+
+function plugin.on_message(api, message, ctx)
+ if not message.reply then
+ return api.send_message(
+ message.chat.id,
+ 'Please reply to a message you want to bookmark.',
+ { parse_mode = 'html' }
+ )
+ end
+
+ -- Build message link for supergroups
+ local link_id = tostring(message.chat.id):gsub('^%-100', '')
+ local message_link = string.format('https://t.me/c/%s/%s', link_id, tostring(message.reply.message_id))
+
+ -- Build a preview of the bookmarked content
+ local preview = ''
+ local reply_text = message.reply.text or message.reply.caption
+ if reply_text and reply_text ~= '' then
+ if #reply_text > 200 then
+ preview = reply_text:sub(1, 197) .. '...'
+ else
+ preview = reply_text
+ end
+ else
+ preview = '(media or non-text message)'
+ end
+
+ local chat_title = tools.escape_html(message.chat.title or 'Unknown chat')
+ local bookmark_text = string.format(
+ 'Bookmark from <b>%s</b>\n\n%s\n\n<a href="%s">Go to message</a>',
+ chat_title,
+ tools.escape_html(preview),
+ message_link
+ )
+
+ -- Try to send the bookmark as a DM
+ local result = api.send_message(message.from.id, bookmark_text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true }
+ })
+
+ if not result or not result.result then
+ return api.send_message(
+ message.chat.id,
+ 'I couldn\'t send you a DM. Please start a private chat with me first.'
+ )
+ end
+
+ -- Track bookmark count per user
+ ctx.redis.incr('bookmarks:' .. message.from.id)
+
+ return api.send_message(message.chat.id, 'Bookmark saved! Check your DMs.')
+end
+
+return plugin
diff --git a/src/plugins/utility/calc.lua b/src/plugins/utility/calc.lua
index f38ed30..426bcac 100644
--- a/src/plugins/utility/calc.lua
+++ b/src/plugins/utility/calc.lua
@@ -1,43 +1,43 @@
--[[
mattata v2.0 - Calculator Plugin
Evaluates mathematical expressions using the mathjs.org API.
]]
local plugin = {}
plugin.name = 'calc'
plugin.category = 'utility'
plugin.description = 'Evaluate mathematical expressions'
plugin.commands = { 'calc', 'calculate', 'calculator' }
plugin.help = '/calc <expression> - Evaluate a mathematical expression.'
function plugin.on_message(api, message, ctx)
- local https = require('ssl.https')
+ 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 an expression to evaluate. Example: /calc 2+2*5')
end
local encoded = url.escape(input)
local api_url = 'https://api.mathjs.org/v4/?expr=' .. encoded
- local body, status = https.request(api_url)
+ local body, status = http.get(api_url)
if not body or status ~= 200 then
return api.send_message(message.chat.id, 'Failed to evaluate that expression. Please check the syntax and try again.')
end
-- mathjs returns the result as plain text
local result = body:match('^%s*(.-)%s*$')
if not result or result == '' then
return api.send_message(message.chat.id, 'No result returned for that expression.')
end
return api.send_message(
message.chat.id,
string.format('<b>Expression:</b> <code>%s</code>\n<b>Result:</b> <code>%s</code>', tools.escape_html(input), tools.escape_html(result)),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/utility/commandstats.lua b/src/plugins/utility/commandstats.lua
index 16cc7d7..06bbc44 100644
--- a/src/plugins/utility/commandstats.lua
+++ b/src/plugins/utility/commandstats.lua
@@ -1,51 +1,51 @@
--[[
mattata v2.0 - Command Stats Plugin
Displays command usage statistics for the current chat.
]]
local plugin = {}
plugin.name = 'commandstats'
plugin.category = 'utility'
plugin.description = 'View command usage statistics for this chat'
plugin.commands = { 'commandstats', 'cstats' }
plugin.help = '/commandstats - View top 10 most used commands in this chat.\n/cstats reset - Reset command statistics (admin only).'
plugin.group_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local input = message.args
-- Handle reset
if input and input:lower() == 'reset' then
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, 'You need to be an admin to reset command statistics.')
end
ctx.db.call('sp_reset_command_stats', { message.chat.id })
return api.send_message(message.chat.id, 'Command statistics have been reset for this chat.')
end
-- Query top 10 commands by usage
local result = ctx.db.call('sp_get_top_commands', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No command statistics available for this chat yet.')
end
local lines = { '<b>Command Usage Statistics</b>', '' }
local total_usage = 0
for i, row in ipairs(result) do
local count = tonumber(row.total) or 0
total_usage = total_usage + count
table.insert(lines, string.format(
'%d. /%s - <code>%d</code> uses',
i, tools.escape_html(row.command), count
))
end
table.insert(lines, '')
table.insert(lines, string.format('<i>Total (top 10): %d command uses</i>', total_usage))
- return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/currency.lua b/src/plugins/utility/currency.lua
index b91359a..62beccf 100644
--- a/src/plugins/utility/currency.lua
+++ b/src/plugins/utility/currency.lua
@@ -1,147 +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 https = require('ssl.https')
-local json = require('dkjson')
-local ltn12 = require('ltn12')
+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 body = {}
- local _, code = https.request({
- url = request_url,
- sink = ltn12.sink.table(body)
- })
- if code ~= 200 then
- return nil, 'Currency conversion request failed. Check that the currency codes are valid.'
- end
- local data = json.decode(table.concat(body))
+ local data, code = http.get_json(request_url)
if not data then
- return nil, 'Failed to parse conversion response.'
+ 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>',
- 'html'
+ { 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>',
- 'html'
+ { 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)),
- 'html'
+ { 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, 'html')
+ 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 dbff37a..e7fc520 100644
--- a/src/plugins/utility/github.lua
+++ b/src/plugins/utility/github.lua
@@ -1,82 +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 https = require('ssl.https')
- local json = require('dkjson')
+ 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 body, status = https.request({
- url = api_url,
- headers = {
- ['User-Agent'] = 'mattata-bot',
- ['Accept'] = 'application/vnd.github.v3+json'
- }
+ local data, code = http.get_json(api_url, {
+ ['Accept'] = 'application/vnd.github.v3+json'
})
- if not body or status ~= 200 then
+ if not data then
return api.send_message(message.chat.id, 'Repository not found or GitHub API is unavailable.')
end
-
- local data = json.decode(body)
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'), 'html', true, false, nil, keyboard)
+ 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/growth.lua b/src/plugins/utility/growth.lua
new file mode 100644
index 0000000..cf7dcf0
--- /dev/null
+++ b/src/plugins/utility/growth.lua
@@ -0,0 +1,114 @@
+--[[
+ mattata v2.0 - Growth Plugin
+ Tracks member join/leave events and shows chat growth statistics over time.
+ Uses Redis daily counters with 48h TTL for automatic cleanup.
+]]
+
+local plugin = {}
+plugin.name = 'growth'
+plugin.category = 'utility'
+plugin.description = 'Chat growth statistics over time'
+plugin.commands = { 'growth', 'chatgrowth' }
+plugin.help = '/growth - Show chat member join/leave statistics for the last 7 days.'
+plugin.group_only = true
+
+local tools = require('telegram-bot-lua.tools')
+
+local function format_number(n)
+ local s = tostring(n)
+ local pos = #s % 3
+ if pos == 0 then pos = 3 end
+ local result = s:sub(1, pos)
+ for i = pos + 1, #s, 3 do
+ result = result .. ',' .. s:sub(i, i + 2)
+ end
+ return result
+end
+
+-- Format a net value with +/- prefix and right-alignment
+local function format_net(n, width)
+ local s
+ if n > 0 then
+ s = '+' .. tostring(n)
+ elseif n < 0 then
+ s = tostring(n)
+ else
+ s = '0'
+ end
+ return string.format('%' .. width .. 's', s)
+end
+
+function plugin.on_member_join(api, message, ctx)
+ if not ctx.is_group then return end
+ local date = os.date('!%Y-%m-%d')
+ local key = string.format('growth:joins:%s:%s', message.chat.id, date)
+ local count = ctx.redis.incr(key)
+ if count == 1 then ctx.redis.expire(key, 691200) end
+end
+
+function plugin.on_chat_member_update(api, update, ctx)
+ if not update.chat then return end
+ local new_status = update.new_chat_member and update.new_chat_member.status
+ if new_status == 'left' or new_status == 'kicked' then
+ local date = os.date('!%Y-%m-%d')
+ local key = string.format('growth:leaves:%s:%s', update.chat.id, date)
+ local count = ctx.redis.incr(key)
+ if count == 1 then ctx.redis.expire(key, 691200) end
+ end
+end
+
+function plugin.on_message(api, message, ctx)
+ local chat_id = message.chat.id
+ local days = {}
+ local total_joins = 0
+ local total_leaves = 0
+
+ -- Collect data for the last 7 days
+ for i = 0, 6 do
+ local timestamp = os.time() - (i * 86400)
+ local date = os.date('!%Y-%m-%d', timestamp)
+ local join_key = string.format('growth:joins:%s:%s', chat_id, date)
+ local leave_key = string.format('growth:leaves:%s:%s', chat_id, date)
+ local joins = tonumber(ctx.redis.get(join_key)) or 0
+ local leaves = tonumber(ctx.redis.get(leave_key)) or 0
+ local net = joins - leaves
+ total_joins = total_joins + joins
+ total_leaves = total_leaves + leaves
+ table.insert(days, { date = date, joins = joins, leaves = leaves, net = net })
+ end
+
+ local total_net = total_joins - total_leaves
+
+ -- Build output table
+ local lines = {}
+ table.insert(lines, 'Chat Growth \xe2\x80\x94 Last 7 Days')
+ table.insert(lines, '')
+ table.insert(lines, string.format('%-12s %6s %7s %6s', 'Date', 'Joins', 'Leaves', 'Net'))
+ table.insert(lines, string.rep('-', 33))
+
+ for _, day in ipairs(days) do
+ table.insert(lines, string.format('%-12s %6d %7d %s',
+ day.date, day.joins, day.leaves, format_net(day.net, 6)
+ ))
+ end
+
+ table.insert(lines, string.rep('-', 33))
+ table.insert(lines, string.format('%-12s %6d %7d %s',
+ 'Total:', total_joins, total_leaves, format_net(total_net, 6)
+ ))
+
+ -- Get current member count
+ local member_count_text = ''
+ local count_result = api.get_chat_member_count(chat_id)
+ if count_result and count_result.result then
+ member_count_text = '\n\nCurrent members: ' .. format_number(count_result.result)
+ end
+
+ local output = '<pre>' .. tools.escape_html(
+ table.concat(lines, '\n')
+ ) .. '</pre>' .. member_count_text
+
+ return api.send_message(chat_id, output, { parse_mode = 'html' })
+end
+
+return plugin
diff --git a/src/plugins/utility/help.lua b/src/plugins/utility/help.lua
index 31c64c4..804a6c3 100644
--- a/src/plugins/utility/help.lua
+++ b/src/plugins/utility/help.lua
@@ -1,161 +1,171 @@
--[[
mattata v2.0 - Help Plugin
Displays help menus with inline keyboard navigation.
]]
local plugin = {}
plugin.name = 'help'
plugin.category = 'utility'
plugin.description = 'View bot help and command list'
plugin.commands = { 'help', 'start' }
plugin.help = '/help [command] - View help menu or get usage info for a specific command.'
plugin.permanent = true
local PER_PAGE = 10
local function get_page(items, page)
local start_idx = (page - 1) * PER_PAGE + 1
local end_idx = math.min(start_idx + PER_PAGE - 1, #items)
local result = {}
for i = start_idx, end_idx do
table.insert(result, items[i])
end
return result, math.ceil(#items / PER_PAGE)
end
local function format_help_list(help_items)
local lines = {}
for _, item in ipairs(help_items) do
local cmd = item.commands[1] and ('/' .. item.commands[1]) or ''
local desc = item.description or ''
table.insert(lines, string.format('%s %s - <em>%s</em>', '\xe2\x80\xa2', cmd, desc))
end
return table.concat(lines, '\n')
end
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local loader = require('src.core.loader')
-- If argument given, show help for specific command
if message.args and message.args ~= '' then
local input = message.args:match('^/?(%w+)$')
if input then
local target = loader.get_by_command(input:lower())
if target and target.help then
return api.send_message(message.chat.id, 'Usage:\n' .. target.help .. '\n\nTo see all commands, send /help.')
end
return api.send_message(message.chat.id, 'No plugin found matching that command. Send /help to see all available commands.')
end
end
-- Show main help menu
local name = tools.escape_html(message.from.first_name)
local output = string.format(
'Hey %s! I\'m <b>%s</b>, a feature-rich Telegram bot.\n\nUse the buttons below to navigate my commands, or type <code>/help &lt;command&gt;</code> for details on a specific command.',
name, tools.escape_html(api.info.first_name)
)
local keyboard = api.inline_keyboard():row(
api.row():callback_data_button('Commands', 'help:cmds:1')
:callback_data_button('Admin Help', 'help:acmds:1')
):row(
api.row():callback_data_button('Links', 'help:links')
:callback_data_button('Settings', 'help:settings')
)
- return api.send_message(message.chat.id, output, 'html', true, false, nil, keyboard)
+ return api.send_message(message.chat.id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local tools = require('telegram-bot-lua.tools')
local loader = require('src.core.loader')
local data = callback_query.data
if data:match('^cmds:%d+$') then
local page = tonumber(data:match('^cmds:(%d+)$'))
local all_help = loader.get_help(nil)
-- Filter non-admin
local items = {}
for _, h in ipairs(all_help) do
if h.category ~= 'admin' then
table.insert(items, h)
end
end
local page_items, total_pages = get_page(items, page)
if page < 1 then page = total_pages end
if page > total_pages then page = 1 end
page_items, total_pages = get_page(items, page)
local output = format_help_list(page_items)
local keyboard = api.inline_keyboard():row(
api.row():callback_data_button('<', 'help:cmds:' .. (page - 1))
:callback_data_button(page .. '/' .. total_pages, 'help:noop')
:callback_data_button('>', 'help:cmds:' .. (page + 1))
):row(
api.row():callback_data_button('Back', 'help:back')
)
- return api.edit_message_text(message.chat.id, message.message_id, output, 'html', true, keyboard)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
elseif data:match('^acmds:%d+$') then
local page = tonumber(data:match('^acmds:(%d+)$'))
local items = loader.get_help('admin')
local page_items, total_pages = get_page(items, page)
if page < 1 then page = total_pages end
if page > total_pages then page = 1 end
page_items, total_pages = get_page(items, page)
local output = format_help_list(page_items)
local keyboard = api.inline_keyboard():row(
api.row():callback_data_button('<', 'help:acmds:' .. (page - 1))
:callback_data_button(page .. '/' .. total_pages, 'help:noop')
:callback_data_button('>', 'help:acmds:' .. (page + 1))
):row(
api.row():callback_data_button('Back', 'help:back')
)
- return api.edit_message_text(message.chat.id, message.message_id, output, 'html', true, keyboard)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
elseif data == 'links' then
+ local cfg = require('src.core.config')
+ local channel_url = cfg.get('CHANNEL_URL', 'https://t.me/mattata')
+ local support_url = cfg.get('SUPPORT_URL', 'https://t.me/mattataSupport')
+ local github_url = cfg.get('GITHUB_URL', 'https://github.com/wrxck/mattata')
+ local dev_url = cfg.get('DEV_URL', 'https://t.me/mattataDev')
local keyboard = api.inline_keyboard():row(
- api.row():url_button('Development', 'https://t.me/mattataDev')
- :url_button('Channel', 'https://t.me/mattata')
+ api.row():url_button('Development', dev_url)
+ :url_button('Channel', channel_url)
):row(
- api.row():url_button('GitHub', 'https://github.com/wrxck/mattata')
- :url_button('Support', 'https://t.me/mattataSupport')
+ api.row():url_button('GitHub', github_url)
+ :url_button('Support', support_url)
):row(
api.row():callback_data_button('Back', 'help:back')
)
- return api.edit_message_text(message.chat.id, message.message_id, 'Useful links:', nil, true, keyboard)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, 'Useful links:', { link_preview_options = { is_disabled = true }, reply_markup = keyboard })
elseif data == 'settings' then
local permissions = require('src.core.permissions')
if message.chat.type == 'supergroup' and not permissions.is_group_admin(api, message.chat.id, callback_query.from.id) then
- return api.answer_callback_query(callback_query.id, 'You need to be an admin to change settings.')
+ return api.answer_callback_query(callback_query.id, { text = 'You need to be an admin to change settings.' })
end
local keyboard = api.inline_keyboard():row(
- api.row():callback_data_button('Administration', 'administration:' .. message.chat.id .. ':page:1')
- :callback_data_button('Plugins', 'plugins:' .. message.chat.id .. ':page:1')
+ api.row():callback_data_button('Administration', 'administration:page:1')
+ :callback_data_button('Plugins', 'plugins:page:1')
):row(
api.row():callback_data_button('Back', 'help:back')
)
- return api.edit_message_reply_markup(message.chat.id, message.message_id, nil, keyboard)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_reply_markup(message.chat.id, message.message_id, { reply_markup = keyboard })
elseif data == 'back' then
local name = tools.escape_html(callback_query.from.first_name)
local output = string.format(
'Hey %s! I\'m <b>%s</b>, a feature-rich Telegram bot.\n\nUse the buttons below to navigate my commands, or type <code>/help &lt;command&gt;</code> for details on a specific command.',
name, tools.escape_html(api.info.first_name)
)
local keyboard = api.inline_keyboard():row(
api.row():callback_data_button('Commands', 'help:cmds:1')
:callback_data_button('Admin Help', 'help:acmds:1')
):row(
api.row():callback_data_button('Links', 'help:links')
:callback_data_button('Settings', 'help:settings')
)
- return api.edit_message_text(message.chat.id, message.message_id, output, 'html', true, keyboard)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, output, { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
elseif data == 'noop' then
return api.answer_callback_query(callback_query.id)
end
end
return plugin
diff --git a/src/plugins/utility/id.lua b/src/plugins/utility/id.lua
index fcd1988..2bdfa1a 100644
--- a/src/plugins/utility/id.lua
+++ b/src/plugins/utility/id.lua
@@ -1,62 +1,94 @@
--[[
- mattata v2.0 - ID Plugin
- Returns user/chat ID and information.
+ mattata v2.1 - ID Plugin
+ Returns user/chat ID and information with modern Telegram fields.
]]
local plugin = {}
plugin.name = 'id'
plugin.category = 'utility'
plugin.description = 'Get user or chat ID and info'
plugin.commands = { 'id', 'user', 'whoami' }
plugin.help = '/id [user] - Returns ID and info for the given user, or yourself if no argument is given.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local target = message.from
local input = message.args
-- If replying to someone, use their info
if message.reply and message.reply.from then
target = message.reply.from
elseif input and input ~= '' then
-- Try to resolve username or ID
local resolved = input:match('^@?(.+)$')
local user_id = tonumber(resolved) or ctx.redis.get('username:' .. resolved:lower())
if user_id then
local result = api.get_chat(user_id)
if result and result.result then
target = result.result
end
end
end
local lines = {}
table.insert(lines, '<b>User Information</b>')
table.insert(lines, 'ID: <code>' .. target.id .. '</code>')
table.insert(lines, 'Name: ' .. tools.escape_html(target.first_name or ''))
if target.last_name then
table.insert(lines, 'Last name: ' .. tools.escape_html(target.last_name))
end
if target.username then
table.insert(lines, 'Username: @' .. target.username)
end
if target.language_code then
table.insert(lines, 'Language: <code>' .. target.language_code .. '</code>')
end
+ if target.is_bot then
+ table.insert(lines, 'Bot: Yes')
+ end
+ if target.is_premium then
+ table.insert(lines, 'Premium: Yes')
+ end
+ if target.added_to_attachment_menu then
+ table.insert(lines, 'Attachment menu: Yes')
+ end
+
+ -- Profile photo count
+ local photos = api.get_user_profile_photos(target.id, 0, 1)
+ if photos and photos.result and photos.result.total_count then
+ table.insert(lines, 'Profile photos: ' .. photos.result.total_count)
+ end
-- If in a group, also show chat info
if ctx.is_group then
table.insert(lines, '')
table.insert(lines, '<b>Chat Information</b>')
table.insert(lines, 'ID: <code>' .. message.chat.id .. '</code>')
table.insert(lines, 'Title: ' .. tools.escape_html(message.chat.title or ''))
table.insert(lines, 'Type: ' .. (message.chat.type or 'unknown'))
if message.chat.username then
table.insert(lines, 'Username: @' .. message.chat.username)
end
+ if message.chat.is_forum then
+ table.insert(lines, 'Forum: Yes')
+ end
+ -- Fetch full chat info for extra details
+ local chat_info = api.get_chat(message.chat.id)
+ if chat_info and chat_info.result then
+ local chat = chat_info.result
+ if chat.linked_chat_id then
+ table.insert(lines, 'Linked chat: <code>' .. chat.linked_chat_id .. '</code>')
+ end
+ if chat.has_hidden_members then
+ table.insert(lines, 'Hidden members: Yes')
+ end
+ if chat.has_aggressive_anti_spam_enabled then
+ table.insert(lines, 'Aggressive anti-spam: Yes')
+ end
+ end
end
- return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/info.lua b/src/plugins/utility/info.lua
index 6747104..3a4945f 100644
--- a/src/plugins/utility/info.lua
+++ b/src/plugins/utility/info.lua
@@ -1,37 +1,37 @@
--[[
mattata v2.0 - Info Plugin
System information (admin only).
]]
local plugin = {}
plugin.name = 'info'
plugin.category = 'utility'
plugin.description = 'View system information'
plugin.commands = { 'info' }
plugin.help = '/info - View system and bot statistics.'
plugin.global_admin_only = true
function plugin.on_message(api, message, ctx)
local loader = require('src.core.loader')
local lines = {
'<b>mattata v' .. ctx.config.VERSION .. '</b>',
'',
'Plugins loaded: <code>' .. loader.count() .. '</code>',
'Lua version: <code>' .. _VERSION .. '</code>',
'Uptime: <code>' .. os.date('!%H:%M:%S', os.clock()) .. '</code>'
}
-- Database stats
local user_count = ctx.db.call('sp_count_users', {})
local chat_count = ctx.db.call('sp_count_chats', {})
if user_count and user_count[1] then
table.insert(lines, 'Users tracked: <code>' .. user_count[1].count .. '</code>')
end
if chat_count and chat_count[1] then
table.insert(lines, 'Groups tracked: <code>' .. chat_count[1].count .. '</code>')
end
- return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/init.lua b/src/plugins/utility/init.lua
index 60d7fc3..9433c4c 100644
--- a/src/plugins/utility/init.lua
+++ b/src/plugins/utility/init.lua
@@ -1,37 +1,43 @@
--[[
mattata v2.0 - Utility Plugin Category
]]
return {
plugins = {
'help',
'about',
'ping',
'id',
'info',
'weather',
'translate',
'search',
'currency',
'wikipedia',
'time',
'remind',
'afk',
'karma',
'nick',
'setlang',
'setloc',
'statistics',
'commandstats',
'sed',
'calc',
'base64',
'share',
'urbandictionary',
'github',
'xkcd',
'pokedex',
'lastfm',
- 'plugins'
+ 'plugins',
+ 'inline',
+ 'rss',
+ 'paste',
+ 'bookmark',
+ 'schedule',
+ 'growth'
}
}
diff --git a/src/plugins/utility/inline.lua b/src/plugins/utility/inline.lua
new file mode 100644
index 0000000..508334c
--- /dev/null
+++ b/src/plugins/utility/inline.lua
@@ -0,0 +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)
+ 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)
+ 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)
+ 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/karma.lua b/src/plugins/utility/karma.lua
index 8750fb0..52909dd 100644
--- a/src/plugins/utility/karma.lua
+++ b/src/plugins/utility/karma.lua
@@ -1,67 +1,67 @@
--[[
mattata v2.0 - Karma Plugin
Tracks karma scores for users via +1/-1 replies.
]]
local plugin = {}
plugin.name = 'karma'
plugin.category = 'utility'
plugin.description = 'Upvote or downvote users with +1/-1 replies'
plugin.commands = { 'karma' }
plugin.help = '/karma [user] - View karma score for yourself or a replied-to user. Reply to a message with +1 or -1 to change their karma.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local target = message.from
if message.reply and message.reply.from then
target = message.reply.from
elseif message.args and message.args ~= '' then
local resolved = message.args:match('^@?(.+)$')
local user_id = tonumber(resolved) or ctx.redis.get('username:' .. resolved:lower())
if user_id then
local result = api.get_chat(user_id)
if result and result.result then
target = result.result
end
end
end
local karma = tonumber(ctx.redis.get('karma:' .. target.id)) or 0
local name = tools.escape_html(target.first_name or 'Unknown')
return api.send_message(
message.chat.id,
string.format('%s has <b>%d</b> karma.', name, karma),
- 'html'
+ { parse_mode = 'html' }
)
end
function plugin.on_new_message(api, message, ctx)
if not message.text then return end
if not message.reply or not message.reply.from then return end
local text = message.text:match('^%s*(.-)%s*$')
if text ~= '+1' and text ~= '-1' then return end
-- Prevent self-karma
if message.from.id == message.reply.from.id then
return api.send_message(message.chat.id, 'You can\'t modify your own karma!')
end
-- Prevent karma on bots
if message.reply.from.is_bot then return end
local tools = require('telegram-bot-lua.tools')
local target_id = message.reply.from.id
local key = 'karma:' .. target_id
if text == '+1' then
ctx.redis.incr(key)
else
local current = tonumber(ctx.redis.get(key)) or 0
ctx.redis.set(key, tostring(current - 1))
end
local new_karma = tonumber(ctx.redis.get(key)) or 0
local name = tools.escape_html(message.reply.from.first_name or 'Unknown')
local arrow = text == '+1' and '/' or '\\'
return api.send_message(
message.chat.id,
string.format('%s %s <b>%s</b> now has <b>%d</b> karma.', arrow, text == '+1' and 'Upvoted!' or 'Downvoted!', name, new_karma),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/utility/lastfm.lua b/src/plugins/utility/lastfm.lua
index 52c7ac1..1000011 100644
--- a/src/plugins/utility/lastfm.lua
+++ b/src/plugins/utility/lastfm.lua
@@ -1,116 +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 https = require('ssl.https')
- local json = require('dkjson')
+ 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)),
- 'html'
+ { 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 body, status = https.request(api_url)
- if not body or status ~= 200 then
+ local data, status = 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
-
- local data = json.decode(body)
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_body, user_status = https.request(user_url)
- if user_body and user_status == 200 then
- local user_data = json.decode(user_body)
- if user_data and user_data.user and user_data.user.playcount then
+ local user_data, user_status = 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'), 'html', true, false, nil, keyboard)
+ 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/nick.lua b/src/plugins/utility/nick.lua
index 1f88a33..803985e 100644
--- a/src/plugins/utility/nick.lua
+++ b/src/plugins/utility/nick.lua
@@ -1,50 +1,50 @@
--[[
mattata v2.0 - Nickname Plugin
Set, view, and delete your nickname.
]]
local plugin = {}
plugin.name = 'nick'
plugin.category = 'utility'
plugin.description = 'Set a custom nickname'
plugin.commands = { 'nick', 'nickname', 'setnick', 'nn' }
plugin.help = '/nick <name> - Set your nickname.\n/nick - View your current nickname.\n/nick --delete - Remove your nickname.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local input = message.args
-- View current nickname
if not input or input == '' then
local result = ctx.db.call('sp_get_nickname', { message.from.id })
if result and result[1] and result[1].nickname then
return api.send_message(
message.chat.id,
string.format('Your nickname is: <b>%s</b>', tools.escape_html(result[1].nickname)),
- 'html'
+ { parse_mode = 'html' }
)
end
return api.send_message(message.chat.id, 'You don\'t have a nickname set. Use /nick <name> to set one.')
end
-- Delete nickname
if input == '--delete' or input == '-d' then
ctx.db.call('sp_clear_nickname', { message.from.id })
return api.send_message(message.chat.id, 'Your nickname has been removed.')
end
-- Validate length
if #input > 128 then
return api.send_message(message.chat.id, 'Nicknames must be 128 characters or fewer.')
end
-- Set nickname
ctx.db.call('sp_set_nickname', { message.from.id, input })
return api.send_message(
message.chat.id,
string.format('Your nickname has been set to: <b>%s</b>', tools.escape_html(input)),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/utility/paste.lua b/src/plugins/utility/paste.lua
new file mode 100644
index 0000000..3ff3a15
--- /dev/null
+++ b/src/plugins/utility/paste.lua
@@ -0,0 +1,71 @@
+--[[
+ mattata v2.0 - Paste Plugin
+ Pastes text to dpaste.org and returns the URL.
+]]
+
+local plugin = {}
+plugin.name = 'paste'
+plugin.category = 'utility'
+plugin.description = 'Paste text to dpaste.org and get a shareable link'
+plugin.commands = { 'paste', 'p' }
+plugin.help = '/paste <text> - Paste text to dpaste.org. Also works as a reply to a message.'
+
+local http = require('src.core.http')
+
+local MAX_INPUT = 50000
+
+local function url_encode(str)
+ return str:gsub('([^%w%-%.%_%~])', function(c)
+ return string.format('%%%02X', string.byte(c))
+ end)
+end
+
+function plugin.on_message(api, message, ctx)
+ local input = message.args
+
+ -- If no args, try to use the replied message text
+ if (not input or input == '') and message.reply then
+ input = message.reply.text or message.reply.caption
+ end
+
+ if not input or input == '' then
+ return api.send_message(
+ message.chat.id,
+ 'Please provide text to paste.\nUsage: <code>/paste hello world</code>\nOr reply to a message with <code>/paste</code>',
+ { parse_mode = 'html' }
+ )
+ end
+
+ if #input > MAX_INPUT then
+ return api.send_message(
+ message.chat.id,
+ string.format('Input is too long (%d characters). Maximum is %d.', #input, MAX_INPUT)
+ )
+ end
+
+ local post_body = 'content=' .. url_encode(input) .. '&format=url'
+ -- Add syntax=text for code or long text
+ if #input > 200 then
+ post_body = post_body .. '&syntax=text'
+ end
+
+ local body, code = http.post('https://dpaste.org/api/', post_body, 'application/x-www-form-urlencoded')
+
+ if not body or body == '' or code ~= 200 then
+ return api.send_message(message.chat.id, 'Failed to create paste. Please try again later.')
+ end
+
+ -- dpaste returns the URL with a trailing newline
+ local paste_url = body:match('^%s*(.-)%s*$')
+ if not paste_url or paste_url == '' then
+ return api.send_message(message.chat.id, 'Failed to parse the paste URL from the response.')
+ end
+
+ return api.send_message(
+ message.chat.id,
+ string.format('<a href="%s">Pasted!</a> Expires in 7 days.', paste_url),
+ { parse_mode = 'html' }
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/ping.lua b/src/plugins/utility/ping.lua
index 7ea8f71..a46cf40 100644
--- a/src/plugins/utility/ping.lua
+++ b/src/plugins/utility/ping.lua
@@ -1,21 +1,21 @@
--[[
mattata v2.0 - Ping Plugin
]]
local plugin = {}
plugin.name = 'ping'
plugin.category = 'utility'
plugin.description = 'Check bot responsiveness'
plugin.commands = { 'ping', 'pong' }
plugin.help = '/ping - PONG!'
function plugin.on_message(api, message, ctx)
local socket = require('socket')
local latency = math.floor((socket.gettime() - (message.date or socket.gettime())) * 1000)
if message.command == 'pong' then
return api.send_message(message.chat.id, 'You really have to go the extra mile, don\'t you?')
end
- return api.send_message(message.chat.id, string.format('Pong! <code>%dms</code>', latency), 'html')
+ return api.send_message(message.chat.id, string.format('Pong! <code>%dms</code>', latency), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/plugins.lua b/src/plugins/utility/plugins.lua
index 96b0f77..672af4e 100644
--- a/src/plugins/utility/plugins.lua
+++ b/src/plugins/utility/plugins.lua
@@ -1,141 +1,141 @@
--[[
mattata v2.0 - Plugins Management Plugin
Allows admins to enable or disable plugins per chat.
]]
local plugin = {}
plugin.name = 'plugins'
plugin.category = 'utility'
plugin.description = 'Enable or disable plugins in this chat'
plugin.commands = { 'plugins', 'enableplugin', 'disableplugin' }
plugin.help = '/plugins - View and toggle plugins for this chat.\n/enableplugin <name> - Enable a plugin.\n/disableplugin <name> - Disable a plugin.'
plugin.admin_only = true
plugin.group_only = true
local PER_PAGE = 10
function plugin.on_message(api, message, ctx)
local loader = require('src.core.loader')
local tools = require('telegram-bot-lua.tools')
-- Direct enable/disable commands
if message.command == 'enableplugin' or message.command == 'disableplugin' then
local name = message.args
if not name or name == '' then
return api.send_message(message.chat.id, 'Please specify a plugin name.')
end
name = name:lower()
local target = loader.get_by_name(name)
if not target then
return api.send_message(message.chat.id, 'Plugin "' .. tools.escape_html(name) .. '" not found.')
end
if loader.is_permanent(name) then
return api.send_message(message.chat.id, 'The "' .. name .. '" plugin cannot be toggled.')
end
if message.command == 'enableplugin' then
ctx.session.enable_plugin(message.chat.id, name)
return api.send_message(message.chat.id, 'The "' .. name .. '" plugin has been enabled.')
else
ctx.session.disable_plugin(message.chat.id, name)
return api.send_message(message.chat.id, 'The "' .. name .. '" plugin has been disabled.')
end
end
-- Show plugin list with toggle keyboard
return plugin.send_plugin_page(api, message.chat.id, nil, 1, ctx)
end
function plugin.send_plugin_page(api, chat_id, message_id, page, ctx)
local loader = require('src.core.loader')
local all_plugins = loader.get_plugins()
-- Filter toggleable plugins
local toggleable = {}
for _, p in ipairs(all_plugins) do
if not loader.is_permanent(p.name) then
table.insert(toggleable, p)
end
end
local total_pages = math.max(1, math.ceil(#toggleable / PER_PAGE))
if page < 1 then page = total_pages end
if page > total_pages then page = 1 end
local start_idx = (page - 1) * PER_PAGE + 1
local end_idx = math.min(start_idx + PER_PAGE - 1, #toggleable)
local keyboard = api.inline_keyboard()
for i = start_idx, end_idx do
local p = toggleable[i]
local is_disabled = ctx.session.is_plugin_disabled(chat_id, p.name)
local status = is_disabled and 'OFF' or 'ON'
local label = string.format('%s [%s]', p.name, status)
keyboard:row(
api.row():callback_data_button(label, 'plugins:toggle:' .. p.name .. ':' .. page)
)
end
-- Navigation row
keyboard:row(
api.row()
:callback_data_button('<', 'plugins:page:' .. (page - 1))
:callback_data_button(page .. '/' .. total_pages, 'plugins:noop')
:callback_data_button('>', 'plugins:page:' .. (page + 1))
)
local text = 'Toggle plugins on or off for this chat. Permanent plugins (help, about, plugins) cannot be disabled.'
if message_id then
- return api.edit_message_text(chat_id, message_id, text, nil, true, keyboard)
+ return api.edit_message_text(chat_id, message_id, text, { link_preview_options = { is_disabled = true }, reply_markup = keyboard })
else
- return api.send_message(chat_id, text, nil, true, false, nil, keyboard)
+ return api.send_message(chat_id, text, { link_preview_options = { is_disabled = true }, reply_markup = keyboard })
end
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local data = callback_query.data
local loader = require('src.core.loader')
local permissions = require('src.core.permissions')
-- Check admin permission
if not permissions.is_group_admin(api, message.chat.id, callback_query.from.id) then
- return api.answer_callback_query(callback_query.id, 'You need to be an admin to manage plugins.')
+ return api.answer_callback_query(callback_query.id, { text = 'You need to be an admin to manage plugins.' })
end
if data == 'noop' then
return api.answer_callback_query(callback_query.id)
end
-- Page navigation
local page = data:match('^page:(%d+)$')
if page then
page = tonumber(page)
plugin.send_plugin_page(api, message.chat.id, message.message_id, page, ctx)
return api.answer_callback_query(callback_query.id)
end
-- Toggle plugin
local plugin_name, return_page = data:match('^toggle:([%w_]+):(%d+)$')
if plugin_name then
return_page = tonumber(return_page)
if loader.is_permanent(plugin_name) then
- return api.answer_callback_query(callback_query.id, 'This plugin cannot be toggled.')
+ return api.answer_callback_query(callback_query.id, { text = 'This plugin cannot be toggled.' })
end
local target = loader.get_by_name(plugin_name)
if not target then
- return api.answer_callback_query(callback_query.id, 'Plugin not found.')
+ return api.answer_callback_query(callback_query.id, { text = 'Plugin not found.' })
end
local is_disabled = ctx.session.is_plugin_disabled(message.chat.id, plugin_name)
if is_disabled then
ctx.session.enable_plugin(message.chat.id, plugin_name)
- api.answer_callback_query(callback_query.id, plugin_name .. ' has been enabled.')
+ api.answer_callback_query(callback_query.id, { text = plugin_name .. ' has been enabled.' })
else
ctx.session.disable_plugin(message.chat.id, plugin_name)
- api.answer_callback_query(callback_query.id, plugin_name .. ' has been disabled.')
+ api.answer_callback_query(callback_query.id, { text = plugin_name .. ' has been disabled.' })
end
return plugin.send_plugin_page(api, message.chat.id, message.message_id, return_page, ctx)
end
end
return plugin
diff --git a/src/plugins/utility/pokedex.lua b/src/plugins/utility/pokedex.lua
index e09aeef..3ca96bd 100644
--- a/src/plugins/utility/pokedex.lua
+++ b/src/plugins/utility/pokedex.lua
@@ -1,109 +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 https = require('ssl.https')
- local json = require('dkjson')
+ 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 body, status = https.request(api_url)
- if not body or status ~= 200 then
+ local data, code = 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
-
- local data = json.decode(body)
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, table.concat(lines, '\n'), 'html')
+ 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'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/remind.lua b/src/plugins/utility/remind.lua
index 0cc8fe6..a2b06f6 100644
--- a/src/plugins/utility/remind.lua
+++ b/src/plugins/utility/remind.lua
@@ -1,279 +1,279 @@
--[[
mattata v2.0 - Remind Plugin
Sets timed reminders stored in Redis with a cron job to check expirations.
Supports duration parsing (e.g., 2h30m, 1d, 45m, 90s).
Max 4 active reminders per chat per user.
]]
local plugin = {}
plugin.name = 'remind'
plugin.category = 'utility'
plugin.description = 'Set and manage reminders'
plugin.commands = { 'remind', 'reminders' }
plugin.help = '/remind <duration> <message> - Set a reminder (e.g., /remind 2h30m take out the bins).\n/reminders - List your active reminders.'
local tools = require('telegram-bot-lua.tools')
local MAX_REMINDERS = 4
local MAX_DURATION = 7 * 24 * 3600 -- 7 days
local REDIS_PREFIX = 'reminder:'
-- Parse a duration string like "2h30m", "1d", "45m", "90s", "1h", "2d12h"
local function parse_duration(str)
if not str or str == '' then
return nil
end
-- Try pure number (assume minutes)
local pure_num = tonumber(str)
if pure_num then
return math.floor(pure_num * 60)
end
local total = 0
local found = false
-- Days
local d = str:match('(%d+)%s*d')
if d then
total = total + tonumber(d) * 86400
found = true
end
-- Hours
local h = str:match('(%d+)%s*h')
if h then
total = total + tonumber(h) * 3600
found = true
end
-- Minutes
local m = str:match('(%d+)%s*m')
if m then
total = total + tonumber(m) * 60
found = true
end
-- Seconds
local s = str:match('(%d+)%s*s')
if s then
total = total + tonumber(s)
found = true
end
if not found or total <= 0 then
return nil
end
return total
end
-- Format seconds into a human-readable string
local function format_duration(seconds)
if seconds < 60 then
return seconds .. ' second' .. (seconds == 1 and '' or 's')
end
local parts = {}
local days = math.floor(seconds / 86400)
seconds = seconds % 86400
local hours = math.floor(seconds / 3600)
seconds = seconds % 3600
local mins = math.floor(seconds / 60)
local secs = seconds % 60
if days > 0 then
table.insert(parts, days .. 'd')
end
if hours > 0 then
table.insert(parts, hours .. 'h')
end
if mins > 0 then
table.insert(parts, mins .. 'm')
end
if secs > 0 and days == 0 then
table.insert(parts, secs .. 's')
end
return table.concat(parts, ' ')
end
-- Get all reminder keys for a user in a chat
local function get_user_reminders(redis, chat_id, user_id)
local pattern = string.format('%s%s:%s:*', REDIS_PREFIX, tostring(chat_id), tostring(user_id))
return redis.keys(pattern) or {}
end
-- Get all reminder keys globally (for cron)
local function get_all_reminders(redis)
return redis.keys(REDIS_PREFIX .. '*') or {}
end
function plugin.on_message(api, message, ctx)
local redis = ctx.redis
-- /reminders - list active reminders
if message.command == 'reminders' then
local keys = get_user_reminders(redis, message.chat.id, message.from.id)
if #keys == 0 then
return api.send_message(message.chat.id, 'You have no active reminders in this chat.')
end
local lines = { '<b>Your active reminders:</b>', '' }
for i, key in ipairs(keys) do
local data = redis.hgetall(key)
if data and data.text then
local expires = tonumber(data.expires) or 0
local remaining = expires - os.time()
if remaining > 0 then
table.insert(lines, string.format(
'%d. %s <i>(in %s)</i>',
i,
tools.escape_html(data.text),
format_duration(remaining)
))
end
end
end
if #lines <= 2 then
return api.send_message(message.chat.id, 'You have no active reminders in this chat.')
end
- return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
-- /remind <duration> <message>
local input = message.args
if not input or input == '' then
return api.send_message(
message.chat.id,
'Usage: <code>/remind &lt;duration&gt; &lt;message&gt;</code>\n\n'
.. 'Durations: <code>30m</code>, <code>2h</code>, <code>1d</code>, '
.. '<code>2h30m</code>\n'
.. 'Max: 7 days. Max 4 reminders per chat.\n\n'
.. 'Examples:\n'
.. '<code>/remind 30m check the oven</code>\n'
.. '<code>/remind 2h30m meeting with John</code>\n'
.. '<code>/remind 1d renew subscription</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
-- Parse duration from the first token
local duration_str, reminder_text = input:match('^(%S+)%s+(.+)$')
if not duration_str then
-- Maybe just a duration with no text
duration_str = input
reminder_text = nil
end
local duration = parse_duration(duration_str)
if not duration then
return api.send_message(
message.chat.id,
'Invalid duration format. Use combinations like: <code>30m</code>, <code>2h</code>, <code>1d</code>, <code>2h30m</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
if not reminder_text or reminder_text == '' then
return api.send_message(message.chat.id, 'Please include a reminder message after the duration.')
end
if duration < 30 then
return api.send_message(message.chat.id, 'Minimum reminder duration is 30 seconds.')
end
if duration > MAX_DURATION then
return api.send_message(message.chat.id, 'Maximum reminder duration is 7 days.')
end
-- Check reminder limit
local existing = get_user_reminders(redis, message.chat.id, message.from.id)
-- Filter to only count non-expired ones
local active_count = 0
for _, key in ipairs(existing) do
local expires = redis.hget(key, 'expires')
if expires and tonumber(expires) > os.time() then
active_count = active_count + 1
else
-- Clean up expired entry
redis.del(key)
end
end
if active_count >= MAX_REMINDERS then
return api.send_message(
message.chat.id,
string.format('You already have %d active reminders in this chat (max %d). Wait for one to expire or use /reminders to check them.', active_count, MAX_REMINDERS)
)
end
-- Truncate long reminder text
if #reminder_text > 500 then
reminder_text = reminder_text:sub(1, 497) .. '...'
end
-- Store reminder
local expires_at = os.time() + duration
local reminder_id = string.format('%s%s:%s:%d',
REDIS_PREFIX,
tostring(message.chat.id),
tostring(message.from.id),
expires_at
)
redis.hset(reminder_id, 'chat_id', tostring(message.chat.id))
redis.hset(reminder_id, 'user_id', tostring(message.from.id))
redis.hset(reminder_id, 'text', reminder_text)
redis.hset(reminder_id, 'expires', tostring(expires_at))
redis.hset(reminder_id, 'first_name', message.from.first_name or 'User')
-- Set Redis TTL slightly beyond expiry for auto-cleanup
local key_ttl = duration + 300
redis.expire(reminder_id, key_ttl)
return api.send_message(
message.chat.id,
string.format(
'Reminder set for <b>%s</b> from now.\n\n<i>%s</i>',
format_duration(duration),
tools.escape_html(reminder_text)
),
- 'html'
+ { parse_mode = 'html' }
)
end
-- Cron job: runs every minute, checks for expired reminders
function plugin.cron(api, ctx)
local redis = ctx.redis
local keys = get_all_reminders(redis)
local now = os.time()
for _, key in ipairs(keys) do
local data = redis.hgetall(key)
if data and data.expires then
local expires = tonumber(data.expires)
if expires and expires <= now then
-- Send the reminder
local chat_id = data.chat_id
local user_id = data.user_id
local text = data.text or 'Reminder!'
local first_name = data.first_name or 'User'
if chat_id then
local output = string.format(
'<a href="tg://user?id=%s">%s</a>, here is your reminder:\n\n<i>%s</i>',
tostring(user_id),
tools.escape_html(first_name),
tools.escape_html(text)
)
pcall(function()
- api.send_message(tonumber(chat_id), output, 'html')
+ api.send_message(tonumber(chat_id), output, { parse_mode = 'html' })
end)
end
-- Delete the reminder
redis.del(key)
end
end
end
end
return plugin
diff --git a/src/plugins/utility/rss.lua b/src/plugins/utility/rss.lua
new file mode 100644
index 0000000..0c98e0d
--- /dev/null
+++ b/src/plugins/utility/rss.lua
@@ -0,0 +1,353 @@
+--[[
+ mattata v2.0 - RSS Plugin
+ Subscribe groups to RSS/Atom feeds with automatic polling.
+ Feeds are checked every 5 minutes via cron (staggered).
+ All state stored in Redis — no database procedures needed.
+]]
+
+local plugin = {}
+plugin.name = 'rss'
+plugin.category = 'utility'
+plugin.description = 'Subscribe to RSS/Atom feeds in group chats'
+plugin.commands = { 'rss' }
+plugin.help = '/rss add <url> - Subscribe to a feed\n/rss remove <url> - Unsubscribe\n/rss list - List active subscriptions'
+plugin.group_only = true
+plugin.admin_only = true
+
+local http = require('src.core.http')
+local tools = require('telegram-bot-lua.tools')
+local logger = require('src.core.logger')
+
+local MAX_FEEDS_PER_CHAT = 5
+local POLL_INTERVAL = 300 -- 5 minutes between checks per feed
+local MAX_FEEDS_PER_TICK = 3 -- max feeds to check per cron tick
+local MAX_ITEMS = 5 -- only process latest 5 items per fetch
+local MAX_SEEN = 200 -- keep last N entry IDs in seen set
+
+local function url_hash(url)
+ local h = 0
+ for i = 1, #url do
+ h = (h * 31 + url:byte(i)) % 2147483647
+ end
+ return tostring(h)
+end
+
+-- Parse RSS 2.0 items
+local function parse_rss(body)
+ local items = {}
+ for item in body:gmatch('<item>(.-)</item>') do
+ local title = item:match('<title><!%[CDATA%[(.-)%]%]>') or item:match('<title>(.-)</title>') or 'Untitled'
+ local link = item:match('<link>(.-)</link>') or ''
+ local guid = item:match('<guid>(.-)</guid>') or link
+ title = title:gsub('<[^>]+>', '')
+ table.insert(items, { title = title, link = link, guid = guid })
+ if #items >= MAX_ITEMS then break end
+ end
+ return items
+end
+
+-- Parse Atom entries
+local function parse_atom(body)
+ local items = {}
+ for entry in body:gmatch('<entry>(.-)</entry>') do
+ local title = entry:match('<title>(.-)</title>') or 'Untitled'
+ local link = entry:match('<link[^>]*href="([^"]*)"') or entry:match('<link>(.-)</link>') or ''
+ local id = entry:match('<id>(.-)</id>') or link
+ title = title:gsub('<[^>]+>', '')
+ table.insert(items, { title = title, link = link, guid = id })
+ if #items >= MAX_ITEMS then break end
+ end
+ return items
+end
+
+local function parse_feed(body)
+ if body:match('<feed') then
+ return parse_atom(body)
+ else
+ return parse_rss(body)
+ end
+end
+
+-- Extract feed title from XML
+local function extract_feed_title(body)
+ -- Try channel/title for RSS
+ local channel = body:match('<channel>(.-)<item')
+ if channel then
+ local title = channel:match('<title><!%[CDATA%[(.-)%]%]>') or channel:match('<title>(.-)</title>')
+ if title then return title:gsub('<[^>]+>', '') end
+ end
+ -- Try feed/title for Atom
+ local title = body:match('<feed[^>]*>.-<title>(.-)</title>')
+ if title then return title:gsub('<[^>]+>', '') end
+ return 'Untitled Feed'
+end
+
+local function handle_add(api, message, ctx)
+ local url = message.args and message.args:match('^add%s+(.+)$')
+ if not url then
+ return api.send_message(message.chat.id, 'Usage: <code>/rss add https://example.com/feed.xml</code>', { parse_mode = 'html' })
+ end
+
+ url = url:match('^%s*(.-)%s*$') -- trim whitespace
+
+ if not url:match('^https?://') then
+ return api.send_message(message.chat.id, 'Invalid URL. Must start with http:// or https://')
+ end
+
+ -- Block internal/private IP ranges to prevent SSRF
+ local host = url:match('^https?://([^/:]+)')
+ if host then
+ local h = host:lower()
+ if h == 'localhost' or h:match('^127%.') or h:match('^10%.') or h:match('^192%.168%.')
+ or h:match('^172%.1[6-9]%.') or h:match('^172%.2%d%.') or h:match('^172%.3[01]%.')
+ or h:match('^0%.') or h:match('^%[') or h:match('^169%.254%.') then
+ return api.send_message(message.chat.id, 'Invalid URL. Internal addresses are not allowed.')
+ end
+ end
+
+ local feed_key = 'rss:feeds:' .. message.chat.id
+ local count = ctx.redis.scard(feed_key)
+ if tonumber(count) >= MAX_FEEDS_PER_CHAT then
+ return api.send_message(
+ message.chat.id,
+ string.format('This chat already has %d subscriptions (max %d). Remove one first.', count, MAX_FEEDS_PER_CHAT)
+ )
+ end
+
+ -- Check if already subscribed
+ local is_member = ctx.redis.sismember(feed_key, url)
+ if tonumber(is_member) == 1 then
+ return api.send_message(message.chat.id, 'This chat is already subscribed to that feed.')
+ end
+
+ -- Fetch and validate the feed
+ local body, code = http.get(url)
+ if not body or code ~= 200 then
+ return api.send_message(
+ message.chat.id,
+ string.format('Failed to fetch feed (HTTP %s). Check the URL and try again.', tostring(code))
+ )
+ end
+
+ local items = parse_feed(body)
+ if #items == 0 then
+ return api.send_message(message.chat.id, 'No feed items found at that URL. Make sure it is a valid RSS or Atom feed.')
+ end
+
+ local feed_title = extract_feed_title(body)
+ local hash = url_hash(url)
+
+ -- Store subscription
+ ctx.redis.sadd(feed_key, url)
+ ctx.redis.sadd('rss:subs:' .. hash, tostring(message.chat.id))
+ ctx.redis.sadd('rss:active_feeds', hash)
+
+ -- Store metadata
+ local meta_key = 'rss:meta:' .. hash
+ ctx.redis.hset(meta_key, 'title', feed_title)
+ ctx.redis.hset(meta_key, 'url', url)
+ ctx.redis.hset(meta_key, 'last_checked', tostring(os.time()))
+
+ -- Mark all current entries as seen so we don't flood on first add
+ local seen_key = 'rss:seen:' .. hash
+ for _, item in ipairs(items) do
+ if item.guid and item.guid ~= '' then
+ ctx.redis.sadd(seen_key, item.guid)
+ end
+ end
+
+ return api.send_message(
+ message.chat.id,
+ string.format('Subscribed to <b>%s</b>.', tools.escape_html(feed_title)),
+ { parse_mode = 'html' }
+ )
+end
+
+local function handle_remove(api, message, ctx)
+ local url = message.args and message.args:match('^remove%s+(.+)$')
+ if not url then
+ return api.send_message(message.chat.id, 'Usage: <code>/rss remove https://example.com/feed.xml</code>', { parse_mode = 'html' })
+ end
+
+ url = url:match('^%s*(.-)%s*$') -- trim whitespace
+
+ local feed_key = 'rss:feeds:' .. message.chat.id
+ local removed = ctx.redis.srem(feed_key, url)
+ if tonumber(removed) == 0 then
+ return api.send_message(message.chat.id, 'This chat is not subscribed to that feed.')
+ end
+
+ local hash = url_hash(url)
+ ctx.redis.srem('rss:subs:' .. hash, tostring(message.chat.id))
+
+ -- If no more subscribers, clean up
+ local remaining = ctx.redis.scard('rss:subs:' .. hash)
+ if tonumber(remaining) == 0 then
+ ctx.redis.del('rss:meta:' .. hash)
+ ctx.redis.del('rss:seen:' .. hash)
+ ctx.redis.srem('rss:active_feeds', hash)
+ end
+
+ return api.send_message(message.chat.id, 'Unsubscribed from feed.')
+end
+
+local function handle_list(api, message, ctx)
+ local feed_key = 'rss:feeds:' .. message.chat.id
+ local urls = ctx.redis.smembers(feed_key)
+
+ if not urls or #urls == 0 then
+ return api.send_message(message.chat.id, 'No active feed subscriptions for this chat.')
+ end
+
+ local lines = { '<b>RSS Subscriptions</b>' }
+ for i, feed_url in ipairs(urls) do
+ local hash = url_hash(feed_url)
+ local title = ctx.redis.hget('rss:meta:' .. hash, 'title') or 'Unknown'
+ table.insert(lines, string.format('%d. <b>%s</b>\n <code>%s</code>', i, tools.escape_html(title), tools.escape_html(feed_url)))
+ end
+
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html', link_preview_options = { is_disabled = true } })
+end
+
+function plugin.on_message(api, message, ctx)
+ if not message.args or message.args == '' then
+ return api.send_message(
+ message.chat.id,
+ '<b>RSS Feed Subscriptions</b>\n\n'
+ .. '<code>/rss add &lt;url&gt;</code> - Subscribe to a feed\n'
+ .. '<code>/rss remove &lt;url&gt;</code> - Unsubscribe\n'
+ .. '<code>/rss list</code> - List active subscriptions\n\n'
+ .. string.format('Max %d feeds per chat. Feeds are checked every %d minutes.', MAX_FEEDS_PER_CHAT, POLL_INTERVAL / 60),
+ { parse_mode = 'html' }
+ )
+ end
+
+ local subcommand = message.args:match('^(%S+)')
+ if not subcommand then
+ return api.send_message(message.chat.id, 'Unknown subcommand. Use /rss for help.')
+ end
+
+ subcommand = subcommand:lower()
+
+ if subcommand == 'add' then
+ return handle_add(api, message, ctx)
+ elseif subcommand == 'remove' or subcommand == 'del' or subcommand == 'delete' then
+ return handle_remove(api, message, ctx)
+ elseif subcommand == 'list' then
+ return handle_list(api, message, ctx)
+ else
+ return api.send_message(message.chat.id, 'Unknown subcommand. Use /rss for help.')
+ end
+end
+
+function plugin.cron(api, ctx)
+ -- Get all active feed hashes
+ local active_feeds = ctx.redis.smembers('rss:active_feeds')
+ if not active_feeds or #active_feeds == 0 then
+ return
+ end
+
+ -- Find feeds due for checking (not checked in last POLL_INTERVAL seconds)
+ local now = os.time()
+ local due = {}
+ for _, hash in ipairs(active_feeds) do
+ local last_checked = ctx.redis.hget('rss:meta:' .. hash, 'last_checked')
+ last_checked = tonumber(last_checked) or 0
+ if now - last_checked >= POLL_INTERVAL then
+ table.insert(due, hash)
+ end
+ if #due >= MAX_FEEDS_PER_TICK then break end
+ end
+
+ for _, hash in ipairs(due) do
+ local feed_url = ctx.redis.hget('rss:meta:' .. hash, 'url')
+ if not feed_url then
+ -- Orphaned metadata, remove from active set
+ ctx.redis.srem('rss:active_feeds', hash)
+ else
+ -- Update last_checked immediately to avoid double-polling
+ ctx.redis.hset('rss:meta:' .. hash, 'last_checked', tostring(now))
+
+ local ok, err = pcall(function()
+ local body, code = http.get(feed_url)
+ if not body or code ~= 200 then
+ logger.warn('RSS: failed to fetch %s (HTTP %s)', feed_url, tostring(code))
+ return
+ end
+
+ local items = parse_feed(body)
+ if #items == 0 then return end
+
+ local feed_title = ctx.redis.hget('rss:meta:' .. hash, 'title') or extract_feed_title(body)
+ local seen_key = 'rss:seen:' .. hash
+
+ -- Check items in reverse order so oldest new items are posted first
+ local new_items = {}
+ for _, item in ipairs(items) do
+ if item.guid and item.guid ~= '' then
+ local already_seen = ctx.redis.sismember(seen_key, item.guid)
+ if tonumber(already_seen) == 0 then
+ table.insert(new_items, item)
+ end
+ end
+ end
+
+ if #new_items == 0 then return end
+
+ -- Mark new items as seen
+ for _, item in ipairs(new_items) do
+ ctx.redis.sadd(seen_key, item.guid)
+ end
+
+ -- Get all subscriber chats
+ local subscribers = ctx.redis.smembers('rss:subs:' .. hash)
+ if not subscribers or #subscribers == 0 then return end
+
+ -- Send new items to all subscribers (oldest first)
+ for i = #new_items, 1, -1 do
+ local item = new_items[i]
+ local text
+ if item.link and item.link ~= '' then
+ text = string.format(
+ '<b>%s</b>\n<a href="%s">%s</a>',
+ tools.escape_html(feed_title),
+ tools.escape_html(item.link),
+ tools.escape_html(item.title)
+ )
+ else
+ text = string.format(
+ '<b>%s</b>\n%s',
+ tools.escape_html(feed_title),
+ tools.escape_html(item.title)
+ )
+ end
+
+ for _, chat_id in ipairs(subscribers) do
+ local send_ok, send_err = pcall(
+ api.send_message, chat_id, text,
+ { parse_mode = 'html', link_preview_options = { is_disabled = true } }
+ )
+ if not send_ok then
+ logger.warn('RSS: failed to send to chat %s: %s', tostring(chat_id), tostring(send_err))
+ end
+ end
+ end
+
+ -- Trim seen set if it grows too large by using a TTL
+ -- This avoids the data loss issue of rebuilding from current items only
+ local seen_count = ctx.redis.scard(seen_key)
+ if tonumber(seen_count) > MAX_SEEN * 2 then
+ -- Set a 7-day TTL on the seen set to let it naturally expire
+ -- rather than deleting entries that might cause re-posts
+ ctx.redis.expire(seen_key, 604800)
+ end
+ end)
+
+ if not ok then
+ logger.error('RSS: error processing feed %s: %s', feed_url, tostring(err))
+ end
+ end
+ end
+end
+
+return plugin
diff --git a/src/plugins/utility/schedule.lua b/src/plugins/utility/schedule.lua
new file mode 100644
index 0000000..e75b431
--- /dev/null
+++ b/src/plugins/utility/schedule.lua
@@ -0,0 +1,355 @@
+--[[
+ mattata v2.0 - Schedule Plugin
+ Schedule messages to be posted in a group after a delay.
+ Admin-only. Stores scheduled messages in Redis with a cron job to send them.
+]]
+
+local plugin = {}
+plugin.name = 'schedule'
+plugin.category = 'utility'
+plugin.description = 'Schedule messages to be posted in a group at a later time'
+plugin.commands = { 'schedule', 'sched' }
+plugin.help = '/schedule <duration> <message> - Schedule a message.\n/schedule list - List pending messages.\n/schedule cancel <id> - Cancel a scheduled message.'
+plugin.group_only = true
+plugin.admin_only = true
+
+local tools = require('telegram-bot-lua.tools')
+
+local MAX_PER_CHAT = 10
+local MAX_DURATION = 7 * 24 * 3600 -- 7 days
+local MAX_MESSAGE_LENGTH = 4000
+local CRON_SEND_LIMIT = 10
+
+-- Parse a duration string like "2h30m", "1d", "45m", "90s"
+local function parse_duration(str)
+ if not str or str == '' then
+ return nil
+ end
+
+ local total = 0
+ local found = false
+
+ local d = str:match('(%d+)%s*d')
+ if d then
+ total = total + tonumber(d) * 86400
+ found = true
+ end
+
+ local h = str:match('(%d+)%s*h')
+ if h then
+ total = total + tonumber(h) * 3600
+ found = true
+ end
+
+ local m = str:match('(%d+)%s*m')
+ if m then
+ total = total + tonumber(m) * 60
+ found = true
+ end
+
+ local s = str:match('(%d+)%s*s')
+ if s then
+ total = total + tonumber(s)
+ found = true
+ end
+
+ if not found or total <= 0 then
+ return nil
+ end
+
+ return total
+end
+
+-- Format seconds into a human-readable string
+local function format_duration(seconds)
+ if seconds < 60 then
+ return seconds .. ' second' .. (seconds == 1 and '' or 's')
+ end
+ local parts = {}
+ local days = math.floor(seconds / 86400)
+ seconds = seconds % 86400
+ local hours = math.floor(seconds / 3600)
+ seconds = seconds % 3600
+ local mins = math.floor(seconds / 60)
+ local secs = seconds % 60
+ if days > 0 then
+ table.insert(parts, days .. 'd')
+ end
+ if hours > 0 then
+ table.insert(parts, hours .. 'h')
+ end
+ if mins > 0 then
+ table.insert(parts, mins .. 'm')
+ end
+ if secs > 0 and days == 0 then
+ table.insert(parts, secs .. 's')
+ end
+ return table.concat(parts, ' ')
+end
+
+-- Get the index key for a chat's scheduled messages
+local function index_key(chat_id)
+ return 'schedule:index:' .. tostring(chat_id)
+end
+
+-- Get the hash key for a specific scheduled message
+local function hash_key(chat_id, id)
+ return 'schedule:' .. tostring(chat_id) .. ':' .. tostring(id)
+end
+
+-- Handle /schedule list
+local function handle_list(api, message, ctx)
+ local redis = ctx.redis
+ local members = redis.smembers(index_key(message.chat.id))
+
+ if not members or #members == 0 then
+ return api.send_message(message.chat.id, 'No scheduled messages for this chat.')
+ end
+
+ local lines = { '<b>Scheduled messages:</b>', '' }
+ local now = os.time()
+ local found = false
+
+ for _, key in ipairs(members) do
+ local data = redis.hgetall(key)
+ if data and data.send_at then
+ local send_at = tonumber(data.send_at)
+ if send_at and send_at > now then
+ found = true
+ local remaining = send_at - now
+ local preview = data.text or ''
+ if #preview > 50 then
+ preview = preview:sub(1, 47) .. '...'
+ end
+ -- Extract the ID from the key
+ local id = key:match(':(%d+)$')
+ table.insert(lines, string.format(
+ '<b>#%s</b> - in %s\n <i>%s</i>',
+ id or '?',
+ format_duration(remaining),
+ tools.escape_html(preview)
+ ))
+ end
+ end
+ end
+
+ if not found then
+ return api.send_message(message.chat.id, 'No scheduled messages for this chat.')
+ end
+
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
+end
+
+-- Handle /schedule cancel <id>
+local function handle_cancel(api, message, ctx, id_str)
+ local redis = ctx.redis
+ local id = tonumber(id_str)
+
+ if not id then
+ return api.send_message(message.chat.id, 'Please provide a valid message ID to cancel.\nUsage: <code>/schedule cancel 3</code>', { parse_mode = 'html' })
+ end
+
+ local key = hash_key(message.chat.id, id)
+ local exists = redis.hget(key, 'chat_id')
+
+ if not exists then
+ return api.send_message(message.chat.id, string.format('Scheduled message #%d not found.', id))
+ end
+
+ redis.del(key)
+ redis.srem(index_key(message.chat.id), key)
+
+ return api.send_message(message.chat.id, string.format('Scheduled message #%d has been cancelled.', id))
+end
+
+-- Handle /schedule <duration> <message>
+local function handle_schedule(api, message, ctx)
+ local redis = ctx.redis
+ local input = message.args
+
+ if not input or input == '' then
+ return api.send_message(
+ message.chat.id,
+ 'Usage:\n'
+ .. '<code>/schedule &lt;duration&gt; &lt;message&gt;</code> - Schedule a message\n'
+ .. '<code>/schedule list</code> - List pending messages\n'
+ .. '<code>/schedule cancel &lt;id&gt;</code> - Cancel a message\n\n'
+ .. 'Durations: <code>30m</code>, <code>2h</code>, <code>1d</code>, <code>2h30m</code>\n'
+ .. 'Max: 7 days, 10 messages per chat.',
+ { parse_mode = 'html' }
+ )
+ end
+
+ -- Parse duration from the first token
+ local duration_str, sched_text = input:match('^(%S+)%s+(.+)$')
+ if not duration_str then
+ return api.send_message(
+ message.chat.id,
+ 'Please provide both a duration and a message.\nUsage: <code>/schedule 2h Hello everyone!</code>',
+ { parse_mode = 'html' }
+ )
+ end
+
+ local duration = parse_duration(duration_str)
+ if not duration then
+ return api.send_message(
+ message.chat.id,
+ 'Invalid duration format. Use combinations like: <code>30m</code>, <code>2h</code>, <code>1d</code>, <code>2h30m</code>',
+ { parse_mode = 'html' }
+ )
+ end
+
+ if duration < 60 then
+ return api.send_message(message.chat.id, 'Minimum schedule duration is 1 minute.')
+ end
+
+ if duration > MAX_DURATION then
+ return api.send_message(message.chat.id, 'Maximum schedule duration is 7 days.')
+ end
+
+ if #sched_text > MAX_MESSAGE_LENGTH then
+ return api.send_message(
+ message.chat.id,
+ string.format('Message is too long (%d characters). Maximum is %d.', #sched_text, MAX_MESSAGE_LENGTH)
+ )
+ end
+
+ -- Check per-chat limit
+ local members = redis.smembers(index_key(message.chat.id))
+ local active_count = 0
+ local now = os.time()
+ if members then
+ for _, key in ipairs(members) do
+ local send_at = redis.hget(key, 'send_at')
+ if send_at and tonumber(send_at) and tonumber(send_at) > now then
+ active_count = active_count + 1
+ else
+ -- Clean up expired entries from index
+ redis.srem(index_key(message.chat.id), key)
+ redis.del(key)
+ end
+ end
+ end
+
+ if active_count >= MAX_PER_CHAT then
+ return api.send_message(
+ message.chat.id,
+ string.format('This chat already has %d scheduled messages (max %d). Cancel some first with /schedule cancel <id>.', active_count, MAX_PER_CHAT)
+ )
+ end
+
+ -- Assign an incremental ID
+ local id = redis.incr('schedule:next_id:' .. tostring(message.chat.id))
+ local key = hash_key(message.chat.id, id)
+ local send_at = now + duration
+
+ -- Store the scheduled message hash
+ redis.hset(key, 'chat_id', tostring(message.chat.id))
+ redis.hset(key, 'text', sched_text)
+ redis.hset(key, 'send_at', tostring(send_at))
+ redis.hset(key, 'created_by', tostring(message.from.id))
+ redis.hset(key, 'first_name', message.from.first_name or 'Admin')
+
+ -- Add to the chat's index set and global active chats set
+ redis.sadd(index_key(message.chat.id), key)
+ redis.sadd('schedule:active_chats', tostring(message.chat.id))
+
+ -- Set TTL for auto-cleanup (duration + 5 minutes buffer)
+ redis.expire(key, duration + 300)
+
+ return api.send_message(
+ message.chat.id,
+ string.format(
+ 'Message #%d scheduled for <b>%s</b> from now.\n\n<i>%s</i>',
+ id,
+ format_duration(duration),
+ tools.escape_html(#sched_text > 100 and sched_text:sub(1, 97) .. '...' or sched_text)
+ ),
+ { parse_mode = 'html' }
+ )
+end
+
+function plugin.on_message(api, message, ctx)
+ local input = message.args
+
+ if input and input:lower() == 'list' then
+ return handle_list(api, message, ctx)
+ end
+
+ if input and input:lower():match('^cancel%s') then
+ local id_str = input:match('^%S+%s+(.+)$')
+ return handle_cancel(api, message, ctx, id_str)
+ end
+
+ if input and input:lower() == 'cancel' then
+ return api.send_message(
+ message.chat.id,
+ 'Please provide the ID of the message to cancel.\nUsage: <code>/schedule cancel 3</code>',
+ { parse_mode = 'html' }
+ )
+ end
+
+ return handle_schedule(api, message, ctx)
+end
+
+-- Cron job: runs every minute, checks for scheduled messages ready to send
+function plugin.cron(api, ctx)
+ local redis = ctx.redis
+ local now = os.time()
+ local sent = 0
+
+ -- Get all chats with scheduled messages
+ local active_chats = redis.smembers('schedule:active_chats') or {}
+ local index_keys = {}
+ for _, chat_id in ipairs(active_chats) do
+ index_keys[#index_keys + 1] = 'schedule:index:' .. chat_id
+ end
+
+ for _, idx_key in ipairs(index_keys) do
+ if sent >= CRON_SEND_LIMIT then
+ break
+ end
+
+ local members = redis.smembers(idx_key)
+ if members then
+ for _, key in ipairs(members) do
+ if sent >= CRON_SEND_LIMIT then
+ break
+ end
+
+ local data = redis.hgetall(key)
+ if data and data.send_at then
+ local send_at = tonumber(data.send_at)
+ if send_at and send_at <= now then
+ local chat_id = tonumber(data.chat_id)
+ local text = data.text
+
+ if chat_id and text then
+ pcall(function()
+ api.send_message(chat_id, text)
+ end)
+ sent = sent + 1
+ end
+
+ -- Clean up
+ redis.del(key)
+ redis.srem(idx_key, key)
+ end
+ else
+ -- Orphaned entry, remove from index
+ redis.srem(idx_key, key)
+ end
+ end
+ -- Clean up empty index sets from global tracker
+ local remaining = redis.scard(idx_key)
+ if tonumber(remaining) == 0 then
+ local tracked_chat_id = idx_key:match('schedule:index:(.+)$')
+ if tracked_chat_id then
+ redis.srem('schedule:active_chats', tracked_chat_id)
+ end
+ end
+ end
+ end
+end
+
+return plugin
diff --git a/src/plugins/utility/search.lua b/src/plugins/utility/search.lua
index ad5a91e..4566630 100644
--- a/src/plugins/utility/search.lua
+++ b/src/plugins/utility/search.lua
@@ -1,138 +1,124 @@
--[[
mattata v2.0 - Search Plugin
Web search using DuckDuckGo Instant Answers API.
]]
local plugin = {}
plugin.name = 'search'
plugin.category = 'utility'
plugin.description = 'Search the web using DuckDuckGo'
plugin.commands = { 'search', 'ddg', 'google' }
plugin.help = '/search <query> - Search the web using DuckDuckGo Instant Answers.'
-local https = require('ssl.https')
-local json = require('dkjson')
+local http = require('src.core.http')
local url = require('socket.url')
-local ltn12 = require('ltn12')
local tools = require('telegram-bot-lua.tools')
local function search(query)
local encoded = url.escape(query)
local request_url = 'https://api.duckduckgo.com/?q=' .. encoded .. '&format=json&no_redirect=1&no_html=1&skip_disambig=1'
- local body = {}
- local _, code = https.request({
- url = request_url,
- sink = ltn12.sink.table(body),
- headers = {
- ['User-Agent'] = 'mattata-telegram-bot/2.0'
- }
- })
- if code ~= 200 then
- return nil, 'Search request failed (HTTP ' .. tostring(code) .. ').'
- end
- local data = json.decode(table.concat(body))
+ local data, code = http.get_json(request_url)
if not data then
- return nil, 'Failed to parse search results.'
+ return nil, 'Search request failed (HTTP ' .. tostring(code) .. ').'
end
return data
end
local function format_results(data, query)
local lines = {}
-- Abstract (instant answer)
if data.AbstractText and data.AbstractText ~= '' then
table.insert(lines, '<b>' .. tools.escape_html(data.Heading or query) .. '</b>')
table.insert(lines, '')
local abstract = data.AbstractText
if #abstract > 500 then
abstract = abstract:sub(1, 497) .. '...'
end
table.insert(lines, tools.escape_html(abstract))
if data.AbstractURL and data.AbstractURL ~= '' then
table.insert(lines, '')
table.insert(lines, '<a href="' .. tools.escape_html(data.AbstractURL) .. '">Read more</a>')
end
return table.concat(lines, '\n')
end
-- Answer (calculations, conversions, etc.)
if data.Answer and data.Answer ~= '' then
local answer = data.Answer:gsub('<[^>]+>', '') -- strip HTML tags
table.insert(lines, '<b>Answer:</b> ' .. tools.escape_html(answer))
return table.concat(lines, '\n')
end
-- Definition
if data.Definition and data.Definition ~= '' then
table.insert(lines, '<b>Definition:</b>')
table.insert(lines, tools.escape_html(data.Definition))
if data.DefinitionSource and data.DefinitionSource ~= '' then
table.insert(lines, '<i>Source: ' .. tools.escape_html(data.DefinitionSource) .. '</i>')
end
return table.concat(lines, '\n')
end
-- Related topics
if data.RelatedTopics and #data.RelatedTopics > 0 then
table.insert(lines, '<b>Results for:</b> ' .. tools.escape_html(query))
table.insert(lines, '')
local count = 0
for _, topic in ipairs(data.RelatedTopics) do
if count >= 5 then break end
if topic.Text and topic.Text ~= '' then
local text = topic.Text
if #text > 200 then
text = text:sub(1, 197) .. '...'
end
if topic.FirstURL and topic.FirstURL ~= '' then
table.insert(lines, '<a href="' .. tools.escape_html(topic.FirstURL) .. '">' .. tools.escape_html(text) .. '</a>')
else
table.insert(lines, tools.escape_html(text))
end
count = count + 1
end
end
if count > 0 then
return table.concat(lines, '\n')
end
end
-- Redirect (bang or direct answer)
if data.Redirect and data.Redirect ~= '' then
return '<b>Redirect:</b> <a href="' .. tools.escape_html(data.Redirect) .. '">' .. tools.escape_html(query) .. '</a>'
end
return nil
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 query.\nUsage: <code>/search your query here</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
local data, err = search(input)
if not data then
return api.send_message(message.chat.id, err)
end
local output = format_results(data, input)
if not output then
local ddg_url = 'https://duckduckgo.com/?q=' .. url.escape(input)
return api.send_message(
message.chat.id,
'No instant answers found. <a href="' .. tools.escape_html(ddg_url) .. '">Search on DuckDuckGo</a>',
- 'html',
- true
+ { parse_mode = 'html', link_preview_options = { is_disabled = true } }
)
end
- return api.send_message(message.chat.id, output, 'html', true)
+ 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/sed.lua b/src/plugins/utility/sed.lua
index 0a2e49e..a319ad0 100644
--- a/src/plugins/utility/sed.lua
+++ b/src/plugins/utility/sed.lua
@@ -1,67 +1,90 @@
--[[
mattata v2.0 - Sed Plugin
Regex-style substitution on replied-to messages using Lua patterns.
]]
local plugin = {}
plugin.name = 'sed'
plugin.category = 'utility'
plugin.description = 'Regex-style find and replace on messages'
plugin.commands = {}
plugin.help = 's/pattern/replacement/ - Reply to a message to perform a find-and-replace using Lua patterns.'
function plugin.on_new_message(api, message, ctx)
if not message.text then return end
if not message.reply then return end
if not message.reply.text or message.reply.text == '' then return end
-- Match s/pattern/replacement/ or s/pattern/replacement (no trailing slash)
-- Support escaped forward slashes within the pattern/replacement
local pattern, replacement, flags = message.text:match('^s/(.-[^\\])/(.-[^\\]?)/([gi]*)$')
if not pattern then
pattern, replacement = message.text:match('^s/(.-[^\\])/(.-[^\\]?)/?$')
flags = ''
end
-- Handle edge case: empty replacement
if not pattern then
pattern = message.text:match('^s/(.-[^\\])//[gi]*$')
if pattern then replacement = '' end
end
if not pattern then
pattern = message.text:match('^s/(.-[^\\])/$')
if pattern then replacement = '' end
end
if not pattern or not replacement then return end
-- Unescape forward slashes
pattern = pattern:gsub('\\/', '/')
replacement = replacement:gsub('\\/', '/')
-- Validate the Lua pattern
local ok, err = pcall(string.find, '', pattern)
if not ok then
return api.send_message(message.chat.id, 'Invalid pattern: ' .. tostring(err))
end
+ -- Reject patterns that could cause catastrophic backtracking
+ if #pattern > 128 then
+ return api.send_message(message.chat.id, 'Pattern too long (max 128 characters).')
+ end
+ local wq_count = 0
+ do
+ local i = 1
+ while i <= #pattern do
+ if pattern:sub(i, i) == '%' then
+ i = i + 2
+ elseif pattern:sub(i, i) == '.' and i < #pattern then
+ local nc = pattern:sub(i + 1, i + 1)
+ if nc == '+' or nc == '*' or nc == '-' then wq_count = wq_count + 1 end
+ i = i + 1
+ else
+ i = i + 1
+ end
+ end
+ end
+ if wq_count > 3 then
+ return api.send_message(message.chat.id, 'Pattern too complex (too many wildcard repetitions).')
+ end
+
local original = message.reply.text
local result
if flags and flags:find('g') then
result = original:gsub(pattern, replacement)
else
result = original:gsub(pattern, replacement, 1)
end
if result == original then
return api.send_message(message.chat.id, 'No matches found for that pattern.')
end
local tools = require('telegram-bot-lua.tools')
local name = tools.escape_html(message.reply.from and message.reply.from.first_name or 'Unknown')
return api.send_message(
message.chat.id,
string.format('<b>%s</b> meant to say:\n%s', name, tools.escape_html(result)),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/utility/setlang.lua b/src/plugins/utility/setlang.lua
index a2656a5..918589b 100644
--- a/src/plugins/utility/setlang.lua
+++ b/src/plugins/utility/setlang.lua
@@ -1,68 +1,68 @@
--[[
mattata v2.0 - Set Language Plugin
Allows users to select their preferred language via inline keyboard.
]]
local plugin = {}
plugin.name = 'setlang'
plugin.category = 'utility'
plugin.description = 'Set your preferred language'
plugin.commands = { 'setlang', 'language', 'lang' }
plugin.help = '/setlang - Select your preferred language from the available options.'
local LANG_NAMES = {
en_gb = 'English (GB)',
en_us = 'English (US)',
de_de = 'Deutsch',
de_at = 'Deutsch (AT)',
ar_ar = 'العربية',
pl_pl = 'Polski',
pt_br = 'Português (BR)',
pt_pt = 'Português (PT)',
tr_tr = 'Türkçe',
scottish = 'Scottish'
}
function plugin.on_message(api, message, ctx)
local i18n = require('src.core.i18n')
local available = i18n.available()
-- Build keyboard with 2 languages per row
local keyboard = api.inline_keyboard()
local current_row = nil
for i, code in ipairs(available) do
if (i - 1) % 2 == 0 then
current_row = api.row()
end
local label = LANG_NAMES[code] or code
current_row:callback_data_button(label, 'setlang:set:' .. code)
if i % 2 == 0 or i == #available then
keyboard:row(current_row)
end
end
return api.send_message(
message.chat.id,
'Select your preferred language:',
- nil, true, false, nil, keyboard
+ { reply_markup = keyboard }
)
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local data = callback_query.data
local code = data:match('^set:(.+)$')
if not code then return end
local i18n = require('src.core.i18n')
if not i18n.exists(code) then
- return api.answer_callback_query(callback_query.id, 'Language not available.')
+ return api.answer_callback_query(callback_query.id, { text = 'Language not available.' })
end
ctx.session.set_setting(callback_query.from.id, 'language', code, 0)
local name = LANG_NAMES[code] or code
- api.answer_callback_query(callback_query.id, 'Language set to ' .. name .. '!')
+ api.answer_callback_query(callback_query.id, { text = 'Language set to ' .. name .. '!' })
return api.edit_message_text(
message.chat.id,
message.message_id,
string.format('Language set to <b>%s</b>.', name),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/utility/setloc.lua b/src/plugins/utility/setloc.lua
index 24ac2d6..5a83c6e 100644
--- a/src/plugins/utility/setloc.lua
+++ b/src/plugins/utility/setloc.lua
@@ -1,73 +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 https = require('ssl.https')
- local json = require('dkjson')
+ 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
),
- 'html'
+ { 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 body, status = https.request(api_url)
- if not body or status ~= 200 then
+ local data, status = 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
-
- local data = json.decode(body)
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
),
- 'html'
+ { parse_mode = 'html' }
)
end
return plugin
diff --git a/src/plugins/utility/share.lua b/src/plugins/utility/share.lua
index 8159212..f15de12 100644
--- a/src/plugins/utility/share.lua
+++ b/src/plugins/utility/share.lua
@@ -1,55 +1,55 @@
--[[
mattata v2.0 - Share Plugin
Creates a share button for a given URL.
]]
local plugin = {}
plugin.name = 'share'
plugin.category = 'utility'
plugin.description = 'Create a share button for a URL'
plugin.commands = { 'share' }
plugin.help = '/share <url> [text] - Create an inline share button for the given URL.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local url_lib = require('socket.url')
local input = message.args
if not input or input == '' then
return api.send_message(message.chat.id, 'Please provide a URL to share. Usage: /share <url> [text]')
end
-- Extract URL and optional text
local share_url, text = input:match('^(%S+)%s+(.+)$')
if not share_url then
share_url = input:match('^(%S+)$')
text = share_url
end
if not share_url then
return api.send_message(message.chat.id, 'Invalid URL provided.')
end
-- Add https:// if no protocol specified
if not share_url:match('^https?://') then
share_url = 'https://' .. share_url
end
local share_link = string.format(
'https://t.me/share/url?url=%s&text=%s',
url_lib.escape(share_url),
url_lib.escape(text or share_url)
)
local keyboard = api.inline_keyboard():row(
api.row():url_button('Share', share_link)
)
return api.send_message(
message.chat.id,
string.format('Press the button below to share <code>%s</code>.', tools.escape_html(share_url)),
- 'html', true, false, nil, keyboard
+ { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard }
)
end
return plugin
diff --git a/src/plugins/utility/statistics.lua b/src/plugins/utility/statistics.lua
index 2a155e1..0ac4129 100644
--- a/src/plugins/utility/statistics.lua
+++ b/src/plugins/utility/statistics.lua
@@ -1,73 +1,73 @@
--[[
mattata v2.0 - Statistics Plugin
Displays message statistics for the current chat.
]]
local plugin = {}
plugin.name = 'statistics'
plugin.category = 'utility'
plugin.description = 'View message statistics for this chat'
plugin.commands = { 'statistics', 'stats', 'morestats' }
plugin.help = '/stats - View top 10 most active users in this chat.\n/morestats - View extended stats.\n/stats reset - Reset statistics (admin only).'
plugin.group_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local input = message.args
-- Handle reset
if input and input:lower() == 'reset' then
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, 'You need to be an admin to reset statistics.')
end
ctx.db.call('sp_reset_message_stats', { message.chat.id })
return api.send_message(message.chat.id, 'Message statistics have been reset for this chat.')
end
-- Query top 10 users by message count
local result = ctx.db.call('sp_get_top_users', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No message statistics available for this chat yet.')
end
local lines = { '<b>Message Statistics</b>', '' }
local total_messages = 0
for i, row in ipairs(result) do
local name = tools.escape_html(row.first_name or 'Unknown')
if row.last_name then
name = name .. ' ' .. tools.escape_html(row.last_name)
end
local count = tonumber(row.total) or 0
total_messages = total_messages + count
table.insert(lines, string.format(
'%d. %s - <code>%d</code> messages',
i, name, count
))
end
table.insert(lines, '')
table.insert(lines, string.format('<i>Total (top 10): %d messages</i>', total_messages))
-- Extended stats for /morestats
if message.command == 'morestats' then
local total_result = ctx.db.call('sp_get_total_messages', { message.chat.id })
local unique_result = ctx.db.call('sp_get_unique_users', { message.chat.id })
if total_result and total_result[1] then
table.insert(lines, string.format(
'<i>All-time total: %s messages</i>',
total_result[1].total or '0'
))
end
if unique_result and unique_result[1] then
table.insert(lines, string.format(
'<i>Unique users: %s</i>',
unique_result[1].total or '0'
))
end
end
- return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/time.lua b/src/plugins/utility/time.lua
index e0c8c81..6949ee3 100644
--- a/src/plugins/utility/time.lua
+++ b/src/plugins/utility/time.lua
@@ -1,163 +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 https = require('ssl.https')
-local json = require('dkjson')
+local http = require('src.core.http')
local url = require('socket.url')
-local ltn12 = require('ltn12')
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 body = {}
- local _, code = https.request({
- url = request_url,
- sink = ltn12.sink.table(body),
- headers = {
- ['User-Agent'] = 'mattata-telegram-bot/2.0'
- }
- })
- if code ~= 200 then
+ local data, code = http.get_json(request_url)
+ if not data then
return nil, 'Geocoding request failed.'
end
- local data = json.decode(table.concat(body))
- if not data or #data == 0 then
+ 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)
- -- Use timeapi.io to get timezone from coordinates
local request_url = string.format(
'https://timeapi.io/api/TimeZone/coordinate?latitude=%.6f&longitude=%.6f',
lat, lon
)
- local body = {}
- local _, code = https.request({
- url = request_url,
- sink = ltn12.sink.table(body),
- headers = {
- ['User-Agent'] = 'mattata-telegram-bot/2.0'
- }
- })
- if code ~= 200 then
- return nil, 'Timezone lookup failed.'
- end
- local data = json.decode(table.concat(body))
+ local data, code = http.get_json(request_url)
if not data or not data.timeZone then
- return nil, 'Could not determine timezone for this location.'
+ 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>',
- 'html'
+ { 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'), 'html')
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/translate.lua b/src/plugins/utility/translate.lua
index 2a5acf3..0fc5fbc 100644
--- a/src/plugins/utility/translate.lua
+++ b/src/plugins/utility/translate.lua
@@ -1,147 +1,136 @@
--[[
mattata v2.0 - Translate Plugin
Translates text using LibreTranslate public API.
Supports auto-detection of source language.
]]
local plugin = {}
plugin.name = 'translate'
plugin.category = 'utility'
plugin.description = 'Translate text between languages'
plugin.commands = { 'translate', 'tl' }
plugin.help = '/translate [lang] <text> - Translate text to the specified language (default: en). Reply to a message to translate it, or provide text directly.'
-local https = require('ssl.https')
+local http = require('src.core.http')
local json = require('dkjson')
-local ltn12 = require('ltn12')
local tools = require('telegram-bot-lua.tools')
local BASE_URL = 'https://libretranslate.com'
-- Common language code aliases
local LANG_ALIASES = {
english = 'en', en = 'en',
spanish = 'es', es = 'es',
french = 'fr', fr = 'fr',
german = 'de', de = 'de',
italian = 'it', it = 'it',
portuguese = 'pt', pt = 'pt',
russian = 'ru', ru = 'ru',
chinese = 'zh', zh = 'zh',
japanese = 'ja', ja = 'ja',
korean = 'ko', ko = 'ko',
arabic = 'ar', ar = 'ar',
hindi = 'hi', hi = 'hi',
dutch = 'nl', nl = 'nl',
polish = 'pl', pl = 'pl',
turkish = 'tr', tr = 'tr',
swedish = 'sv', sv = 'sv',
czech = 'cs', cs = 'cs',
romanian = 'ro', ro = 'ro',
hungarian = 'hu', hu = 'hu',
ukrainian = 'uk', uk = 'uk',
indonesian = 'id', id = 'id',
finnish = 'fi', fi = 'fi',
hebrew = 'he', he = 'he',
thai = 'th', th = 'th',
vietnamese = 'vi', vi = 'vi',
greek = 'el', el = 'el'
}
local function translate_text(text, target, source)
source = source or 'auto'
local request_body = json.encode({
q = text,
source = source,
target = target,
format = 'text'
})
- local body = {}
- local _, code = https.request({
- url = BASE_URL .. '/translate',
- method = 'POST',
- headers = {
- ['Content-Type'] = 'application/json',
- ['Content-Length'] = tostring(#request_body)
- },
- source = ltn12.source.string(request_body),
- sink = ltn12.sink.table(body)
- })
+ local body, code = http.post(BASE_URL .. '/translate', request_body, 'application/json')
if code ~= 200 then
return nil, 'Translation service returned an error (HTTP ' .. tostring(code) .. '). The public instance may be rate-limited; try again shortly.'
end
- local data = json.decode(table.concat(body))
+ local data = json.decode(body)
if not data then
return nil, 'Failed to parse translation response.'
end
if data.error then
return nil, 'Translation error: ' .. tostring(data.error)
end
return {
translated = data.translatedText,
source_lang = data.detectedLanguage and data.detectedLanguage.language or source
}
end
function plugin.on_message(api, message, ctx)
local input = message.args
local text_to_translate
local target_lang = 'en'
-- If replying to a message, use that text
if message.reply and message.reply.text and message.reply.text ~= '' then
text_to_translate = message.reply.text
-- If args given, treat as target language
if input and input ~= '' then
local lang = input:match('^(%S+)')
if lang then
lang = lang:lower()
target_lang = LANG_ALIASES[lang] or lang
end
end
elseif input and input ~= '' then
-- Parse: /translate [lang] <text>
local first_word, rest = input:match('^(%S+)%s+(.+)$')
if first_word then
local resolved = LANG_ALIASES[first_word:lower()]
if resolved then
target_lang = resolved
text_to_translate = rest
elseif first_word:match('^%a%a$') or first_word:match('^%a%a%a$') then
-- Assume it's a language code even if not in our alias table
target_lang = first_word:lower()
text_to_translate = rest
else
-- No language specified, translate the whole input to English
text_to_translate = input
end
else
text_to_translate = input
end
end
if not text_to_translate or text_to_translate == '' then
return api.send_message(
message.chat.id,
'Please provide text to translate.\nUsage: <code>/translate [lang] text</code>\nOr reply to a message with <code>/translate [lang]</code>',
- 'html'
+ { parse_mode = 'html' }
)
end
local result, err = translate_text(text_to_translate, target_lang)
if not result then
return api.send_message(message.chat.id, err)
end
local source_label = result.source_lang ~= 'auto' and result.source_lang:upper() or '??'
local output = string.format(
'<b>Translation</b> [%s -> %s]\n\n%s',
tools.escape_html(source_label),
tools.escape_html(target_lang:upper()),
tools.escape_html(result.translated)
)
- return api.send_message(message.chat.id, output, 'html')
+ return api.send_message(message.chat.id, output, { parse_mode = 'html' })
end
return plugin
diff --git a/src/plugins/utility/urbandictionary.lua b/src/plugins/utility/urbandictionary.lua
index 2c26bdd..63ebb83 100644
--- a/src/plugins/utility/urbandictionary.lua
+++ b/src/plugins/utility/urbandictionary.lua
@@ -1,72 +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 https = require('ssl.https')
- local json = require('dkjson')
+ 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 body, status = https.request(api_url)
- if not body or status ~= 200 then
+ local data, code = 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
-
- local data = json.decode(body)
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'), 'html')
+ 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 2f87552..e40d9c6 100644
--- a/src/plugins/utility/weather.lua
+++ b/src/plugins/utility/weather.lua
@@ -1,168 +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', 'w' }
+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 https = require('ssl.https')
-local json = require('dkjson')
+local http = require('src.core.http')
local url = require('socket.url')
-local ltn12 = require('ltn12')
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 body = {}
- local _, code = https.request({
- url = request_url,
- sink = ltn12.sink.table(body),
- headers = {
- ['User-Agent'] = 'mattata-telegram-bot/2.0'
- }
- })
- if code ~= 200 then
+ local data, code = http.get_json(request_url)
+ if not data then
return nil, 'Geocoding request failed.'
end
- local data = json.decode(table.concat(body))
- if not data or #data == 0 then
+ 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 body = {}
- local _, code = https.request({
- url = request_url,
- sink = ltn12.sink.table(body)
- })
- if code ~= 200 then
- return nil, 'Weather API request failed.'
- end
- local data = json.decode(table.concat(body))
+ local data, code = http.get_json(request_url)
if not data or not data.current then
- return nil, 'Failed to parse weather data.'
+ 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>',
- 'html'
+ { 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, 'html')
+ 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 f28a1b3..069b252 100644
--- a/src/plugins/utility/wikipedia.lua
+++ b/src/plugins/utility/wikipedia.lua
@@ -1,151 +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 https = require('ssl.https')
-local json = require('dkjson')
+local http = require('src.core.http')
local url = require('socket.url')
-local ltn12 = require('ltn12')
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)
- -- Use the REST API summary endpoint via search
local search_url = string.format(
'https://%s.wikipedia.org/api/rest_v1/page/summary/%s?redirect=true',
lang, encoded
)
- local body = {}
- local _, code = https.request({
- url = search_url,
- sink = ltn12.sink.table(body),
- headers = {
- ['User-Agent'] = 'mattata-telegram-bot/2.0',
- ['Accept'] = 'application/json'
- }
- })
- -- If direct lookup fails, try the search API
- if code ~= 200 then
+ local data, code = http.get_json(search_url, { ['Accept'] = 'application/json' })
+ if not data then
return search_wikipedia_fallback(query, lang)
end
- local data = json.decode(table.concat(body))
- if not data or data.type == 'not_found' or data.type == 'https://mediawiki.org/wiki/HyperSwitch/errors/not_found' then
+ 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 body = {}
- local _, code = https.request({
- url = search_url,
- sink = ltn12.sink.table(body),
- headers = {
- ['User-Agent'] = 'mattata-telegram-bot/2.0'
- }
- })
- if code ~= 200 then
+ local data, code = http.get_json(search_url)
+ if not data then
return nil, 'Wikipedia search failed (HTTP ' .. tostring(code) .. ').'
end
- local data = json.decode(table.concat(body))
- if not data or not data[2] or #data[2] == 0 then
+ 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
)
- body = {}
- _, code = https.request({
- url = summary_url,
- sink = ltn12.sink.table(body),
- headers = {
- ['User-Agent'] = 'mattata-telegram-bot/2.0',
- ['Accept'] = 'application/json'
- }
- })
- if code ~= 200 then
- return nil, 'Failed to retrieve article summary.'
- end
- local summary = json.decode(table.concat(body))
+ local summary, summary_code = http.get_json(summary_url, { ['Accept'] = 'application/json' })
if not summary then
- return nil, 'Failed to parse article summary.'
+ 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>',
- 'html'
+ { 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, 'html', true)
+ 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'), 'html', true)
+ 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 a27a1ac..1649a71 100644
--- a/src/plugins/utility/xkcd.lua
+++ b/src/plugins/utility/xkcd.lua
@@ -1,68 +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 https = require('ssl.https')
- local json = require('dkjson')
+ 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_body, latest_status = https.request('https://xkcd.com/info.0.json')
- if latest_body and latest_status == 200 then
- local latest = json.decode(latest_body)
- 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
+ local latest, latest_code = 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 body, status = https.request(api_url)
- if not body or status ~= 200 then
+ local data, code = 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
-
- local data = json.decode(body)
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, 'html', false, nil, keyboard)
+ 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, 'html')
+ 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, 6:27 AM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
57009
Default Alt Text
(577 KB)

Event Timeline