Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
711 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..aa62185
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+.env
+.vscode
+.idea
+backups/
+downloads/
+pgdata/
+redisdata/
+*.md
+LICENSE
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3f865a1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,46 @@
+# mattata v2.0 Configuration
+# Copy to .env and fill in required values
+
+# Required
+BOT_TOKEN=
+BOT_ADMINS=221714512
+BOT_NAME=mattata
+
+# PostgreSQL (primary database)
+DATABASE_HOST=postgres
+DATABASE_PORT=5432
+DATABASE_NAME=mattata
+DATABASE_USER=mattata
+DATABASE_PASSWORD=changeme
+
+# Redis (cache/sessions only)
+REDIS_HOST=redis
+REDIS_PORT=6379
+REDIS_PASSWORD=
+REDIS_DB=0
+
+# Mode: polling (default) or webhook
+WEBHOOK_ENABLED=false
+WEBHOOK_URL=
+WEBHOOK_PORT=8443
+WEBHOOK_SECRET=
+POLLING_TIMEOUT=60
+POLLING_LIMIT=100
+
+# AI (disabled by default)
+AI_ENABLED=false
+OPENAI_API_KEY=
+OPENAI_MODEL=gpt-4o
+ANTHROPIC_API_KEY=
+ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
+
+# Optional API keys (core features work without any of these)
+LASTFM_API_KEY=
+YOUTUBE_API_KEY=
+SPOTIFY_CLIENT_ID=
+SPOTIFY_CLIENT_SECRET=
+SPAMWATCH_TOKEN=
+
+# Logging
+LOG_CHAT=
+DEBUG=false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..231ba0c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+# Environment
+.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
new file mode 100644
index 0000000..170b6a6
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,100 @@
+# 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
+
+-- 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.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/`:
+
+```lua
+local migration = {}
+function migration.up()
+ return [[
+ CREATE TABLE IF NOT EXISTS my_table (
+ id SERIAL PRIMARY KEY,
+ ...
+ )
+ ]]
+end
+return migration
+```
+
+### 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/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..44bcebd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,65 @@
+# mattata v2.0 - Multi-stage Docker build
+# Builder stage: compile Lua and dependencies
+FROM ubuntu:22.04 AS builder
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ wget \
+ unzip \
+ libreadline-dev \
+ libssl-dev \
+ git \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Lua 5.3
+RUN cd /tmp && \
+ wget -q https://www.lua.org/ftp/lua-5.3.6.tar.gz && \
+ tar xzf lua-5.3.6.tar.gz && \
+ cd lua-5.3.6 && \
+ make linux && \
+ make install && \
+ cd / && rm -rf /tmp/lua-5.3.6*
+
+# Install LuaRocks
+RUN cd /tmp && \
+ wget -q https://luarocks.org/releases/luarocks-3.9.2.tar.gz && \
+ tar xzf luarocks-3.9.2.tar.gz && \
+ cd luarocks-3.9.2 && \
+ ./configure --with-lua=/usr/local && \
+ make && make install && \
+ cd / && rm -rf /tmp/luarocks-3.9.2*
+
+# Install Lua dependencies
+RUN luarocks install telegram-bot-lua && \
+ luarocks install pgmoon && \
+ luarocks install redis-lua && \
+ luarocks install dkjson && \
+ luarocks install luasocket && \
+ luarocks install luasec && \
+ luarocks install luautf8
+
+# Runtime stage
+FROM ubuntu:22.04
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update && apt-get install -y \
+ libreadline8 \
+ libssl3 \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy Lua installation from builder
+COPY --from=builder /usr/local /usr/local
+
+# Set up app directory
+WORKDIR /app
+COPY . /app
+
+# Run as non-root
+RUN useradd -m mattata
+USER mattata
+
+CMD ["lua", "main.lua"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bd3b2cf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,130 @@
+# 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
+- **100+ Plugins** - Weather, translate, search, currency, Wikipedia, AI chat, and more
+- **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
+│ │ ├── 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)
+│ │ └── ai/ # LLM integration
+│ ├── db/migrations/ # PostgreSQL schema migrations
+│ ├── languages/ # 10 language packs
+│ └── data/ # Static data (slaps, join messages)
+├── docker-compose.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.yml b/docker-compose.yml
new file mode 100644
index 0000000..4f75fb4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,45 @@
+version: '3.8'
+
+services:
+ bot:
+ build: .
+ env_file: .env
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ 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:
diff --git a/main.lua b/main.lua
new file mode 100644
index 0000000..e504c0e
--- /dev/null
+++ b/main.lua
@@ -0,0 +1,94 @@
+--[[
+ _ _ _
+ _ __ ___ __ _| |_| |_ __ _| |_ __ _
+ | '_ ` _ \ / _` | __| __/ _` | __/ _` |
+ | | | | | | (_| | |_| || (_| | || (_| |
+ |_| |_| |_|\__,_|\__|\__\__,_|\__\__,_|
+
+ v2.0
+
+ 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.core').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
+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
+logger.info('Starting main loop...')
+router.run()
diff --git a/mattata-2.0-0.rockspec b/mattata-2.0-0.rockspec
new file mode 100644
index 0000000..ae3be6d
--- /dev/null
+++ b/mattata-2.0-0.rockspec
@@ -0,0 +1,32 @@
+package = 'mattata'
+version = '2.0-0'
+source = {
+ url = 'git://github.com/wrxck/mattata.git'
+}
+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.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/core/config_spec.lua b/spec/core/config_spec.lua
new file mode 100644
index 0000000..a1c8ddc
--- /dev/null
+++ b/spec/core/config_spec.lua
@@ -0,0 +1,364 @@
+--[[
+ Tests for src/core/config.lua
+ Config module: loading .env, get/set values, typed access (number, boolean, list).
+]]
+
+describe('core.config', function()
+ local config
+ local tmpfile
+
+ -- Write a temporary .env file for testing
+ local function write_env(content)
+ tmpfile = os.tmpname()
+ local f = io.open(tmpfile, 'w')
+ f:write(content)
+ f:close()
+ return tmpfile
+ end
+
+ before_each(function()
+ -- Clear the cached module so each test gets a fresh config
+ package.loaded['src.core.config'] = nil
+ config = require('src.core.config')
+ end)
+
+ after_each(function()
+ if tmpfile then
+ os.remove(tmpfile)
+ tmpfile = nil
+ end
+ end)
+
+ describe('load()', function()
+ it('should load a valid .env file', function()
+ local path = write_env('FOO=bar\nBAZ=qux\n')
+ config.load(path)
+ assert.are.equal('bar', config.get('FOO'))
+ assert.are.equal('qux', config.get('BAZ'))
+ end)
+
+ it('should not error on missing .env file', function()
+ assert.has_no.errors(function()
+ config.load('/tmp/nonexistent_env_file_' .. os.time())
+ end)
+ end)
+
+ it('should ignore empty lines', function()
+ local path = write_env('FOO=bar\n\n\nBAZ=qux\n')
+ config.load(path)
+ assert.are.equal('bar', config.get('FOO'))
+ assert.are.equal('qux', config.get('BAZ'))
+ end)
+
+ it('should ignore comment lines', function()
+ local path = write_env('# This is a comment\nFOO=bar\n# Another comment\n')
+ config.load(path)
+ assert.are.equal('bar', config.get('FOO'))
+ end)
+
+ it('should strip surrounding double quotes from values', function()
+ local path = write_env('FOO="hello world"\n')
+ config.load(path)
+ assert.are.equal('hello world', config.get('FOO'))
+ end)
+
+ it('should strip surrounding single quotes from values', function()
+ local path = write_env("FOO='hello world'\n")
+ config.load(path)
+ assert.are.equal('hello world', config.get('FOO'))
+ end)
+
+ it('should strip inline comments from unquoted values', function()
+ local path = write_env('FOO=bar # this is a comment\n')
+ config.load(path)
+ assert.are.equal('bar', config.get('FOO'))
+ end)
+
+ it('should handle values with equals signs', function()
+ local path = write_env('FOO=bar=baz\n')
+ config.load(path)
+ assert.are.equal('bar=baz', config.get('FOO'))
+ end)
+
+ it('should trim whitespace around keys and values', function()
+ local path = write_env(' FOO = bar \n')
+ config.load(path)
+ assert.are.equal('bar', config.get('FOO'))
+ end)
+ end)
+
+ describe('get()', function()
+ it('should return the value for a known key', function()
+ local path = write_env('MY_KEY=my_value\n')
+ config.load(path)
+ assert.are.equal('my_value', config.get('MY_KEY'))
+ end)
+
+ it('should return default when key is missing', function()
+ local path = write_env('OTHER_KEY=other\n')
+ config.load(path)
+ assert.are.equal('fallback', config.get('NONEXISTENT', 'fallback'))
+ end)
+
+ it('should return nil when key is missing and no default', function()
+ local path = write_env('')
+ config.load(path)
+ assert.is_nil(config.get('NONEXISTENT'))
+ end)
+
+ it('should fall back to os.getenv for empty .env values', function()
+ local path = write_env('EMPTY_KEY=\n')
+ config.load(path)
+ -- This will either return nil or whatever the OS env has
+ local result = config.get('EMPTY_KEY', 'default_val')
+ assert.is_not_nil(result)
+ end)
+
+ it('should auto-load .env if not explicitly loaded', function()
+ -- Just calling get() without load() should not error
+ assert.has_no.errors(function()
+ config.get('ANYTHING')
+ end)
+ end)
+ end)
+
+ describe('get_number()', function()
+ it('should return a number for numeric values', function()
+ local path = write_env('PORT=8080\n')
+ config.load(path)
+ assert.are.equal(8080, config.get_number('PORT'))
+ end)
+
+ it('should return default for non-numeric values', function()
+ local path = write_env('PORT=abc\n')
+ config.load(path)
+ assert.are.equal(3000, config.get_number('PORT', 3000))
+ end)
+
+ it('should return default when key is missing', function()
+ local path = write_env('')
+ config.load(path)
+ assert.are.equal(5432, config.get_number('DB_PORT', 5432))
+ end)
+
+ it('should return nil when key is missing and no default', function()
+ local path = write_env('')
+ config.load(path)
+ assert.is_nil(config.get_number('MISSING'))
+ end)
+
+ it('should handle float values', function()
+ local path = write_env('RATE=1.5\n')
+ config.load(path)
+ assert.are.equal(1.5, config.get_number('RATE'))
+ end)
+
+ it('should handle negative numbers', function()
+ local path = write_env('OFFSET=-10\n')
+ config.load(path)
+ assert.are.equal(-10, config.get_number('OFFSET'))
+ end)
+ end)
+
+ describe('is_enabled()', function()
+ it('should return true for "true"', function()
+ local path = write_env('FLAG=true\n')
+ config.load(path)
+ assert.is_true(config.is_enabled('FLAG'))
+ end)
+
+ it('should return true for "1"', function()
+ local path = write_env('FLAG=1\n')
+ config.load(path)
+ assert.is_true(config.is_enabled('FLAG'))
+ end)
+
+ it('should return true for "yes"', function()
+ local path = write_env('FLAG=yes\n')
+ config.load(path)
+ assert.is_true(config.is_enabled('FLAG'))
+ end)
+
+ it('should return true case-insensitively for "TRUE"', function()
+ local path = write_env('FLAG=TRUE\n')
+ config.load(path)
+ assert.is_true(config.is_enabled('FLAG'))
+ end)
+
+ it('should return true case-insensitively for "Yes"', function()
+ local path = write_env('FLAG=Yes\n')
+ config.load(path)
+ assert.is_true(config.is_enabled('FLAG'))
+ end)
+
+ it('should return false for "false"', function()
+ local path = write_env('FLAG=false\n')
+ config.load(path)
+ assert.is_false(config.is_enabled('FLAG'))
+ end)
+
+ it('should return false for "0"', function()
+ local path = write_env('FLAG=0\n')
+ config.load(path)
+ assert.is_false(config.is_enabled('FLAG'))
+ end)
+
+ it('should return false for "no"', function()
+ local path = write_env('FLAG=no\n')
+ config.load(path)
+ assert.is_false(config.is_enabled('FLAG'))
+ end)
+
+ it('should return false for missing key', function()
+ local path = write_env('')
+ config.load(path)
+ assert.is_false(config.is_enabled('MISSING'))
+ end)
+
+ it('should return false for arbitrary string', function()
+ local path = write_env('FLAG=maybe\n')
+ config.load(path)
+ assert.is_false(config.is_enabled('FLAG'))
+ end)
+ end)
+
+ describe('get_list()', function()
+ it('should split comma-separated values into a table', function()
+ local path = write_env('ITEMS=a,b,c\n')
+ config.load(path)
+ local list = config.get_list('ITEMS')
+ assert.are.equal(3, #list)
+ assert.are.equal('a', list[1])
+ assert.are.equal('b', list[2])
+ assert.are.equal('c', list[3])
+ end)
+
+ it('should trim whitespace around items', function()
+ local path = write_env('ITEMS= a , b , c \n')
+ config.load(path)
+ local list = config.get_list('ITEMS')
+ assert.are.equal(3, #list)
+ assert.are.equal('a', list[1])
+ assert.are.equal('b', list[2])
+ assert.are.equal('c', list[3])
+ end)
+
+ it('should convert numeric items to numbers', function()
+ local path = write_env('IDS=100,200,300\n')
+ config.load(path)
+ local list = config.get_list('IDS')
+ assert.are.equal(3, #list)
+ assert.are.equal(100, list[1])
+ assert.are.equal(200, list[2])
+ assert.are.equal(300, list[3])
+ end)
+
+ it('should return empty table for missing key', function()
+ local path = write_env('')
+ config.load(path)
+ local list = config.get_list('MISSING')
+ assert.are.same({}, list)
+ end)
+
+ it('should return empty table for empty value', function()
+ local path = write_env('ITEMS=\n')
+ config.load(path)
+ local list = config.get_list('ITEMS')
+ assert.are.same({}, list)
+ end)
+
+ it('should handle single-item list', function()
+ local path = write_env('ITEMS=only\n')
+ config.load(path)
+ local list = config.get_list('ITEMS')
+ assert.are.equal(1, #list)
+ assert.are.equal('only', list[1])
+ end)
+
+ it('should handle mixed numeric and string items', function()
+ local path = write_env('ITEMS=100,hello,300\n')
+ config.load(path)
+ local list = config.get_list('ITEMS')
+ assert.are.equal(100, list[1])
+ assert.are.equal('hello', list[2])
+ assert.are.equal(300, list[3])
+ end)
+ end)
+
+ describe('convenience accessors', function()
+ it('should return bot_token', function()
+ local path = write_env('BOT_TOKEN=12345:ABCDEF\n')
+ config.load(path)
+ assert.are.equal('12345:ABCDEF', config.bot_token())
+ end)
+
+ it('should return bot_admins as a list', function()
+ local path = write_env('BOT_ADMINS=221714512,123456\n')
+ config.load(path)
+ local admins = config.bot_admins()
+ assert.are.equal(2, #admins)
+ assert.are.equal(221714512, admins[1])
+ end)
+
+ it('should return bot_name with default', function()
+ local path = write_env('')
+ config.load(path)
+ assert.are.equal('mattata', config.bot_name())
+ end)
+
+ it('should return database config with defaults', function()
+ local path = write_env('')
+ config.load(path)
+ local db = config.database()
+ assert.are.equal('postgres', db.host)
+ assert.are.equal(5432, db.port)
+ assert.are.equal('mattata', db.database)
+ end)
+
+ it('should return redis config with defaults', function()
+ local path = write_env('')
+ config.load(path)
+ local rc = config.redis_config()
+ assert.are.equal('redis', rc.host)
+ assert.are.equal(6379, rc.port)
+ assert.are.equal(0, rc.db)
+ end)
+
+ it('should return polling config with defaults', function()
+ local path = write_env('')
+ config.load(path)
+ local p = config.polling()
+ assert.are.equal(60, p.timeout)
+ assert.are.equal(100, p.limit)
+ end)
+
+ it('should return webhook config', function()
+ local path = write_env('WEBHOOK_ENABLED=true\nWEBHOOK_URL=https://example.com\nWEBHOOK_PORT=8443\n')
+ config.load(path)
+ local wh = config.webhook()
+ assert.is_true(wh.enabled)
+ assert.are.equal('https://example.com', wh.url)
+ assert.are.equal(8443, wh.port)
+ end)
+
+ it('should return debug status', function()
+ local path = write_env('DEBUG=true\n')
+ config.load(path)
+ assert.is_true(config.debug())
+ end)
+
+ it('should return ai config with defaults', function()
+ local path = write_env('')
+ config.load(path)
+ local ai = config.ai()
+ assert.is_false(ai.enabled)
+ assert.are.equal('gpt-4o', ai.openai_model)
+ end)
+ end)
+
+ describe('VERSION', function()
+ it('should be 2.0', function()
+ assert.are.equal('2.0', config.VERSION)
+ end)
+ end)
+end)
diff --git a/spec/core/database_spec.lua b/spec/core/database_spec.lua
new file mode 100644
index 0000000..d435fc0
--- /dev/null
+++ b/spec/core/database_spec.lua
@@ -0,0 +1,176 @@
+--[[
+ Tests for src/core/database.lua
+ Tests connection pooling, query execution, insert, upsert, and transactions.
+ Uses mock_db to avoid real PostgreSQL connections.
+]]
+
+describe('core.database (mock)', function()
+ local mock_db = require('spec.helpers.mock_db')
+ local db
+
+ before_each(function()
+ db = mock_db.new()
+ end)
+
+ after_each(function()
+ db.reset()
+ end)
+
+ describe('query()', function()
+ it('should record SQL queries', function()
+ db.query('SELECT 1')
+ assert.are.equal(1, #db.queries)
+ assert.are.equal('SELECT 1', db.queries[1].sql)
+ end)
+
+ it('should return empty table by default', function()
+ local result = db.query('SELECT * FROM users')
+ assert.are.same({}, result)
+ end)
+
+ it('should return configured next_result', function()
+ local expected = { { user_id = 1, username = 'test' } }
+ db.set_next_result(expected)
+ local result = db.query('SELECT * FROM users')
+ assert.are.same(expected, result)
+ end)
+
+ it('should consume next_result after one use', function()
+ db.set_next_result({ { id = 1 } })
+ db.query('SELECT 1')
+ local result = db.query('SELECT 2')
+ assert.are.same({}, result)
+ end)
+
+ it('should consume results from the queue in order', function()
+ db.queue_result({ { id = 1 } })
+ db.queue_result({ { id = 2 } })
+ local r1 = db.query('SELECT 1')
+ local r2 = db.query('SELECT 2')
+ assert.are.equal(1, r1[1].id)
+ assert.are.equal(2, r2[1].id)
+ end)
+ end)
+
+ describe('execute()', function()
+ it('should record parameterized queries', function()
+ db.execute('SELECT * FROM users WHERE user_id = $1', { 12345 })
+ assert.are.equal(1, #db.queries)
+ assert.are.same({ 12345 }, db.queries[1].params)
+ end)
+
+ it('should return configured next_result', function()
+ local expected = { { count = 5 } }
+ db.set_next_result(expected)
+ local result = db.execute('SELECT COUNT(*) FROM users', {})
+ assert.are.same(expected, result)
+ end)
+
+ it('should record multiple execute calls', function()
+ db.execute('INSERT INTO users (id) VALUES ($1)', { 1 })
+ db.execute('INSERT INTO users (id) VALUES ($1)', { 2 })
+ assert.are.equal(2, #db.queries)
+ end)
+ end)
+
+ describe('insert()', function()
+ it('should record the insert operation', function()
+ db.insert('users', { user_id = 123, username = 'test' })
+ assert.are.equal(1, #db.queries)
+ assert.are.equal('insert', db.queries[1].op)
+ assert.are.equal('users', db.queries[1].table_name)
+ assert.are.equal(123, db.queries[1].data.user_id)
+ end)
+
+ it('should store data in the data table', function()
+ db.insert('users', { user_id = 123 })
+ assert.are.equal(1, #db.data['users'])
+ assert.are.equal(123, db.data['users'][1].user_id)
+ end)
+
+ it('should return inserted row wrapped in table', function()
+ local result = db.insert('users', { user_id = 123 })
+ assert.are.equal(1, #result)
+ assert.are.equal(123, result[1].user_id)
+ end)
+
+ it('should handle multiple inserts into same table', function()
+ db.insert('users', { user_id = 1 })
+ db.insert('users', { user_id = 2 })
+ assert.are.equal(2, #db.data['users'])
+ end)
+ end)
+
+ describe('upsert()', function()
+ it('should record the upsert operation', function()
+ db.upsert('users', { user_id = 123, username = 'test' }, { 'user_id' }, { 'username' })
+ assert.are.equal(1, #db.queries)
+ assert.are.equal('upsert', db.queries[1].op)
+ assert.are.equal('users', db.queries[1].table_name)
+ end)
+
+ it('should store data in the data table', function()
+ db.upsert('users', { user_id = 123, username = 'test' }, { 'user_id' }, { 'username' })
+ assert.are.equal(1, #db.data['users'])
+ end)
+
+ it('should record conflict and update keys', function()
+ db.upsert('users', { user_id = 123 }, { 'user_id' }, { 'username', 'last_seen' })
+ assert.are.same({ 'user_id' }, db.queries[1].conflict_keys)
+ assert.are.same({ 'username', 'last_seen' }, db.queries[1].update_keys)
+ end)
+ end)
+
+ describe('transaction()', function()
+ it('should call the provided function with query and execute', function()
+ local called = false
+ db.transaction(function(query, execute)
+ called = true
+ assert.is_function(query)
+ assert.is_function(execute)
+ end)
+ assert.is_true(called)
+ end)
+
+ it('should return the result of the function', function()
+ local result = db.transaction(function(query, execute)
+ return 'success'
+ end)
+ assert.are.equal('success', result)
+ end)
+ end)
+
+ describe('pool_stats()', function()
+ it('should return available and max_size', function()
+ local stats = db.pool_stats()
+ assert.is_not_nil(stats.available)
+ assert.is_not_nil(stats.max_size)
+ assert.are.equal(5, stats.available)
+ assert.are.equal(10, stats.max_size)
+ end)
+ end)
+
+ describe('reset()', function()
+ it('should clear all recorded state', function()
+ db.insert('users', { user_id = 1 })
+ db.execute('SELECT 1', {})
+ db.set_next_result({ { id = 1 } })
+ db.reset()
+ assert.are.same({}, db.queries)
+ assert.are.same({}, db.data)
+ assert.is_nil(db.next_result)
+ end)
+ end)
+
+ describe('has_query()', function()
+ it('should return true when a matching query exists', function()
+ db.execute('SELECT * FROM users WHERE user_id = $1', { 1 })
+ assert.is_true(db.has_query('SELECT.*FROM users'))
+ end)
+
+ it('should return false when no matching query exists', function()
+ db.execute('SELECT 1', {})
+ assert.is_false(db.has_query('INSERT'))
+ end)
+ end)
+end)
diff --git a/spec/core/i18n_spec.lua b/spec/core/i18n_spec.lua
new file mode 100644
index 0000000..afc15ae
--- /dev/null
+++ b/spec/core/i18n_spec.lua
@@ -0,0 +1,177 @@
+--[[
+ Tests for src/core/i18n.lua
+ Tests language loading, get, exists, translate with interpolation.
+]]
+
+describe('core.i18n', function()
+ local i18n
+
+ -- Mock language tables
+ local mock_en_gb = {
+ errors = {
+ connection = 'Connection error.',
+ admin = 'You need to be an admin.',
+ },
+ ban = {
+ success = '{admin} has banned {target}.',
+ specify = 'Please specify the user to ban.',
+ },
+ help = {
+ greeting = 'Hey {name}!',
+ },
+ }
+
+ local mock_de_de = {
+ errors = {
+ connection = 'Verbindungsfehler.',
+ admin = 'Du musst ein Admin sein.',
+ },
+ ban = {
+ success = '{admin} hat {target} gebannt.',
+ },
+ }
+
+ before_each(function()
+ package.loaded['src.core.i18n'] = nil
+ package.loaded['src.core.config'] = {
+ get = function(key, default) return default end,
+ is_enabled = function() return false end,
+ load = function() end,
+ debug = function() return false end,
+ VERSION = '2.0',
+ }
+ package.loaded['src.core.logger'] = {
+ debug = function() end,
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ }
+
+ -- Mock the language registry and language files
+ package.loaded['src.languages.init'] = {
+ en_gb = 'src.languages.en_gb',
+ de_de = 'src.languages.de_de',
+ }
+ package.loaded['src.languages.en_gb'] = mock_en_gb
+ package.loaded['src.languages.de_de'] = mock_de_de
+
+ i18n = require('src.core.i18n')
+ i18n.init()
+ end)
+
+ describe('init()', function()
+ it('should load languages from the registry', function()
+ assert.is_true(i18n.count() > 0)
+ end)
+
+ it('should load exactly the number of languages in the registry', function()
+ assert.are.equal(2, i18n.count())
+ end)
+ end)
+
+ describe('get()', function()
+ it('should return a language table by code', function()
+ local lang = i18n.get('en_gb')
+ assert.is_not_nil(lang)
+ assert.are.equal('Connection error.', lang.errors.connection)
+ end)
+
+ it('should return German language', function()
+ local lang = i18n.get('de_de')
+ assert.is_not_nil(lang)
+ assert.are.equal('Verbindungsfehler.', lang.errors.connection)
+ end)
+
+ it('should fall back to en_gb for unknown code', function()
+ local lang = i18n.get('zz_zz')
+ assert.is_not_nil(lang)
+ assert.are.equal('Connection error.', lang.errors.connection)
+ end)
+
+ it('should fall back to en_gb for nil code', function()
+ local lang = i18n.get(nil)
+ assert.is_not_nil(lang)
+ assert.are.equal('Connection error.', lang.errors.connection)
+ end)
+ end)
+
+ describe('exists()', function()
+ it('should return true for loaded languages', function()
+ assert.is_true(i18n.exists('en_gb'))
+ assert.is_true(i18n.exists('de_de'))
+ end)
+
+ it('should return false for unloaded languages', function()
+ assert.is_false(i18n.exists('zz_zz'))
+ assert.is_false(i18n.exists('fr_fr'))
+ end)
+ end)
+
+ describe('available()', function()
+ it('should return sorted list of codes', function()
+ local codes = i18n.available()
+ assert.are.equal(2, #codes)
+ -- Sorted alphabetically
+ assert.are.equal('de_de', codes[1])
+ assert.are.equal('en_gb', codes[2])
+ end)
+ end)
+
+ describe('count()', function()
+ it('should return the number of loaded languages', function()
+ assert.are.equal(2, i18n.count())
+ end)
+ end)
+
+ describe('t() - translation', function()
+ it('should traverse nested keys', function()
+ local result = i18n.t(mock_en_gb, 'errors', 'connection')
+ assert.are.equal('Connection error.', result)
+ end)
+
+ it('should return nil for missing key', function()
+ local result = i18n.t(mock_en_gb, 'nonexistent', 'key')
+ assert.is_nil(result)
+ end)
+
+ it('should return nil for partially missing key', function()
+ local result = i18n.t(mock_en_gb, 'errors', 'nonexistent')
+ assert.is_nil(result)
+ end)
+
+ it('should interpolate variables', function()
+ local result = i18n.t(mock_en_gb, 'ban', 'success', { admin = 'Alice', target = 'Bob' })
+ assert.are.equal('Alice has banned Bob.', result)
+ end)
+
+ it('should handle multiple interpolation variables', function()
+ local result = i18n.t(mock_en_gb, 'help', 'greeting', { name = 'Matt' })
+ assert.are.equal('Hey Matt!', result)
+ end)
+
+ it('should accept language code string as first arg', function()
+ local result = i18n.t('en_gb', 'errors', 'connection')
+ assert.are.equal('Connection error.', result)
+ end)
+
+ it('should fall back to default lang for unknown code string', function()
+ local result = i18n.t('zz_zz', 'errors', 'connection')
+ assert.are.equal('Connection error.', result)
+ end)
+
+ it('should return nil when value is a table not a string', function()
+ local result = i18n.t(mock_en_gb, 'errors')
+ assert.is_nil(result)
+ end)
+
+ it('should handle nil lang_table by falling back', function()
+ local result = i18n.t(nil, 'errors', 'connection')
+ assert.are.equal('Connection error.', result)
+ end)
+
+ it('should return simple string without interpolation', function()
+ local result = i18n.t(mock_en_gb, 'ban', 'specify')
+ assert.are.equal('Please specify the user to ban.', result)
+ end)
+ end)
+end)
diff --git a/spec/core/loader_spec.lua b/spec/core/loader_spec.lua
new file mode 100644
index 0000000..9be4d7c
--- /dev/null
+++ b/spec/core/loader_spec.lua
@@ -0,0 +1,307 @@
+--[[
+ Tests for src/core/loader.lua
+ Tests plugin registration, command lookup, category lookup, is_permanent, reload.
+ Uses mock plugins to avoid loading real plugin files.
+]]
+
+describe('core.loader', function()
+ local loader
+
+ -- Fake plugins for testing
+ local fake_ping = {
+ name = 'ping',
+ category = 'utility',
+ commands = { 'ping', 'pong' },
+ help = '/ping - PONG!',
+ description = 'Check bot responsiveness',
+ on_message = function() end,
+ }
+
+ local fake_help = {
+ name = 'help',
+ category = 'utility',
+ commands = { 'help', 'start' },
+ help = '/help [command]',
+ description = 'View help',
+ permanent = true,
+ on_message = function() end,
+ }
+
+ local fake_ban = {
+ name = 'ban',
+ category = 'admin',
+ commands = { 'ban', 'b' },
+ help = '/ban [user]',
+ description = 'Ban users',
+ admin_only = true,
+ group_only = true,
+ on_message = function() end,
+ }
+
+ local fake_about = {
+ name = 'about',
+ category = 'utility',
+ commands = { 'about' },
+ help = '/about',
+ description = 'About the bot',
+ on_message = function() end,
+ }
+
+ local fake_plugins = {
+ name = 'plugins',
+ category = 'utility',
+ commands = { 'plugins' },
+ help = '/plugins',
+ description = 'Enable/disable plugins',
+ on_message = function() end,
+ }
+
+ before_each(function()
+ -- Reset all module caches
+ package.loaded['src.core.loader'] = 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,
+ get_number = function(key, default) return default end,
+ is_enabled = function() return false end,
+ bot_admins = function() return {} end,
+ load = function() end,
+ VERSION = '2.0',
+ }
+
+ -- Mock category manifests
+ package.loaded['src.plugins.admin.init'] = { plugins = { 'ban' } }
+ package.loaded['src.plugins.utility.init'] = { plugins = { 'help', 'ping', 'about', 'plugins' } }
+ package.loaded['src.plugins.fun.init'] = { plugins = {} }
+ package.loaded['src.plugins.media.init'] = { plugins = {} }
+ package.loaded['src.plugins.ai.init'] = { plugins = {} }
+
+ -- Mock individual plugins
+ package.loaded['src.plugins.admin.ban'] = fake_ban
+ package.loaded['src.plugins.utility.help'] = fake_help
+ package.loaded['src.plugins.utility.ping'] = fake_ping
+ package.loaded['src.plugins.utility.about'] = fake_about
+ package.loaded['src.plugins.utility.plugins'] = fake_plugins
+
+ loader = require('src.core.loader')
+ loader.init(nil, nil, nil)
+ end)
+
+ describe('init()', function()
+ it('should load plugins from manifests', function()
+ assert.is_true(loader.count() > 0)
+ end)
+
+ it('should load the expected number of plugins', function()
+ assert.are.equal(5, loader.count())
+ end)
+ end)
+
+ describe('get_plugins()', function()
+ it('should return all loaded plugins', function()
+ local plugins = loader.get_plugins()
+ assert.are.equal(5, #plugins)
+ end)
+
+ it('should return plugins in order', function()
+ local plugins = loader.get_plugins()
+ -- Admin category loads first, then utility
+ assert.are.equal('ban', plugins[1].name)
+ assert.are.equal('help', plugins[2].name)
+ end)
+ end)
+
+ describe('get_by_command()', function()
+ it('should find plugin by primary command', function()
+ local plugin = loader.get_by_command('ping')
+ assert.is_not_nil(plugin)
+ assert.are.equal('ping', plugin.name)
+ end)
+
+ it('should find plugin by alias command', function()
+ local plugin = loader.get_by_command('pong')
+ assert.is_not_nil(plugin)
+ assert.are.equal('ping', plugin.name)
+ end)
+
+ it('should find plugin by short alias', function()
+ local plugin = loader.get_by_command('b')
+ assert.is_not_nil(plugin)
+ assert.are.equal('ban', plugin.name)
+ end)
+
+ it('should be case-insensitive', function()
+ local plugin = loader.get_by_command('PING')
+ assert.is_not_nil(plugin)
+ assert.are.equal('ping', plugin.name)
+ end)
+
+ it('should return nil for unknown command', function()
+ local plugin = loader.get_by_command('nonexistent')
+ assert.is_nil(plugin)
+ end)
+ end)
+
+ describe('get_by_name()', function()
+ it('should find plugin by name', function()
+ local plugin = loader.get_by_name('ban')
+ assert.is_not_nil(plugin)
+ assert.are.equal('ban', plugin.name)
+ end)
+
+ it('should return nil for unknown name', function()
+ local plugin = loader.get_by_name('nonexistent')
+ assert.is_nil(plugin)
+ end)
+ end)
+
+ describe('get_category()', function()
+ it('should return all plugins in admin category', function()
+ local plugins = loader.get_category('admin')
+ assert.are.equal(1, #plugins)
+ assert.are.equal('ban', plugins[1].name)
+ end)
+
+ it('should return all plugins in utility category', function()
+ local plugins = loader.get_category('utility')
+ assert.are.equal(4, #plugins)
+ end)
+
+ it('should return empty table for empty category', function()
+ local plugins = loader.get_category('fun')
+ assert.are.same({}, plugins)
+ end)
+
+ it('should return empty table for unknown category', function()
+ local plugins = loader.get_category('nonexistent')
+ assert.are.same({}, plugins)
+ end)
+ end)
+
+ describe('is_permanent()', function()
+ it('should return true for help plugin', function()
+ assert.is_true(loader.is_permanent('help'))
+ end)
+
+ it('should return true for about plugin', function()
+ assert.is_true(loader.is_permanent('about'))
+ end)
+
+ it('should return true for plugins plugin', function()
+ assert.is_true(loader.is_permanent('plugins'))
+ end)
+
+ it('should return false for ban plugin', function()
+ assert.is_false(loader.is_permanent('ban'))
+ end)
+
+ it('should return false for ping plugin', function()
+ assert.is_false(loader.is_permanent('ping'))
+ end)
+
+ it('should return false for unknown plugin', function()
+ assert.is_false(loader.is_permanent('nonexistent'))
+ end)
+ end)
+
+ describe('reload()', function()
+ it('should return false for non-existent plugin', function()
+ local ok, err = loader.reload('nonexistent')
+ assert.is_false(ok)
+ assert.is_truthy(err:match('not found'))
+ end)
+
+ it('should successfully reload an existing plugin', function()
+ -- Set up a new version of the plugin in package.loaded
+ local new_ping = {
+ name = 'ping',
+ category = 'utility',
+ commands = { 'ping', 'pong', 'latency' },
+ help = '/ping - Updated!',
+ description = 'Updated ping',
+ on_message = function() return 'updated' end,
+ }
+ package.loaded['src.plugins.utility.ping'] = new_ping
+
+ local ok = loader.reload('ping')
+ assert.is_true(ok)
+
+ -- New command should be registered
+ local p = loader.get_by_command('latency')
+ assert.is_not_nil(p)
+ assert.are.equal('ping', p.name)
+ end)
+
+ it('should re-index commands after reload', function()
+ -- Simulate reload with different commands
+ local new_ping = {
+ name = 'ping',
+ commands = { 'newping' },
+ on_message = function() end,
+ }
+ package.loaded['src.plugins.utility.ping'] = new_ping
+
+ loader.reload('ping')
+
+ -- Old commands should no longer work
+ assert.is_nil(loader.get_by_command('pong'))
+ -- New command should work
+ assert.is_not_nil(loader.get_by_command('newping'))
+ end)
+ end)
+
+ describe('get_help()', function()
+ it('should return help for all plugins', function()
+ local help = loader.get_help()
+ assert.is_true(#help > 0)
+ end)
+
+ it('should return help for a specific category', function()
+ local help = loader.get_help('admin')
+ assert.are.equal(1, #help)
+ assert.are.equal('ban', help[1].name)
+ end)
+
+ it('should include commands in help entries', function()
+ local help = loader.get_help('utility')
+ local found_ping = false
+ for _, h in ipairs(help) do
+ if h.name == 'ping' then
+ found_ping = true
+ assert.are.same({ 'ping', 'pong' }, h.commands)
+ end
+ end
+ assert.is_true(found_ping)
+ end)
+
+ it('should include description in help entries', function()
+ local help = loader.get_help('utility')
+ for _, h in ipairs(help) do
+ if h.name == 'ping' then
+ assert.are.equal('Check bot responsiveness', h.description)
+ end
+ end
+ end)
+ end)
+
+ describe('get_categories()', function()
+ it('should return list of all categories', function()
+ local cats = loader.get_categories()
+ assert.is_true(#cats > 0)
+ -- Check it contains expected categories
+ local found_admin = false
+ local found_utility = false
+ for _, c in ipairs(cats) do
+ if c == 'admin' then found_admin = true end
+ if c == 'utility' then found_utility = true end
+ end
+ assert.is_true(found_admin)
+ assert.is_true(found_utility)
+ end)
+ end)
+end)
diff --git a/spec/core/logger_spec.lua b/spec/core/logger_spec.lua
new file mode 100644
index 0000000..eab6e12
--- /dev/null
+++ b/spec/core/logger_spec.lua
@@ -0,0 +1,214 @@
+--[[
+ Tests for src/core/logger.lua
+ Tests log levels, formatting, and level filtering.
+]]
+
+describe('core.logger', function()
+ local logger
+ local captured_output
+
+ before_each(function()
+ package.loaded['src.core.logger'] = nil
+ package.loaded['src.core.config'] = {
+ debug = function() return false end,
+ is_enabled = function() return false end,
+ get = function(key, default) return default end,
+ load = function() end,
+ VERSION = '2.0',
+ }
+ logger = require('src.core.logger')
+ captured_output = {}
+
+ -- Override io.write to capture output
+ local original_write = io.write
+ io.write = function(s)
+ table.insert(captured_output, s)
+ end
+ end)
+
+ after_each(function()
+ -- Restore io.write (best effort, may already be restored)
+ io.write = _G._original_io_write or io.write
+ end)
+
+ -- Save original io.write before all tests
+ setup(function()
+ _G._original_io_write = io.write
+ end)
+
+ teardown(function()
+ io.write = _G._original_io_write
+ end)
+
+ describe('set_level()', function()
+ it('should accept valid level strings', function()
+ assert.has_no.errors(function()
+ logger.set_level('DEBUG')
+ logger.set_level('INFO')
+ logger.set_level('WARN')
+ logger.set_level('ERROR')
+ end)
+ end)
+
+ it('should be case-insensitive', function()
+ assert.has_no.errors(function()
+ logger.set_level('debug')
+ logger.set_level('info')
+ logger.set_level('warn')
+ logger.set_level('error')
+ end)
+ end)
+
+ it('should silently ignore invalid levels', function()
+ assert.has_no.errors(function()
+ logger.set_level('INVALID')
+ end)
+ end)
+ end)
+
+ describe('log level filtering', function()
+ it('should output ERROR when level is ERROR', function()
+ logger.set_level('ERROR')
+ logger.error('test error')
+ assert.is_true(#captured_output > 0)
+ end)
+
+ it('should not output DEBUG when level is INFO', function()
+ logger.set_level('INFO')
+ logger.debug('should not appear')
+ assert.are.equal(0, #captured_output)
+ end)
+
+ it('should not output INFO when level is WARN', function()
+ logger.set_level('WARN')
+ logger.info('should not appear')
+ assert.are.equal(0, #captured_output)
+ end)
+
+ it('should not output WARN when level is ERROR', function()
+ logger.set_level('ERROR')
+ logger.warn('should not appear')
+ assert.are.equal(0, #captured_output)
+ end)
+
+ it('should output DEBUG when level is DEBUG', function()
+ logger.set_level('DEBUG')
+ logger.debug('debug message')
+ assert.is_true(#captured_output > 0)
+ end)
+
+ it('should output INFO when level is DEBUG', function()
+ logger.set_level('DEBUG')
+ logger.info('info message')
+ assert.is_true(#captured_output > 0)
+ end)
+
+ it('should output WARN when level is INFO', function()
+ logger.set_level('INFO')
+ logger.warn('warn message')
+ assert.is_true(#captured_output > 0)
+ end)
+
+ it('should output ERROR at any level', function()
+ logger.set_level('DEBUG')
+ logger.error('error message')
+ assert.is_true(#captured_output > 0)
+ end)
+ end)
+
+ describe('message formatting', function()
+ it('should include level in output', function()
+ logger.set_level('DEBUG')
+ logger.error('test')
+ local output = table.concat(captured_output)
+ assert.is_truthy(output:match('ERROR'))
+ end)
+
+ it('should include timestamp in output', function()
+ logger.set_level('DEBUG')
+ logger.info('test')
+ local output = table.concat(captured_output)
+ -- Check for date pattern like YYYY-MM-DD
+ assert.is_truthy(output:match('%d%d%d%d%-%d%d%-%d%d'))
+ end)
+
+ it('should include the message text', function()
+ logger.set_level('DEBUG')
+ logger.info('hello world')
+ local output = table.concat(captured_output)
+ assert.is_truthy(output:match('hello world'))
+ end)
+
+ it('should format with string.format when args provided', function()
+ logger.set_level('DEBUG')
+ logger.info('user %s has %d messages', 'Alice', 42)
+ local output = table.concat(captured_output)
+ assert.is_truthy(output:match('user Alice has 42 messages'))
+ end)
+
+ it('should handle plain message without format args', function()
+ logger.set_level('DEBUG')
+ logger.info('simple message')
+ local output = table.concat(captured_output)
+ assert.is_truthy(output:match('simple message'))
+ end)
+
+ it('should handle numeric message', function()
+ logger.set_level('DEBUG')
+ logger.info(42)
+ local output = table.concat(captured_output)
+ assert.is_truthy(output:match('42'))
+ end)
+
+ it('should end with newline', function()
+ logger.set_level('DEBUG')
+ logger.info('test')
+ local output = table.concat(captured_output)
+ assert.is_truthy(output:match('\n$'))
+ end)
+ end)
+
+ describe('init()', function()
+ it('should set DEBUG level when config.debug() is true', function()
+ package.loaded['src.core.logger'] = nil
+ package.loaded['src.core.config'] = {
+ debug = function() return true end,
+ is_enabled = function() return true end,
+ get = function(key, default) return default end,
+ load = function() end,
+ VERSION = '2.0',
+ }
+ logger = require('src.core.logger')
+ captured_output = {}
+ logger.init()
+ logger.debug('should appear now')
+ assert.is_true(#captured_output > 0)
+ end)
+ end)
+
+ describe('different log levels produce different prefixes', function()
+ before_each(function()
+ logger.set_level('DEBUG')
+ end)
+
+ it('debug should include DEBUG', function()
+ logger.debug('msg')
+ assert.is_truthy(table.concat(captured_output):match('DEBUG'))
+ end)
+
+ it('info should include INFO', function()
+ logger.info('msg')
+ assert.is_truthy(table.concat(captured_output):match('INFO'))
+ end)
+
+ it('warn should include WARN', function()
+ logger.warn('msg')
+ assert.is_truthy(table.concat(captured_output):match('WARN'))
+ end)
+
+ it('error should include ERROR', function()
+ logger.error('msg')
+ assert.is_truthy(table.concat(captured_output):match('ERROR'))
+ end)
+ end)
+end)
diff --git a/spec/core/middleware_spec.lua b/spec/core/middleware_spec.lua
new file mode 100644
index 0000000..a14ca7d
--- /dev/null
+++ b/spec/core/middleware_spec.lua
@@ -0,0 +1,222 @@
+--[[
+ Tests for src/core/middleware.lua
+ Tests the middleware pipeline: use, run, reset, ordering, stopping.
+]]
+
+describe('core.middleware', function()
+ local middleware
+
+ before_each(function()
+ -- Reset module and logger dependency
+ package.loaded['src.core.middleware'] = nil
+ package.loaded['src.core.logger'] = {
+ debug = function() end,
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ }
+ middleware = require('src.core.middleware')
+ middleware.reset()
+ end)
+
+ after_each(function()
+ middleware.reset()
+ end)
+
+ describe('use()', function()
+ it('should register a valid middleware', function()
+ middleware.use({
+ name = 'test',
+ run = function(ctx, msg) return ctx, true end
+ })
+ assert.are.equal(1, middleware.count())
+ end)
+
+ it('should register multiple middleware', function()
+ middleware.use({ name = 'a', run = function(ctx, msg) return ctx, true end })
+ middleware.use({ name = 'b', run = function(ctx, msg) return ctx, true end })
+ middleware.use({ name = 'c', run = function(ctx, msg) return ctx, true end })
+ assert.are.equal(3, middleware.count())
+ end)
+
+ it('should reject middleware without run function', function()
+ middleware.use({ name = 'bad' })
+ assert.are.equal(0, middleware.count())
+ end)
+
+ it('should reject non-table middleware', function()
+ middleware.use('not_a_table')
+ assert.are.equal(0, middleware.count())
+ end)
+
+ it('should reject nil middleware', function()
+ middleware.use(nil)
+ assert.are.equal(0, middleware.count())
+ end)
+
+ it('should reject middleware with non-function run', function()
+ middleware.use({ name = 'bad', run = 'not_a_function' })
+ assert.are.equal(0, middleware.count())
+ end)
+ end)
+
+ describe('run()', function()
+ it('should run all middleware in order', function()
+ local order = {}
+ middleware.use({
+ name = 'first',
+ run = function(ctx, msg)
+ table.insert(order, 'first')
+ return ctx, true
+ end
+ })
+ middleware.use({
+ name = 'second',
+ run = function(ctx, msg)
+ table.insert(order, 'second')
+ return ctx, true
+ end
+ })
+ middleware.use({
+ name = 'third',
+ run = function(ctx, msg)
+ table.insert(order, 'third')
+ return ctx, true
+ end
+ })
+ middleware.run({}, {})
+ assert.are.same({ 'first', 'second', 'third' }, order)
+ end)
+
+ it('should return modified ctx', function()
+ middleware.use({
+ name = 'modifier',
+ run = function(ctx, msg)
+ ctx.custom_field = 'hello'
+ return ctx, true
+ end
+ })
+ local ctx, should_continue = middleware.run({}, {})
+ assert.are.equal('hello', ctx.custom_field)
+ assert.is_true(should_continue)
+ end)
+
+ it('should stop chain when middleware returns false', function()
+ local ran_second = false
+ middleware.use({
+ name = 'blocker',
+ run = function(ctx, msg)
+ return ctx, false
+ end
+ })
+ middleware.use({
+ name = 'never_runs',
+ run = function(ctx, msg)
+ ran_second = true
+ return ctx, true
+ end
+ })
+ local ctx, should_continue = middleware.run({}, {})
+ assert.is_false(should_continue)
+ assert.is_false(ran_second)
+ end)
+
+ it('should record which middleware stopped the chain', function()
+ middleware.use({
+ name = 'stopper',
+ run = function(ctx, msg) return ctx, false end
+ })
+ local ctx = middleware.run({}, {})
+ assert.is_true(ctx._stopped)
+ assert.are.equal('stopper', ctx._stopped_by)
+ end)
+
+ it('should propagate ctx modifications between middleware', function()
+ middleware.use({
+ name = 'adder',
+ run = function(ctx, msg)
+ ctx.step1 = true
+ return ctx, true
+ end
+ })
+ middleware.use({
+ name = 'reader',
+ run = function(ctx, msg)
+ ctx.step2_saw_step1 = ctx.step1
+ return ctx, true
+ end
+ })
+ local ctx = middleware.run({}, {})
+ assert.is_true(ctx.step1)
+ assert.is_true(ctx.step2_saw_step1)
+ end)
+
+ it('should continue if middleware errors', function()
+ local ran_second = false
+ middleware.use({
+ name = 'erroring',
+ run = function(ctx, msg)
+ error('something broke')
+ return ctx, true
+ end
+ })
+ middleware.use({
+ name = 'continues',
+ run = function(ctx, msg)
+ ran_second = true
+ return ctx, true
+ end
+ })
+ local ctx, should_continue = middleware.run({}, {})
+ assert.is_true(ran_second)
+ assert.is_true(should_continue)
+ end)
+
+ it('should handle middleware returning nil ctx gracefully', function()
+ middleware.use({
+ name = 'nil_returner',
+ run = function(ctx, msg)
+ return nil, true
+ end
+ })
+ middleware.use({
+ name = 'post',
+ run = function(ctx, msg)
+ ctx.post_ran = true
+ return ctx, true
+ end
+ })
+ local ctx, should_continue = middleware.run({ initial = true }, {})
+ assert.is_true(should_continue)
+ -- ctx should be the original since nil was returned
+ assert.is_true(ctx.initial)
+ end)
+
+ it('should return ctx, true when no middleware registered', function()
+ local ctx, should_continue = middleware.run({ empty = true }, {})
+ assert.is_true(should_continue)
+ assert.is_true(ctx.empty)
+ end)
+ end)
+
+ describe('reset()', function()
+ it('should clear all registered middleware', function()
+ middleware.use({ name = 'a', run = function(ctx, msg) return ctx, true end })
+ middleware.use({ name = 'b', run = function(ctx, msg) return ctx, true end })
+ assert.are.equal(2, middleware.count())
+ middleware.reset()
+ assert.are.equal(0, middleware.count())
+ end)
+ end)
+
+ describe('count()', function()
+ it('should return 0 when empty', function()
+ assert.are.equal(0, middleware.count())
+ end)
+
+ it('should return correct count after registration', function()
+ middleware.use({ name = 'a', run = function() return {}, true end })
+ assert.are.equal(1, middleware.count())
+ end)
+ end)
+end)
diff --git a/spec/core/permissions_spec.lua b/spec/core/permissions_spec.lua
new file mode 100644
index 0000000..9400e5c
--- /dev/null
+++ b/spec/core/permissions_spec.lua
@@ -0,0 +1,250 @@
+--[[
+ Tests for src/core/permissions.lua
+ Tests is_global_admin, is_group_admin (with cache), check_bot_can,
+ can_restrict, can_delete, can_pin, is_admin_or_mod.
+]]
+
+describe('core.permissions', function()
+ local permissions
+ local mock_api_mod = require('spec.helpers.mock_api')
+ local mock_redis = require('spec.helpers.mock_redis')
+ local mock_db = require('spec.helpers.mock_db')
+ local api, redis, db
+
+ before_each(function()
+ -- Reset modules to get fresh state
+ package.loaded['src.core.permissions'] = nil
+ package.loaded['src.core.session'] = nil
+ package.loaded['src.core.config'] = nil
+
+ -- Mock config to return known admin list
+ package.loaded['src.core.config'] = {
+ get = function(key, default) return default end,
+ get_number = function(key, default) return default end,
+ is_enabled = function(key) return false end,
+ bot_admins = function() return { 221714512, 99999 } end,
+ bot_name = function() return 'mattata' end,
+ get_list = function(key) return { 221714512, 99999 } end,
+ load = function() end,
+ VERSION = '2.0',
+ }
+
+ redis = mock_redis.new()
+
+ -- Init session with our mock redis
+ local session = require('src.core.session')
+ session.init(redis)
+
+ permissions = require('src.core.permissions')
+ api = mock_api_mod.new()
+ db = mock_db.new()
+ end)
+
+ after_each(function()
+ api.reset()
+ redis.reset()
+ db.reset()
+ end)
+
+ describe('is_global_admin()', function()
+ it('should return true for a global admin', function()
+ assert.is_true(permissions.is_global_admin(221714512))
+ end)
+
+ it('should return true for second global admin', function()
+ assert.is_true(permissions.is_global_admin(99999))
+ end)
+
+ it('should return false for non-admin', function()
+ assert.is_false(permissions.is_global_admin(111111))
+ end)
+
+ it('should return false for nil', function()
+ assert.is_false(permissions.is_global_admin(nil))
+ end)
+
+ it('should handle string user_id by converting to number', function()
+ assert.is_true(permissions.is_global_admin('221714512'))
+ end)
+
+ it('should return false for non-numeric string', function()
+ assert.is_false(permissions.is_global_admin('abc'))
+ end)
+ end)
+
+ describe('is_group_admin()', function()
+ it('should return true for global admin without API call', function()
+ local result = permissions.is_group_admin(api, -100123, 221714512)
+ assert.is_true(result)
+ -- Should not have called get_chat_member since user is global admin
+ assert.are.equal(0, api.count_calls('get_chat_member'))
+ end)
+
+ it('should return true for Telegram administrator', function()
+ api.set_admin(-100123, 456)
+ local result = permissions.is_group_admin(api, -100123, 456)
+ assert.is_true(result)
+ end)
+
+ it('should return true for chat creator', function()
+ api.set_creator(-100123, 789)
+ local result = permissions.is_group_admin(api, -100123, 789)
+ assert.is_true(result)
+ end)
+
+ it('should return false for regular member', function()
+ local result = permissions.is_group_admin(api, -100123, 111)
+ assert.is_false(result)
+ end)
+
+ it('should cache admin status in session', function()
+ api.set_admin(-100123, 456)
+ permissions.is_group_admin(api, -100123, 456)
+ -- Second call should use cache, not API
+ api.reset()
+ local result = permissions.is_group_admin(api, -100123, 456)
+ assert.is_true(result)
+ assert.are.equal(0, api.count_calls('get_chat_member'))
+ end)
+
+ it('should cache non-admin status too', function()
+ permissions.is_group_admin(api, -100123, 111)
+ api.reset()
+ local result = permissions.is_group_admin(api, -100123, 111)
+ assert.is_false(result)
+ assert.are.equal(0, api.count_calls('get_chat_member'))
+ end)
+
+ it('should return false when chat_id is nil', function()
+ assert.is_false(permissions.is_group_admin(api, nil, 456))
+ end)
+
+ it('should return false when user_id is nil', function()
+ assert.is_false(permissions.is_group_admin(api, -100123, nil))
+ end)
+ end)
+
+ describe('is_group_mod()', function()
+ it('should return true when user has moderator role', function()
+ db.set_next_result({ { ['1'] = 1 } })
+ assert.is_true(permissions.is_group_mod(db, -100123, 456))
+ end)
+
+ it('should return false when user is not a moderator', function()
+ db.set_next_result({})
+ assert.is_false(permissions.is_group_mod(db, -100123, 456))
+ end)
+
+ it('should return false for nil chat_id', function()
+ assert.is_false(permissions.is_group_mod(db, nil, 456))
+ end)
+
+ it('should return false for nil user_id', function()
+ assert.is_false(permissions.is_group_mod(db, -100123, nil))
+ end)
+
+ it('should query the correct SQL', function()
+ db.set_next_result({})
+ permissions.is_group_mod(db, -100123, 456)
+ assert.is_true(db.has_query('chat_members'))
+ assert.is_true(db.has_query('moderator'))
+ end)
+ end)
+
+ describe('is_trusted()', function()
+ it('should return true when user has trusted role', function()
+ db.set_next_result({ { ['1'] = 1 } })
+ assert.is_true(permissions.is_trusted(db, -100123, 456))
+ end)
+
+ it('should return false when user is not trusted', function()
+ db.set_next_result({})
+ assert.is_false(permissions.is_trusted(db, -100123, 456))
+ end)
+ end)
+
+ describe('check_bot_can()', function()
+ it('should return true when bot has the permission', function()
+ api.set_bot_admin(-100123, { can_restrict_members = true })
+ local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
+ assert.is_true(result)
+ end)
+
+ it('should return false when bot lacks the permission', function()
+ api.set_bot_admin(-100123, { can_restrict_members = false })
+ local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
+ assert.is_false(result)
+ end)
+
+ it('should return false when bot is not an admin', function()
+ -- Default: bot is a regular member
+ local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
+ assert.is_false(result)
+ end)
+
+ it('should cache the result', function()
+ api.set_bot_admin(-100123, { can_restrict_members = true })
+ permissions.check_bot_can(api, -100123, 'can_restrict_members')
+ api.reset()
+ local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
+ assert.is_true(result)
+ assert.are.equal(0, api.count_calls('get_chat_member'))
+ end)
+
+ it('should return false for nil chat_id', function()
+ assert.is_false(permissions.check_bot_can(api, nil, 'can_restrict_members'))
+ end)
+
+ it('should return false for nil permission', function()
+ assert.is_false(permissions.check_bot_can(api, -100123, nil))
+ end)
+ end)
+
+ describe('convenience permission checks', function()
+ it('can_restrict should check can_restrict_members', function()
+ api.set_bot_admin(-100123, { can_restrict_members = true })
+ assert.is_true(permissions.can_restrict(api, -100123))
+ end)
+
+ it('can_delete should check can_delete_messages', function()
+ api.set_bot_admin(-100123, { can_delete_messages = true })
+ assert.is_true(permissions.can_delete(api, -100123))
+ end)
+
+ it('can_pin should check can_pin_messages', function()
+ api.set_bot_admin(-100123, { can_pin_messages = true })
+ assert.is_true(permissions.can_pin(api, -100123))
+ end)
+
+ it('can_promote should check can_promote_members', function()
+ api.set_bot_admin(-100123, { can_promote_members = true })
+ assert.is_true(permissions.can_promote(api, -100123))
+ end)
+
+ it('can_invite should check can_invite_users', function()
+ api.set_bot_admin(-100123, { can_invite_users = true })
+ assert.is_true(permissions.can_invite(api, -100123))
+ end)
+ end)
+
+ describe('is_admin_or_mod()', function()
+ it('should return true for admin', function()
+ api.set_admin(-100123, 456)
+ assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 456))
+ end)
+
+ it('should return true for moderator', function()
+ db.set_next_result({ { ['1'] = 1 } })
+ assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 789))
+ end)
+
+ it('should return false for regular user', function()
+ db.set_next_result({})
+ assert.is_false(permissions.is_admin_or_mod(api, db, -100123, 111))
+ end)
+
+ it('should return true for global admin', function()
+ assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 221714512))
+ end)
+ end)
+end)
diff --git a/spec/core/redis_spec.lua b/spec/core/redis_spec.lua
new file mode 100644
index 0000000..584f373
--- /dev/null
+++ b/spec/core/redis_spec.lua
@@ -0,0 +1,311 @@
+--[[
+ Tests for Redis module using mock_redis.
+ Tests basic operations, scan vs keys, list ops, hash ops, set ops.
+]]
+
+describe('core.redis (mock)', function()
+ local mock_redis = require('spec.helpers.mock_redis')
+ local redis
+
+ before_each(function()
+ redis = mock_redis.new()
+ end)
+
+ after_each(function()
+ redis.reset()
+ end)
+
+ describe('string operations', function()
+ it('should set and get a value', function()
+ redis.set('key1', 'value1')
+ assert.are.equal('value1', redis.get('key1'))
+ end)
+
+ it('should return nil for non-existent key', function()
+ assert.is_nil(redis.get('nonexistent'))
+ end)
+
+ it('should overwrite existing values', function()
+ redis.set('key1', 'v1')
+ redis.set('key1', 'v2')
+ assert.are.equal('v2', redis.get('key1'))
+ end)
+
+ it('should set with expiry via setex', function()
+ redis.setex('key1', 60, 'value1')
+ assert.are.equal('value1', redis.get('key1'))
+ assert.are.equal(60, redis.ttls['key1'])
+ end)
+
+ it('should setnx only when key does not exist', function()
+ local r1 = redis.setnx('key1', 'first')
+ assert.are.equal(1, r1)
+ assert.are.equal('first', redis.get('key1'))
+
+ local r2 = redis.setnx('key1', 'second')
+ assert.are.equal(0, r2)
+ assert.are.equal('first', redis.get('key1'))
+ end)
+
+ it('should convert values to strings on set', function()
+ redis.set('num', 42)
+ assert.are.equal('42', redis.get('num'))
+ end)
+ end)
+
+ describe('del()', function()
+ it('should delete a key', function()
+ redis.set('key1', 'value1')
+ redis.del('key1')
+ assert.is_nil(redis.get('key1'))
+ end)
+
+ it('should not error when deleting non-existent key', function()
+ assert.has_no.errors(function()
+ redis.del('nonexistent')
+ end)
+ end)
+
+ it('should also delete sets and hashes', function()
+ redis.sadd('myset', 'val')
+ redis.hset('myhash', 'field', 'val')
+ redis.del('myset')
+ redis.del('myhash')
+ assert.are.same({}, redis.smembers('myset'))
+ assert.are.same({}, redis.hgetall('myhash'))
+ end)
+ end)
+
+ describe('exists()', function()
+ it('should return 1 for existing key', function()
+ redis.set('key1', 'value1')
+ assert.are.equal(1, redis.exists('key1'))
+ end)
+
+ it('should return 0 for non-existent key', function()
+ assert.are.equal(0, redis.exists('nonexistent'))
+ end)
+ end)
+
+ describe('expire()', function()
+ it('should record TTL for a key', function()
+ redis.set('key1', 'value1')
+ redis.expire('key1', 300)
+ assert.are.equal(300, redis.ttls['key1'])
+ end)
+ end)
+
+ describe('incr / incrby', function()
+ it('should increment from 0 for new key', function()
+ local val = redis.incr('counter')
+ assert.are.equal(1, val)
+ end)
+
+ it('should increment existing value', function()
+ redis.set('counter', '5')
+ local val = redis.incr('counter')
+ assert.are.equal(6, val)
+ end)
+
+ it('should increment by arbitrary amount', function()
+ local val = redis.incrby('counter', 10)
+ assert.are.equal(10, val)
+ val = redis.incrby('counter', 5)
+ assert.are.equal(15, val)
+ end)
+
+ it('should handle negative incrby', function()
+ redis.set('counter', '10')
+ local val = redis.incrby('counter', -3)
+ assert.are.equal(7, val)
+ end)
+ end)
+
+ describe('hash operations', function()
+ it('should set and get hash fields', function()
+ redis.hset('user:1', 'name', 'Alice')
+ assert.are.equal('Alice', redis.hget('user:1', 'name'))
+ end)
+
+ it('should return nil for non-existent hash field', function()
+ assert.is_nil(redis.hget('user:1', 'name'))
+ end)
+
+ it('should return nil for non-existent hash key', function()
+ redis.hset('user:1', 'name', 'Alice')
+ assert.is_nil(redis.hget('user:1', 'email'))
+ end)
+
+ it('should delete hash fields', function()
+ redis.hset('user:1', 'name', 'Alice')
+ redis.hdel('user:1', 'name')
+ assert.is_nil(redis.hget('user:1', 'name'))
+ end)
+
+ it('should return all hash fields', function()
+ redis.hset('user:1', 'name', 'Alice')
+ redis.hset('user:1', 'age', '30')
+ local all = redis.hgetall('user:1')
+ assert.are.equal('Alice', all['name'])
+ assert.are.equal('30', all['age'])
+ end)
+
+ it('should return empty table for non-existent hash', function()
+ local all = redis.hgetall('nonexistent')
+ assert.are.same({}, all)
+ end)
+
+ it('should check field existence', function()
+ redis.hset('user:1', 'name', 'Alice')
+ assert.is_true(redis.hexists('user:1', 'name'))
+ assert.is_falsy(redis.hexists('user:1', 'email'))
+ end)
+
+ it('should increment hash field', function()
+ local val = redis.hincrby('user:1', 'count', 1)
+ assert.are.equal(1, val)
+ val = redis.hincrby('user:1', 'count', 5)
+ assert.are.equal(6, val)
+ end)
+
+ it('should convert hash values to strings', function()
+ redis.hset('h', 'num', 42)
+ assert.are.equal('42', redis.hget('h', 'num'))
+ end)
+ end)
+
+ describe('set operations', function()
+ it('should add and check membership', function()
+ redis.sadd('myset', 'a')
+ assert.are.equal(1, redis.sismember('myset', 'a'))
+ assert.are.equal(0, redis.sismember('myset', 'b'))
+ end)
+
+ it('should remove members', function()
+ redis.sadd('myset', 'a')
+ redis.srem('myset', 'a')
+ assert.are.equal(0, redis.sismember('myset', 'a'))
+ end)
+
+ it('should return all members', function()
+ redis.sadd('myset', 'a')
+ redis.sadd('myset', 'b')
+ redis.sadd('myset', 'c')
+ local members = redis.smembers('myset')
+ assert.are.equal(3, #members)
+ end)
+
+ it('should return empty table for non-existent set', function()
+ local members = redis.smembers('nonexistent')
+ assert.are.same({}, members)
+ end)
+
+ it('should not duplicate members', function()
+ redis.sadd('myset', 'a')
+ redis.sadd('myset', 'a')
+ local members = redis.smembers('myset')
+ assert.are.equal(1, #members)
+ end)
+
+ it('should count members with scard', function()
+ redis.sadd('myset', 'a')
+ redis.sadd('myset', 'b')
+ assert.are.equal(2, redis.scard('myset'))
+ end)
+ end)
+
+ describe('list operations', function()
+ it('should push and retrieve list items', function()
+ redis.rpush('mylist', 'a')
+ redis.rpush('mylist', 'b')
+ redis.rpush('mylist', 'c')
+ local items = redis.lrange('mylist', 0, -1)
+ assert.are.equal(3, #items)
+ assert.are.equal('a', items[1])
+ assert.are.equal('b', items[2])
+ assert.are.equal('c', items[3])
+ end)
+
+ it('should return empty list for non-existent key', function()
+ local items = redis.lrange('nonexistent', 0, -1)
+ assert.are.same({}, items)
+ end)
+
+ it('should support partial range', function()
+ redis.rpush('mylist', 'a')
+ redis.rpush('mylist', 'b')
+ redis.rpush('mylist', 'c')
+ local items = redis.lrange('mylist', 0, 1)
+ assert.are.equal(2, #items)
+ assert.are.equal('a', items[1])
+ assert.are.equal('b', items[2])
+ end)
+ end)
+
+ describe('scan / keys', function()
+ it('should find keys matching a pattern', function()
+ redis.set('stats:msg:100:2024-01-01:111', '5')
+ redis.set('stats:msg:100:2024-01-01:222', '3')
+ redis.set('stats:cmd:ping:100:2024-01-01', '2')
+ local msg_keys = redis.scan('stats:msg:*')
+ assert.are.equal(2, #msg_keys)
+ end)
+
+ it('should return empty table when no keys match', function()
+ redis.set('foo', 'bar')
+ local results = redis.scan('baz:*')
+ assert.are.same({}, results)
+ end)
+
+ it('keys() should delegate to scan()', function()
+ redis.set('test:1', 'a')
+ redis.set('test:2', 'b')
+ local results = redis.keys('test:*')
+ assert.are.equal(2, #results)
+ end)
+ end)
+
+ describe('command recording', function()
+ it('should record all commands issued', function()
+ redis.set('k', 'v')
+ redis.get('k')
+ redis.del('k')
+ assert.are.equal(3, #redis.commands)
+ assert.are.equal('set', redis.commands[1].cmd)
+ assert.are.equal('get', redis.commands[2].cmd)
+ assert.are.equal('del', redis.commands[3].cmd)
+ end)
+
+ it('should track command arguments', function()
+ redis.setex('key', 60, 'val')
+ assert.are.same({ 'key', 60, 'val' }, redis.commands[1].args)
+ end)
+
+ it('has_command should find recorded commands', function()
+ redis.set('k', 'v')
+ assert.is_true(redis.has_command('set'))
+ assert.is_false(redis.has_command('del'))
+ end)
+
+ it('count_commands should count occurrences', function()
+ redis.get('a')
+ redis.get('b')
+ redis.set('c', 'd')
+ assert.are.equal(2, redis.count_commands('get'))
+ assert.are.equal(1, redis.count_commands('set'))
+ end)
+ end)
+
+ describe('reset()', function()
+ it('should clear all state', function()
+ redis.set('k', 'v')
+ redis.sadd('s', 'v')
+ redis.hset('h', 'f', 'v')
+ redis.reset()
+ assert.are.same({}, redis.store)
+ assert.are.same({}, redis.sets)
+ assert.are.same({}, redis.hashes)
+ assert.are.same({}, redis.commands)
+ end)
+ end)
+end)
diff --git a/spec/core/router_spec.lua b/spec/core/router_spec.lua
new file mode 100644
index 0000000..cead54b
--- /dev/null
+++ b/spec/core/router_spec.lua
@@ -0,0 +1,507 @@
+--[[
+ Tests for src/core/router.lua internals
+ Tests build_ctx lazy admin check, sort_message, extract_command,
+ process_action bug fix, resolve_alias caching.
+
+ Since router.lua functions are local, we test them via the module's
+ exposed behaviour and by reimplementing the key local functions.
+]]
+
+describe('core.router internals', function()
+ -- We test the local functions by reimplementing them identically.
+ -- This is necessary because Lua doesn't expose local functions.
+
+ describe('sort_message()', function()
+ local function sort_message(message)
+ message.text = message.text or message.caption or ''
+ message.text = message.text:gsub('^(/[%a]+)_', '%1 ')
+ if message.text:match('^[/!#]start .-$') then
+ message.text = '/' .. message.text:match('^[/!#]start (.-)$')
+ end
+ if message.reply_to_message then
+ message.reply = message.reply_to_message
+ message.reply_to_message = nil
+ end
+ 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
+ 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
+ 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
+ if message.entities then
+ for _, entity in ipairs(message.entities) do
+ if entity.type == 'text_mention' and entity.user then
+ local name = message.text:sub(entity.offset + 1, entity.offset + entity.length)
+ message.text = message.text:gsub(name, tostring(entity.user.id), 1)
+ end
+ end
+ end
+ if message.caption_entities then
+ message.entities = message.caption_entities
+ message.caption_entities = nil
+ end
+ if message.reply then
+ message.reply = sort_message(message.reply)
+ end
+ return message
+ end
+
+ it('should use caption when text is nil', function()
+ local msg = sort_message({ caption = 'hello caption' })
+ assert.are.equal('hello caption', msg.text)
+ end)
+
+ it('should default to empty string when both text and caption nil', function()
+ local msg = sort_message({})
+ assert.are.equal('', msg.text)
+ end)
+
+ it('should normalise /command_arg to /command arg', function()
+ local msg = sort_message({ text = '/ban_123' })
+ assert.are.equal('/ban 123', msg.text)
+ end)
+
+ it('should not modify /command without underscore', function()
+ local msg = sort_message({ text = '/ban 123' })
+ assert.are.equal('/ban 123', msg.text)
+ end)
+
+ it('should handle deep-link /start parameter', function()
+ local msg = sort_message({ text = '/start help' })
+ assert.are.equal('/help', msg.text)
+ end)
+
+ it('should handle deep-link with ! prefix', function()
+ local msg = sort_message({ text = '!start ban' })
+ assert.are.equal('/ban', msg.text)
+ end)
+
+ it('should move reply_to_message to reply', function()
+ local reply_msg = { text = 'reply text' }
+ local msg = sort_message({ text = 'hello', reply_to_message = reply_msg })
+ assert.is_not_nil(msg.reply)
+ assert.is_nil(msg.reply_to_message)
+ assert.are.equal('reply text', msg.reply.text)
+ end)
+
+ it('should normalise 2-letter language code to xx_xx', function()
+ local msg = sort_message({
+ text = 'hello',
+ from = { language_code = 'de' }
+ })
+ assert.are.equal('de_de', msg.from.language_code)
+ end)
+
+ it('should normalise en to en_us', function()
+ local msg = sort_message({
+ text = 'hello',
+ from = { language_code = 'en' }
+ })
+ assert.are.equal('en_us', msg.from.language_code)
+ end)
+
+ it('should normalise root to en_us', function()
+ local msg = sort_message({
+ text = 'hello',
+ from = { language_code = 'root' }
+ })
+ assert.are.equal('en_us', msg.from.language_code)
+ end)
+
+ it('should normalise en-US to en_us', function()
+ local msg = sort_message({
+ text = 'hello',
+ from = { language_code = 'en-US' }
+ })
+ assert.are.equal('en_us', msg.from.language_code)
+ end)
+
+ it('should normalise pt-BR to pt_br', function()
+ local msg = sort_message({
+ text = 'hello',
+ from = { language_code = 'pt-BR' }
+ })
+ assert.are.equal('pt_br', msg.from.language_code)
+ end)
+
+ it('should detect media messages', function()
+ local msg = sort_message({ text = '', photo = { { file_id = '123' } } })
+ assert.is_truthy(msg.is_media)
+ end)
+
+ it('should not detect non-media messages', function()
+ local msg = sort_message({ text = 'hello' })
+ assert.is_falsy(msg.is_media)
+ end)
+
+ it('should detect service messages', function()
+ local msg = sort_message({ text = '', new_chat_members = { { id = 1 } } })
+ assert.is_true(msg.is_service_message)
+ end)
+
+ it('should not detect regular messages as service', function()
+ local msg = sort_message({ text = 'hello' })
+ assert.is_false(msg.is_service_message)
+ end)
+
+ it('should replace text mentions with user IDs', function()
+ local msg = sort_message({
+ text = '/ban Alice',
+ entities = {
+ { type = 'text_mention', user = { id = 12345 }, offset = 5, length = 5 }
+ }
+ })
+ assert.are.equal('/ban 12345', msg.text)
+ end)
+
+ it('should move caption_entities to entities', function()
+ local entities = { { type = 'bold' } }
+ local msg = sort_message({ caption = 'hello', caption_entities = entities })
+ assert.are.same(entities, msg.entities)
+ assert.is_nil(msg.caption_entities)
+ end)
+
+ it('should recursively sort reply messages', function()
+ local msg = sort_message({
+ text = 'hello',
+ reply_to_message = { caption = 'reply caption' }
+ })
+ assert.are.equal('reply caption', msg.reply.text)
+ end)
+ end)
+
+ describe('extract_command()', function()
+ 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
+
+ it('should extract command from /command', function()
+ local cmd, args = extract_command('/ping')
+ assert.are.equal('ping', cmd)
+ assert.is_nil(args)
+ end)
+
+ it('should extract command and args', function()
+ local cmd, args = extract_command('/ban 12345 reason')
+ assert.are.equal('ban', cmd)
+ assert.are.equal('12345 reason', args)
+ end)
+
+ it('should handle ! prefix', function()
+ local cmd, args = extract_command('!ping')
+ assert.are.equal('ping', cmd)
+ end)
+
+ it('should handle # prefix', function()
+ local cmd, args = extract_command('#help')
+ assert.are.equal('help', cmd)
+ end)
+
+ it('should handle @botname suffix', function()
+ local cmd, args = extract_command('/ping@testbot', 'testbot')
+ assert.are.equal('ping', cmd)
+ end)
+
+ it('should lowercase command', function()
+ local cmd = extract_command('/PING')
+ assert.are.equal('ping', cmd)
+ end)
+
+ it('should return nil for non-command text', function()
+ local cmd = extract_command('hello world')
+ assert.is_nil(cmd)
+ end)
+
+ it('should return nil for nil text', function()
+ local cmd = extract_command(nil)
+ assert.is_nil(cmd)
+ end)
+
+ it('should return nil args for command without args', function()
+ local cmd, args = extract_command('/ping')
+ assert.is_nil(args)
+ end)
+
+ it('should handle underscore in command name', function()
+ local cmd = extract_command('/join_captcha')
+ assert.are.equal('join_captcha', cmd)
+ end)
+
+ it('should handle command with args and @bot', function()
+ local cmd, args = extract_command('/ban@testbot 12345', 'testbot')
+ assert.are.equal('ban', cmd)
+ assert.are.equal('12345', args)
+ end)
+ end)
+
+ describe('process_action() bug fix', function()
+ -- The fix: save message_id before nil'ing message.reply
+ it('should save reply message_id before nil-ing reply', function()
+ local bot_id = 123456789
+ local session_data = {}
+
+ -- Simulate session
+ local function get_action(chat_id, msg_id)
+ local key = chat_id .. ':' .. msg_id
+ return session_data[key]
+ end
+ local function del_action(chat_id, msg_id)
+ local key = chat_id .. ':' .. msg_id
+ session_data[key] = nil
+ end
+
+ -- Set up action
+ session_data['-100123:42'] = '/ban'
+
+ local message = {
+ text = '12345',
+ chat = { id = -100123 },
+ reply = {
+ message_id = 42,
+ from = { id = bot_id }
+ }
+ }
+
+ -- Process action (reimplemented)
+ if message.text and message.chat and message.reply
+ and message.reply.from and message.reply.from.id == bot_id then
+ local reply_message_id = message.reply.message_id
+ local action = get_action(message.chat.id, reply_message_id)
+ if action then
+ message.text = action .. ' ' .. message.text
+ message.reply = nil
+ del_action(message.chat.id, reply_message_id)
+ end
+ end
+
+ assert.are.equal('/ban 12345', message.text)
+ assert.is_nil(message.reply)
+ assert.is_nil(session_data['-100123:42'])
+ end)
+
+ it('should not modify message without reply to bot', function()
+ local message = {
+ text = 'hello',
+ chat = { id = -100123 },
+ }
+ -- No reply, no action processing
+ assert.are.equal('hello', message.text)
+ end)
+
+ it('should not modify message when reply is not from bot', function()
+ local bot_id = 123456789
+ local message = {
+ text = 'hello',
+ chat = { id = -100123 },
+ reply = {
+ message_id = 42,
+ from = { id = 999 }
+ }
+ }
+ -- reply.from.id != bot_id, so no action processing
+ if message.reply and message.reply.from and message.reply.from.id == bot_id then
+ -- This block should NOT execute
+ assert.fail('should not process action for non-bot reply')
+ end
+ assert.are.equal('hello', message.text)
+ end)
+ end)
+
+ describe('resolve_alias()', function()
+ local mock_redis = require('spec.helpers.mock_redis')
+ local json
+
+ before_each(function()
+ json = {
+ encode = function(t)
+ local parts = {}
+ for k, v in pairs(t) do
+ table.insert(parts, '"' .. k .. '":"' .. v .. '"')
+ end
+ return '{' .. table.concat(parts, ',') .. '}'
+ end,
+ decode = function(s)
+ local result = {}
+ for k, v in s:gmatch('"([^"]+)":"([^"]+)"') do
+ result[k] = v
+ end
+ return result
+ end,
+ }
+ -- Make json available globally for the resolve_alias reimplementation
+ package.loaded['dkjson'] = json
+ end)
+
+ it('should resolve a cached alias', function()
+ local redis = mock_redis.new()
+ -- Set cached aliases
+ redis.set('cache:aliases:-100123', '{"b":"ban","h":"help"}')
+
+ local message = {
+ text = '/b user123',
+ chat = { id = -100123, type = 'supergroup' }
+ }
+
+ -- Reimplemented resolve_alias
+ local command, rest = message.text:lower():match('^[/!#]([%w_]+)(.*)')
+ local cached_aliases = redis.get('cache:aliases:' .. message.chat.id)
+ local aliases
+ if cached_aliases then
+ local ok, decoded = pcall(json.decode, cached_aliases)
+ if ok and decoded then aliases = decoded end
+ end
+ if type(aliases) == 'table' then
+ for alias, original in pairs(aliases) do
+ if command == alias then
+ message.text = '/' .. original .. (rest or '')
+ message.is_alias = true
+ break
+ end
+ end
+ end
+
+ assert.are.equal('/ban user123', message.text)
+ assert.is_true(message.is_alias)
+ end)
+
+ it('should fetch aliases from hash when cache misses', function()
+ local redis = mock_redis.new()
+ -- No cache, but aliases exist in hash
+ redis.hset('chat:-100123:aliases', 'h', 'help')
+
+ local message = {
+ text = '/h',
+ chat = { id = -100123, type = 'supergroup' }
+ }
+
+ local command, rest = message.text:lower():match('^[/!#]([%w_]+)(.*)')
+ local cached_aliases = redis.get('cache:aliases:' .. message.chat.id)
+ local aliases
+ if not cached_aliases then
+ aliases = redis.hgetall('chat:' .. message.chat.id .. ':aliases')
+ if type(aliases) == 'table' then
+ pcall(function()
+ redis.setex('cache:aliases:' .. message.chat.id, 300, json.encode(aliases))
+ end)
+ end
+ end
+ if type(aliases) == 'table' then
+ for alias, original in pairs(aliases) do
+ if command == alias then
+ message.text = '/' .. original .. (rest or '')
+ message.is_alias = true
+ break
+ end
+ end
+ end
+
+ assert.are.equal('/help', message.text)
+ -- Should have cached the aliases
+ assert.is_not_nil(redis.get('cache:aliases:-100123'))
+ end)
+
+ it('should not modify non-command messages', function()
+ local message = { text = 'hello world', chat = { id = -100123, type = 'supergroup' } }
+ if not message.text:match('^[/!#][%w_]+') then
+ -- should not process
+ end
+ assert.are.equal('hello world', message.text)
+ end)
+
+ it('should not resolve aliases in private chats', function()
+ local message = { text = '/b', chat = { type = 'private' } }
+ local should_skip = not message.chat or message.chat.type == 'private'
+ assert.is_true(should_skip)
+ end)
+ end)
+
+ describe('build_ctx lazy admin check', function()
+ it('should not call API for admin check until check_admin() is called', function()
+ local api_called = false
+ local mock_api = {
+ get_chat_member = function() api_called = true; return { ok = true, result = { status = 'member' } } end,
+ info = { id = 123 },
+ }
+
+ -- Simulate lazy admin check
+ local admin_resolved = false
+ local admin_value = false
+ local ctx = { is_admin = false, is_global_admin = false, is_group = true }
+
+ function ctx:check_admin()
+ if admin_resolved then return admin_value end
+ admin_resolved = true
+ api_called = true
+ admin_value = false
+ ctx.is_admin = admin_value
+ return admin_value
+ end
+
+ -- Before calling check_admin, no API call should happen
+ assert.is_false(api_called)
+ assert.is_false(ctx.is_admin)
+
+ -- After calling check_admin
+ ctx:check_admin()
+ assert.is_true(admin_resolved)
+ end)
+
+ it('should cache admin result for subsequent calls', function()
+ local call_count = 0
+ local admin_resolved = false
+ local admin_value = false
+ local ctx = { is_admin = false, is_global_admin = false, is_group = true }
+
+ function ctx:check_admin()
+ if admin_resolved then return admin_value end
+ admin_resolved = true
+ call_count = call_count + 1
+ admin_value = true
+ ctx.is_admin = admin_value
+ return admin_value
+ end
+
+ ctx:check_admin()
+ ctx:check_admin()
+ ctx:check_admin()
+ assert.are.equal(1, call_count)
+ assert.is_true(ctx.is_admin)
+ end)
+
+ it('should set is_admin = true for global admin without API call', function()
+ local admin_resolved = false
+ local admin_value = false
+ local ctx = { is_admin = false, is_global_admin = true, is_group = true }
+
+ function ctx:check_admin()
+ if admin_resolved then return admin_value end
+ admin_resolved = true
+ if ctx.is_global_admin then
+ admin_value = true
+ end
+ ctx.is_admin = admin_value
+ return admin_value
+ end
+
+ ctx:check_admin()
+ assert.is_true(ctx.is_admin)
+ end)
+ end)
+end)
diff --git a/spec/core/session_spec.lua b/spec/core/session_spec.lua
new file mode 100644
index 0000000..9de62de
--- /dev/null
+++ b/spec/core/session_spec.lua
@@ -0,0 +1,385 @@
+--[[
+ Tests for src/core/session.lua
+ Tests settings cache, admin status cache, AFK, captcha, rate limiting,
+ blocklist, disabled plugins, get_cached_setting, get_cached_list.
+]]
+
+describe('core.session', function()
+ local session
+ local mock_redis = require('spec.helpers.mock_redis')
+ local redis
+
+ before_each(function()
+ package.loaded['src.core.session'] = nil
+ session = require('src.core.session')
+ redis = mock_redis.new()
+ session.init(redis)
+ end)
+
+ after_each(function()
+ redis.reset()
+ end)
+
+ describe('settings cache', function()
+ it('should set and get a setting', function()
+ session.set_setting(123, 'antilink', 'true')
+ local val = session.get_setting(123, 'antilink')
+ assert.are.equal('true', val)
+ end)
+
+ it('should return nil for unset setting', function()
+ local val = session.get_setting(123, 'nonexistent')
+ assert.is_nil(val)
+ end)
+
+ it('should use correct cache key format', function()
+ session.set_setting(-100123, 'key', 'val')
+ assert.is_true(redis.has_command('setex'))
+ assert.is_not_nil(redis.store['cache:setting:-100123:key'])
+ end)
+
+ it('should use custom TTL', function()
+ session.set_setting(123, 'key', 'val', 600)
+ assert.are.equal(600, redis.ttls['cache:setting:123:key'])
+ end)
+
+ it('should use default TTL of 300', function()
+ session.set_setting(123, 'key', 'val')
+ assert.are.equal(300, redis.ttls['cache:setting:123:key'])
+ end)
+
+ it('should invalidate a setting', function()
+ session.set_setting(123, 'key', 'val')
+ session.invalidate_setting(123, 'key')
+ assert.is_nil(session.get_setting(123, 'key'))
+ end)
+ end)
+
+ describe('get_cached_setting()', function()
+ it('should return cached value without calling fetch_fn', function()
+ redis.setex('cache:setting:123:mykey', 300, 'cached_value')
+ local fetch_called = false
+ local val = session.get_cached_setting(123, 'mykey', function()
+ fetch_called = true
+ return 'db_value'
+ end)
+ assert.are.equal('cached_value', val)
+ assert.is_false(fetch_called)
+ end)
+
+ it('should call fetch_fn and cache on cache miss', function()
+ local fetch_called = false
+ local val = session.get_cached_setting(123, 'mykey', function()
+ fetch_called = true
+ return 'db_value'
+ end)
+ assert.are.equal('db_value', val)
+ assert.is_true(fetch_called)
+ -- Should now be cached
+ assert.is_not_nil(redis.store['cache:setting:123:mykey'])
+ end)
+
+ it('should cache nil results as __nil__', function()
+ local val = session.get_cached_setting(123, 'mykey', function()
+ return nil
+ end)
+ assert.is_nil(val)
+ assert.are.equal('__nil__', redis.store['cache:setting:123:mykey'])
+ end)
+
+ it('should return nil for cached __nil__ values', function()
+ redis.setex('cache:setting:123:mykey', 300, '__nil__')
+ local fetch_called = false
+ local val = session.get_cached_setting(123, 'mykey', function()
+ fetch_called = true
+ return 'should_not_reach'
+ end)
+ assert.is_nil(val)
+ assert.is_false(fetch_called)
+ end)
+
+ it('should respect custom TTL', function()
+ session.get_cached_setting(123, 'mykey', function()
+ return 'val'
+ end, 600)
+ assert.are.equal(600, redis.ttls['cache:setting:123:mykey'])
+ end)
+ end)
+
+ describe('get_cached_list()', function()
+ it('should return cached list without calling fetch_fn', function()
+ -- We need dkjson for this. Mock it.
+ package.loaded['dkjson'] = {
+ encode = function(t)
+ -- Simple JSON array encoding for tests
+ local items = {}
+ for _, v in ipairs(t) do
+ if type(v) == 'string' then
+ table.insert(items, '"' .. v .. '"')
+ else
+ table.insert(items, tostring(v))
+ end
+ end
+ return '[' .. table.concat(items, ',') .. ']'
+ end,
+ decode = function(s)
+ if s == '[]' then return {} end
+ -- Simple decoder for string arrays
+ local result = {}
+ for item in s:gmatch('"([^"]+)"') do
+ table.insert(result, item)
+ end
+ return result
+ end,
+ }
+
+ redis.setex('cache:list:123:filters', 300, '["hello","world"]')
+ local fetch_called = false
+ local val = session.get_cached_list(123, 'filters', function()
+ fetch_called = true
+ return { 'from_db' }
+ end)
+ assert.is_false(fetch_called)
+ assert.are.equal(2, #val)
+ end)
+
+ it('should call fetch_fn and cache on miss', function()
+ package.loaded['dkjson'] = {
+ encode = function(t) return '["a","b"]' end,
+ decode = function(s) return { 'a', 'b' } end,
+ }
+
+ local fetch_called = false
+ local val = session.get_cached_list(123, 'filters', function()
+ fetch_called = true
+ return { 'a', 'b' }
+ end)
+ assert.is_true(fetch_called)
+ assert.is_not_nil(redis.store['cache:list:123:filters'])
+ end)
+
+ it('should cache empty results as []', function()
+ package.loaded['dkjson'] = {
+ encode = function(t) return '[]' end,
+ decode = function(s) return {} end,
+ }
+
+ local val = session.get_cached_list(123, 'filters', function()
+ return nil
+ end)
+ assert.are.same({}, val)
+ assert.are.equal('[]', redis.store['cache:list:123:filters'])
+ end)
+
+ it('should invalidate cached list', function()
+ redis.setex('cache:list:123:filters', 300, '["hello"]')
+ session.invalidate_cached_list(123, 'filters')
+ assert.is_nil(redis.store['cache:list:123:filters'])
+ end)
+ end)
+
+ describe('admin status cache', function()
+ it('should return nil when not cached', function()
+ local val = session.get_admin_status(123, 456)
+ assert.is_nil(val)
+ end)
+
+ it('should cache admin=true as "1"', function()
+ session.set_admin_status(123, 456, true)
+ local val = session.get_admin_status(123, 456)
+ assert.is_true(val)
+ end)
+
+ it('should cache admin=false as "0"', function()
+ session.set_admin_status(123, 456, false)
+ local val = session.get_admin_status(123, 456)
+ assert.is_false(val)
+ end)
+
+ it('should use correct key format', function()
+ session.set_admin_status(-100123, 456, true)
+ assert.is_not_nil(redis.store['cache:admin:-100123:456'])
+ end)
+
+ it('should use 300s TTL', function()
+ session.set_admin_status(123, 456, true)
+ assert.are.equal(300, redis.ttls['cache:admin:123:456'])
+ end)
+ end)
+
+ describe('action state', function()
+ it('should set and get an action', function()
+ session.set_action(123, 100, '/ban')
+ local val = session.get_action(123, 100)
+ assert.are.equal('/ban', val)
+ end)
+
+ it('should return nil for non-existent action', function()
+ assert.is_nil(session.get_action(123, 999))
+ end)
+
+ it('should delete an action', function()
+ session.set_action(123, 100, '/ban')
+ session.del_action(123, 100)
+ assert.is_nil(session.get_action(123, 100))
+ end)
+
+ it('should use 300s TTL for actions', function()
+ session.set_action(123, 100, '/ban')
+ assert.are.equal(300, redis.ttls['action:123:100'])
+ end)
+ end)
+
+ describe('AFK status', function()
+ it('should set AFK status with timestamp', function()
+ session.set_afk(456)
+ local afk = session.get_afk(456)
+ assert.is_not_nil(afk)
+ assert.is_not_nil(afk.since)
+ assert.is_nil(afk.note)
+ end)
+
+ it('should set AFK status with note', function()
+ session.set_afk(456, 'gone fishing')
+ local afk = session.get_afk(456)
+ assert.is_not_nil(afk)
+ assert.are.equal('gone fishing', afk.note)
+ end)
+
+ it('should return nil for non-AFK user', function()
+ assert.is_nil(session.get_afk(456))
+ end)
+
+ it('should clear AFK status', function()
+ session.set_afk(456, 'brb')
+ session.clear_afk(456)
+ assert.is_nil(session.get_afk(456))
+ end)
+
+ it('should not set note when note is empty string', function()
+ session.set_afk(456, '')
+ local afk = session.get_afk(456)
+ assert.is_not_nil(afk)
+ assert.is_nil(afk.note)
+ end)
+ end)
+
+ describe('captcha state', function()
+ it('should set and get captcha', function()
+ session.set_captcha(-100123, 456, 'ABCD', 42)
+ local captcha = session.get_captcha(-100123, 456)
+ assert.is_not_nil(captcha)
+ assert.are.equal('ABCD', captcha.text)
+ assert.are.equal('42', captcha.message_id)
+ end)
+
+ it('should return nil for non-existent captcha', function()
+ assert.is_nil(session.get_captcha(-100123, 456))
+ end)
+
+ it('should clear captcha', function()
+ session.set_captcha(-100123, 456, 'ABCD', 42)
+ session.clear_captcha(-100123, 456)
+ assert.is_nil(session.get_captcha(-100123, 456))
+ end)
+
+ it('should use custom timeout', function()
+ session.set_captcha(-100123, 456, 'ABCD', 42, 120)
+ assert.are.equal(120, redis.ttls['captcha:-100123:456'])
+ end)
+
+ it('should use default 300s timeout', function()
+ session.set_captcha(-100123, 456, 'ABCD', 42)
+ assert.are.equal(300, redis.ttls['captcha:-100123:456'])
+ end)
+ end)
+
+ describe('rate limiting', function()
+ it('should increment rate counter', function()
+ local count = session.increment_rate(-100123, 456)
+ assert.are.equal(1, count)
+ end)
+
+ it('should increment counter on subsequent calls', function()
+ session.increment_rate(-100123, 456)
+ local count = session.increment_rate(-100123, 456)
+ assert.are.equal(2, count)
+ end)
+
+ it('should set expire on first increment', function()
+ session.increment_rate(-100123, 456, 10)
+ assert.are.equal(10, redis.ttls['antispam:-100123:456'])
+ end)
+
+ it('should use default 5s TTL', function()
+ session.increment_rate(-100123, 456)
+ assert.are.equal(5, redis.ttls['antispam:-100123:456'])
+ end)
+
+ it('should get current rate', function()
+ session.increment_rate(-100123, 456)
+ session.increment_rate(-100123, 456)
+ session.increment_rate(-100123, 456)
+ local rate = session.get_rate(-100123, 456)
+ assert.are.equal(3, rate)
+ end)
+
+ it('should return 0 for no-rate user', function()
+ local rate = session.get_rate(-100123, 789)
+ assert.are.equal(0, rate)
+ end)
+ end)
+
+ describe('global blocklist', function()
+ it('should check blocklist status (not blocked)', function()
+ assert.is_false(session.is_globally_blocklisted(456))
+ end)
+
+ it('should set and check blocklist', function()
+ session.set_global_blocklist(456)
+ assert.is_true(session.is_globally_blocklisted(456))
+ end)
+
+ it('should set blocklist with TTL', function()
+ session.set_global_blocklist(456, 86400)
+ assert.is_true(session.is_globally_blocklisted(456))
+ assert.are.equal(86400, redis.ttls['global_blocklist:456'])
+ end)
+
+ it('should use single exists call (not double)', function()
+ session.is_globally_blocklisted(456)
+ -- Should only have one exists command
+ local exists_count = redis.count_commands('exists')
+ assert.are.equal(1, exists_count)
+ end)
+ end)
+
+ describe('disabled plugins', function()
+ it('should return empty list when no plugins disabled', function()
+ local disabled = session.get_disabled_plugins(123)
+ assert.are.same({}, disabled)
+ end)
+
+ it('should disable a plugin', function()
+ session.disable_plugin(123, 'weather')
+ assert.is_true(session.is_plugin_disabled(123, 'weather'))
+ end)
+
+ it('should not report non-disabled plugin as disabled', function()
+ assert.is_false(session.is_plugin_disabled(123, 'weather'))
+ end)
+
+ it('should enable a previously disabled plugin', function()
+ session.disable_plugin(123, 'weather')
+ session.enable_plugin(123, 'weather')
+ assert.is_false(session.is_plugin_disabled(123, 'weather'))
+ end)
+
+ it('should return list of disabled plugins', function()
+ session.disable_plugin(123, 'weather')
+ session.disable_plugin(123, 'translate')
+ local disabled = session.get_disabled_plugins(123)
+ assert.are.equal(2, #disabled)
+ end)
+ end)
+end)
diff --git a/spec/db/migrations_spec.lua b/spec/db/migrations_spec.lua
new file mode 100644
index 0000000..f27ccbb
--- /dev/null
+++ b/spec/db/migrations_spec.lua
@@ -0,0 +1,302 @@
+--[[
+ Tests for database migrations.
+ Validates that migration SQL is well-formed by checking that each migration:
+ - Has an up() function
+ - Returns non-empty SQL
+ - Contains expected table/index creation statements
+ - Has valid SQL syntax (basic structural checks)
+]]
+
+describe('db.migrations', function()
+ describe('001_initial_schema', function()
+ local migration
+
+ before_each(function()
+ package.loaded['src.db.migrations.001_initial_schema'] = nil
+ migration = require('src.db.migrations.001_initial_schema')
+ end)
+
+ it('should have an up() function', function()
+ assert.are.equal('function', type(migration.up))
+ end)
+
+ it('should return non-empty SQL', function()
+ local sql = migration.up()
+ assert.is_truthy(sql)
+ assert.is_true(#sql > 0)
+ end)
+
+ it('should create users table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS users'))
+ end)
+
+ it('should create chats table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS chats'))
+ end)
+
+ it('should create chat_settings table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS chat_settings'))
+ end)
+
+ it('should create chat_members table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS chat_members'))
+ end)
+
+ it('should create bans table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS bans'))
+ end)
+
+ it('should create warnings table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS warnings'))
+ end)
+
+ it('should create filters table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS filters'))
+ end)
+
+ it('should create rules table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS rules'))
+ end)
+
+ it('should create welcome_messages table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS welcome_messages'))
+ end)
+
+ it('should create saved_notes table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS saved_notes'))
+ end)
+
+ it('should create admin_actions table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS admin_actions'))
+ end)
+
+ it('should create disabled_plugins table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS disabled_plugins'))
+ end)
+
+ it('should create triggers table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS triggers'))
+ end)
+
+ it('should create aliases table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS aliases'))
+ end)
+
+ it('should create user_locations table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS user_locations'))
+ end)
+
+ it('should create custom_commands table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS custom_commands'))
+ end)
+
+ it('should create indexes', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE INDEX'))
+ end)
+
+ it('should have PRIMARY KEY on users.user_id', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('user_id BIGINT PRIMARY KEY'))
+ end)
+
+ it('should have balanced parentheses', function()
+ local sql = migration.up()
+ local open = 0
+ for _ in sql:gmatch('%(') do open = open + 1 end
+ local close = 0
+ for _ in sql:gmatch('%)') do close = close + 1 end
+ assert.are.equal(open, close)
+ end)
+
+ it('should contain no stray control characters', function()
+ local sql = migration.up()
+ -- Only allow printable ASCII plus whitespace
+ local cleaned = sql:gsub('[%w%p%s]', '')
+ assert.are.equal(0, #cleaned)
+ end)
+ end)
+
+ describe('002_federation_tables', function()
+ local migration
+
+ before_each(function()
+ package.loaded['src.db.migrations.002_federation_tables'] = nil
+ migration = require('src.db.migrations.002_federation_tables')
+ end)
+
+ it('should have an up() function', function()
+ assert.are.equal('function', type(migration.up))
+ end)
+
+ it('should create federations table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS federations'))
+ end)
+
+ it('should create federation_admins table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS federation_admins'))
+ end)
+
+ it('should create federation_bans table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS federation_bans'))
+ end)
+
+ it('should create federation_chats table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS federation_chats'))
+ end)
+
+ it('should create federation_allowlist table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS federation_allowlist'))
+ end)
+
+ it('should use UUID for federation ID', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('UUID'))
+ end)
+
+ it('should have CASCADE delete on foreign keys', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('ON DELETE CASCADE'))
+ end)
+
+ it('should have indexes on frequently queried columns', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('idx_federation_bans_user'))
+ assert.is_truthy(sql:match('idx_federation_chats_chat'))
+ end)
+
+ it('should have balanced parentheses', function()
+ local sql = migration.up()
+ local open = 0
+ for _ in sql:gmatch('%(') do open = open + 1 end
+ local close = 0
+ for _ in sql:gmatch('%)') do close = close + 1 end
+ assert.are.equal(open, close)
+ end)
+ end)
+
+ describe('003_statistics_tables', function()
+ local migration
+
+ before_each(function()
+ package.loaded['src.db.migrations.003_statistics_tables'] = nil
+ migration = require('src.db.migrations.003_statistics_tables')
+ end)
+
+ it('should have an up() function', function()
+ assert.are.equal('function', type(migration.up))
+ end)
+
+ it('should create message_stats table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS message_stats'))
+ end)
+
+ it('should create command_stats table', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('CREATE TABLE IF NOT EXISTS command_stats'))
+ end)
+
+ it('should have composite primary key for message_stats', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('PRIMARY KEY %(chat_id, user_id, date%)'))
+ end)
+
+ it('should have composite primary key for command_stats', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('PRIMARY KEY %(chat_id, command, date%)'))
+ end)
+
+ it('should have date indexes', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('idx_message_stats_date'))
+ assert.is_truthy(sql:match('idx_command_stats_date'))
+ end)
+ end)
+
+ describe('004_performance_indexes', function()
+ local migration
+
+ before_each(function()
+ package.loaded['src.db.migrations.004_performance_indexes'] = nil
+ migration = require('src.db.migrations.004_performance_indexes')
+ end)
+
+ it('should have an up() function', function()
+ assert.are.equal('function', type(migration.up))
+ end)
+
+ it('should be idempotent (IF NOT EXISTS)', function()
+ local sql = migration.up()
+ for stmt in sql:gmatch('[^;]+') do
+ stmt = stmt:match('^%s*(.-)%s*$')
+ if stmt ~= '' then
+ assert.is_truthy(stmt:match('IF NOT EXISTS'),
+ 'Statement missing IF NOT EXISTS: ' .. stmt:sub(1, 80))
+ end
+ end
+ end)
+
+ it('should create federation and stats indexes', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('idx_federation_bans_user'))
+ assert.is_truthy(sql:match('idx_federation_chats_chat'))
+ assert.is_truthy(sql:match('idx_msg_stats_chat_date'))
+ end)
+
+ it('should create chat_settings index', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('idx_chat_settings_chat'))
+ end)
+
+ it('should create disabled_plugins index', function()
+ local sql = migration.up()
+ assert.is_truthy(sql:match('idx_disabled_plugins_chat'))
+ end)
+ end)
+
+ describe('migration runner', function()
+ local init_mod
+
+ before_each(function()
+ package.loaded['src.db.init'] = nil
+ package.loaded['src.core.logger'] = {
+ debug = function() end, info = function() end,
+ warn = function() end, error = function() end,
+ }
+ init_mod = require('src.db.init')
+ end)
+
+ it('should have a run function', function()
+ assert.are.equal('function', type(init_mod.run))
+ end)
+
+ it('should reference all 4 migrations in order', function()
+ -- The run function references migration_files internally
+ -- We verify the module loads without error which implies
+ -- the file list is valid
+ assert.is_not_nil(init_mod)
+ end)
+ end)
+end)
diff --git a/spec/helpers/mock_api.lua b/spec/helpers/mock_api.lua
new file mode 100644
index 0000000..bac61ec
--- /dev/null
+++ b/spec/helpers/mock_api.lua
@@ -0,0 +1,216 @@
+--[[
+ mattata v2.0 - Mock Telegram Bot API
+ Records all calls and returns configurable responses for testing.
+]]
+
+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, ...)
+ 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)
+ 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)
+ return { ok = true, result = true }
+ 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)
+ return { ok = true, result = true }
+ end
+
+ function api.unpin_chat_message(chat_id, message_id)
+ record('unpin_chat_message', chat_id, message_id)
+ return { ok = true, result = true }
+ 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, ...)
+ 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)
+ return { ok = true, result = { message_id = message_id } }
+ end
+
+ function api.answer_callback_query(callback_id, text)
+ record('answer_callback_query', callback_id, text)
+ 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
+
+ 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_db.lua b/spec/helpers/mock_db.lua
new file mode 100644
index 0000000..f82b163
--- /dev/null
+++ b/spec/helpers/mock_db.lua
@@ -0,0 +1,108 @@
+--[[
+ mattata v2.0 - Mock PostgreSQL Database
+ Records queries and returns configurable results for testing.
+]]
+
+local mock_db = {}
+
+function mock_db.new()
+ local db = {
+ queries = {},
+ data = {}, -- table_name -> array of rows
+ next_result = nil,
+ result_queue = {}, -- FIFO queue of results to return
+ }
+
+ function db.query(sql)
+ table.insert(db.queries, { sql = sql })
+ if db.next_result then
+ local r = db.next_result
+ db.next_result = nil
+ return r
+ end
+ if #db.result_queue > 0 then
+ return table.remove(db.result_queue, 1)
+ end
+ return {}
+ end
+
+ function db.execute(sql, params)
+ table.insert(db.queries, { sql = sql, params = params })
+ if db.next_result then
+ local r = db.next_result
+ db.next_result = nil
+ return r
+ end
+ if #db.result_queue > 0 then
+ return table.remove(db.result_queue, 1)
+ end
+ return {}
+ end
+
+ function db.insert(table_name, data_row)
+ table.insert(db.queries, { op = 'insert', table_name = table_name, data = data_row })
+ if not db.data[table_name] then db.data[table_name] = {} end
+ table.insert(db.data[table_name], data_row)
+ if db.next_result then
+ local r = db.next_result
+ db.next_result = nil
+ return r
+ end
+ return { data_row }
+ end
+
+ function db.upsert(table_name, data_row, conflict_keys, update_keys)
+ table.insert(db.queries, { op = 'upsert', table_name = table_name, data = data_row, conflict_keys = conflict_keys, update_keys = update_keys })
+ if not db.data[table_name] then db.data[table_name] = {} end
+ table.insert(db.data[table_name], data_row)
+ if db.next_result then
+ local r = db.next_result
+ db.next_result = nil
+ return r
+ end
+ return { data_row }
+ end
+
+ function db.set_next_result(result)
+ db.next_result = result
+ end
+
+ -- Queue multiple results (consumed in order)
+ function db.queue_result(result)
+ table.insert(db.result_queue, result)
+ end
+
+ function db.transaction(fn)
+ return fn(db.query, db.execute)
+ end
+
+ function db.pool_stats()
+ return { available = 5, max_size = 10 }
+ end
+
+ function db.reset()
+ db.queries = {}
+ db.data = {}
+ db.next_result = nil
+ db.result_queue = {}
+ end
+
+ -- Helper: check if a query matching pattern was executed
+ function db.has_query(pattern)
+ for _, q in ipairs(db.queries) do
+ if q.sql and q.sql:match(pattern) then
+ return true
+ end
+ end
+ return false
+ end
+
+ -- Helper: get the last query
+ function db.last_query()
+ return db.queries[#db.queries]
+ end
+
+ return db
+end
+
+return mock_db
diff --git a/spec/helpers/mock_redis.lua b/spec/helpers/mock_redis.lua
new file mode 100644
index 0000000..5b92e0b
--- /dev/null
+++ b/spec/helpers/mock_redis.lua
@@ -0,0 +1,151 @@
+--[[
+ 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.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/helpers/test_helper.lua b/spec/helpers/test_helper.lua
new file mode 100644
index 0000000..3860d1b
--- /dev/null
+++ b/spec/helpers/test_helper.lua
@@ -0,0 +1,199 @@
+--[[
+ mattata v2.0 - Test Helper
+ Common utilities for busted test setup/teardown and assertion helpers.
+]]
+
+local test_helper = {}
+
+local mock_api = require('spec.helpers.mock_api')
+local mock_db = require('spec.helpers.mock_db')
+local mock_redis = require('spec.helpers.mock_redis')
+
+-- Create a fresh set of mocks for each test
+function test_helper.setup()
+ local env = {
+ api = mock_api.new(),
+ db = mock_db.new(),
+ redis = mock_redis.new(),
+ }
+ return env
+end
+
+-- Reset all mocks between tests
+function test_helper.teardown(env)
+ if env then
+ if env.api and env.api.reset then env.api.reset() end
+ if env.db and env.db.reset then env.db.reset() end
+ if env.redis and env.redis.reset then env.redis.reset() end
+ end
+end
+
+-- Build a mock message for testing
+function test_helper.make_message(overrides)
+ local msg = {
+ message_id = 1,
+ date = os.time(),
+ from = {
+ id = 111111,
+ is_bot = false,
+ first_name = 'Test',
+ last_name = 'User',
+ username = 'testuser',
+ language_code = 'en_gb',
+ },
+ chat = {
+ id = -100123456789,
+ title = 'Test Group',
+ type = 'supergroup',
+ },
+ text = '',
+ }
+ if overrides then
+ for k, v in pairs(overrides) do
+ if type(v) == 'table' and type(msg[k]) == 'table' then
+ for k2, v2 in pairs(v) do
+ msg[k][k2] = v2
+ end
+ else
+ msg[k] = v
+ end
+ end
+ end
+ return msg
+end
+
+-- Build a mock private message
+function test_helper.make_private_message(overrides)
+ local defaults = {
+ chat = {
+ id = 111111,
+ type = 'private',
+ first_name = 'Test',
+ last_name = 'User',
+ },
+ }
+ if overrides then
+ for k, v in pairs(overrides) do
+ defaults[k] = v
+ end
+ end
+ return test_helper.make_message(defaults)
+end
+
+-- Build a mock callback query
+function test_helper.make_callback_query(overrides)
+ local cb = {
+ id = 'callback_123',
+ from = {
+ id = 111111,
+ is_bot = false,
+ first_name = 'Test',
+ username = 'testuser',
+ },
+ message = {
+ message_id = 1,
+ chat = {
+ id = -100123456789,
+ type = 'supergroup',
+ title = 'Test Group',
+ },
+ },
+ data = '',
+ }
+ if overrides then
+ for k, v in pairs(overrides) do
+ cb[k] = v
+ end
+ end
+ return cb
+end
+
+-- Build a context (ctx) object similar to what the router builds
+function test_helper.make_ctx(env, overrides)
+ local ctx = {
+ api = env.api,
+ db = env.db,
+ redis = env.redis,
+ config = {
+ bot_name = function() return 'mattata' end,
+ get = function(key, default) return default end,
+ get_number = function(key, default) return default end,
+ is_enabled = function(key) return false end,
+ bot_admins = function() return {} end,
+ },
+ is_group = true,
+ is_supergroup = true,
+ is_private = false,
+ is_global_admin = false,
+ is_admin = false,
+ is_mod = false,
+ lang = {
+ errors = {
+ connection = 'Connection error.',
+ results = 'No results found.',
+ supergroup = 'This command can only be used in supergroups.',
+ admin = 'You need to be an admin to use this command.',
+ generic = 'An unexpected error occurred.',
+ },
+ },
+ lang_code = 'en_gb',
+ }
+ if overrides then
+ for k, v in pairs(overrides) do
+ ctx[k] = v
+ end
+ end
+ return ctx
+end
+
+-- Assert a specific API method was called
+function test_helper.assert_api_called(api, method)
+ local call = api.get_call(method)
+ assert.is_not_nil(call, 'Expected API method "' .. method .. '" to be called')
+ return call
+end
+
+-- Assert a specific API method was NOT called
+function test_helper.assert_api_not_called(api, method)
+ local call = api.get_call(method)
+ assert.is_nil(call, 'Expected API method "' .. method .. '" NOT to be called')
+end
+
+-- Assert a send_message was called with text matching a pattern
+function test_helper.assert_sent_message_matches(api, pattern)
+ local found = false
+ for _, call in ipairs(api.calls) do
+ if call.method == 'send_message' and call.args[2] and call.args[2]:match(pattern) then
+ found = true
+ break
+ end
+ end
+ assert.is_true(found, 'Expected send_message with text matching "' .. pattern .. '"')
+end
+
+-- Assert a specific DB query was executed
+function test_helper.assert_db_query_matches(db, pattern)
+ local found = false
+ for _, q in ipairs(db.queries) do
+ local sql = q.sql or ''
+ if sql:match(pattern) then
+ found = true
+ break
+ end
+ end
+ assert.is_true(found, 'Expected DB query matching "' .. pattern .. '"')
+end
+
+-- Assert a specific Redis command was issued
+function test_helper.assert_redis_command(redis, cmd)
+ local found = false
+ for _, c in ipairs(redis.commands) do
+ if c.cmd == cmd then
+ found = true
+ break
+ end
+ end
+ assert.is_true(found, 'Expected Redis command "' .. cmd .. '"')
+end
+
+return test_helper
diff --git a/spec/middleware/blocklist_spec.lua b/spec/middleware/blocklist_spec.lua
new file mode 100644
index 0000000..2b5fc42
--- /dev/null
+++ b/spec/middleware/blocklist_spec.lua
@@ -0,0 +1,156 @@
+--[[
+ Tests for src/middleware/blocklist.lua
+ Tests global blocklist, per-group blocklist, global ban, chat blocklist,
+ global admin bypass, and SpamWatch integration.
+]]
+
+describe('middleware.blocklist', function()
+ local blocklist
+ local test_helper = require('spec.helpers.test_helper')
+ local env, ctx, message
+
+ before_each(function()
+ package.loaded['src.middleware.blocklist'] = nil
+ package.loaded['src.core.config'] = {
+ get = function(key, default) return default end,
+ is_enabled = function() return false end,
+ load = function() end,
+ bot_admins = function() return {} end,
+ VERSION = '2.0',
+ }
+ package.loaded['src.core.session'] = {
+ is_globally_blocklisted = function(user_id)
+ return env.redis.exists('global_blocklist:' .. tostring(user_id)) == 1
+ end,
+ }
+ package.loaded['src.core.logger'] = {
+ debug = function() end,
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ }
+
+ blocklist = require('src.middleware.blocklist')
+ 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('when message has no from', function()
+ it('should stop processing', function()
+ message.from = nil
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_false(should_continue)
+ end)
+ end)
+
+ describe('global admin bypass', function()
+ it('should always allow global admins', function()
+ ctx.is_global_admin = true
+ env.redis.set('global_blocklist:' .. message.from.id, '1')
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('global blocklist', function()
+ it('should block globally blocklisted users', function()
+ env.redis.set('global_blocklist:' .. message.from.id, '1')
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_false(should_continue)
+ assert.is_true(new_ctx.is_blocklisted)
+ end)
+
+ it('should allow non-blocklisted users', function()
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('global ban', function()
+ it('should block globally banned users', function()
+ env.redis.set('global_ban:' .. message.from.id, 'Spamming')
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_false(should_continue)
+ assert.is_true(new_ctx.is_globally_banned)
+ end)
+
+ it('should auto-ban globally banned users in groups', function()
+ ctx.is_group = true
+ env.redis.set('global_ban:' .. message.from.id, 'Spamming')
+ blocklist.run(ctx, message)
+ test_helper.assert_api_called(env.api, 'ban_chat_member')
+ end)
+
+ it('should not auto-ban in private chats', function()
+ ctx.is_group = false
+ env.redis.set('global_ban:' .. message.from.id, 'Spamming')
+ blocklist.run(ctx, message)
+ test_helper.assert_api_not_called(env.api, 'ban_chat_member')
+ end)
+ end)
+
+ describe('per-group blocklist', function()
+ it('should block group-blocklisted users', function()
+ ctx.is_group = true
+ env.redis.set('group_blocklist:' .. message.chat.id .. ':' .. message.from.id, '1')
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_false(should_continue)
+ assert.is_true(new_ctx.is_group_blocklisted)
+ end)
+
+ it('should allow users not on group blocklist', function()
+ ctx.is_group = true
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+
+ it('should not check group blocklist in private chats', function()
+ ctx.is_group = false
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('blocklisted chats', function()
+ it('should leave blocklisted chats', function()
+ ctx.is_group = true
+ env.redis.set('blocklisted_chats:' .. message.chat.id, '1')
+ blocklist.run(ctx, message)
+ test_helper.assert_api_called(env.api, 'leave_chat')
+ end)
+
+ it('should stop processing for blocklisted chats', function()
+ ctx.is_group = true
+ env.redis.set('blocklisted_chats:' .. message.chat.id, '1')
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_false(should_continue)
+ end)
+ end)
+
+ describe('SpamWatch', function()
+ it('should not check SpamWatch when no token configured', function()
+ local new_ctx, should_continue = blocklist.run(ctx, message)
+ assert.is_true(should_continue)
+ assert.is_nil(new_ctx.spamwatch_checked)
+ end)
+ end)
+
+ describe('name', function()
+ it('should be "blocklist"', function()
+ assert.are.equal('blocklist', blocklist.name)
+ end)
+ end)
+
+ describe('run interface', function()
+ it('should be a table with a run function', function()
+ assert.are.equal('table', type(blocklist))
+ assert.are.equal('function', type(blocklist.run))
+ end)
+ end)
+end)
diff --git a/spec/middleware/captcha_spec.lua b/spec/middleware/captcha_spec.lua
new file mode 100644
index 0000000..08678fd
--- /dev/null
+++ b/spec/middleware/captcha_spec.lua
@@ -0,0 +1,107 @@
+--[[
+ 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
+ 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')
+ 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/middleware/federation_spec.lua b/spec/middleware/federation_spec.lua
new file mode 100644
index 0000000..f322f7e
--- /dev/null
+++ b/spec/middleware/federation_spec.lua
@@ -0,0 +1,189 @@
+--[[
+ Tests for src/middleware/federation.lua
+ Tests chat federation lookup, ban check with cache, allowlist bypass.
+]]
+
+describe('middleware.federation', function()
+ local federation_mw
+ local test_helper = require('spec.helpers.test_helper')
+ local mock_redis = require('spec.helpers.mock_redis')
+ local env, ctx, message
+
+ before_each(function()
+ package.loaded['src.middleware.federation'] = nil
+ package.loaded['src.core.session'] = {
+ get_cached_setting = function(chat_id, key, fetch_fn, ttl)
+ local redis = env.redis
+ 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 or 300, tostring(value))
+ else
+ redis.setex(cache_key, ttl or 300, '__nil__')
+ end
+ return value
+ end,
+ }
+ package.loaded['src.core.logger'] = {
+ debug = function() end,
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ }
+
+ federation_mw = require('src.middleware.federation')
+ 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 "federation"', function()
+ assert.are.equal('federation', federation_mw.name)
+ end)
+ end)
+
+ describe('non-group messages', function()
+ it('should pass through for private messages', function()
+ ctx.is_group = false
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('no from', function()
+ it('should pass through when message has no from', function()
+ message.from = nil
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('global admin bypass', function()
+ it('should bypass federation checks for global admins', function()
+ ctx.is_global_admin = true
+ -- Even if banned, should pass
+ env.redis.set('fban:test-fed:' .. message.from.id, 'Spamming')
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('chat not in federation', function()
+ it('should pass through when chat has no federation', function()
+ -- DB returns empty result for federation lookup
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('federation ban check', function()
+ it('should ban user who is federation-banned', function()
+ -- Set up federation membership
+ env.redis.setex(
+ 'cache:setting:' .. message.chat.id .. ':federation_id',
+ 300, 'test-fed-uuid'
+ )
+ -- Set up ban in Redis cache
+ env.redis.set('fban:test-fed-uuid:' .. message.from.id, 'Spamming')
+ -- No allowlist entry
+ env.redis.set('fallowlist:test-fed-uuid:' .. message.from.id, '0')
+
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_false(should_continue)
+ test_helper.assert_api_called(env.api, 'ban_chat_member')
+ end)
+
+ it('should set federation_id in context', function()
+ env.redis.setex(
+ 'cache:setting:' .. message.chat.id .. ':federation_id',
+ 300, 'test-fed-uuid'
+ )
+ env.redis.set('fban:test-fed-uuid:' .. message.from.id, '__not_banned__')
+
+ local new_ctx = federation_mw.run(ctx, message)
+ assert.are.equal('test-fed-uuid', new_ctx.federation_id)
+ end)
+
+ it('should pass through for non-banned users', function()
+ env.redis.setex(
+ 'cache:setting:' .. message.chat.id .. ':federation_id',
+ 300, 'test-fed-uuid'
+ )
+ env.redis.set('fban:test-fed-uuid:' .. message.from.id, '__not_banned__')
+
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+
+ it('should query DB on cache miss and cache result', function()
+ env.redis.setex(
+ 'cache:setting:' .. message.chat.id .. ':federation_id',
+ 300, 'test-fed-uuid'
+ )
+ -- No fban key cached; DB will return empty (not banned)
+ env.db.set_next_result({})
+
+ federation_mw.run(ctx, message)
+
+ -- Should have cached the __not_banned__ result
+ local ban_key = 'fban:test-fed-uuid:' .. message.from.id
+ assert.are.equal('__not_banned__', env.redis.store[ban_key])
+ end)
+
+ it('should cache ban reason from DB', function()
+ env.redis.setex(
+ 'cache:setting:' .. message.chat.id .. ':federation_id',
+ 300, 'test-fed-uuid'
+ )
+ -- DB returns ban
+ env.db.queue_result({ { reason = 'Spamming links' } })
+ -- DB returns no allowlist
+ env.db.queue_result({})
+
+ federation_mw.run(ctx, message)
+
+ local ban_key = 'fban:test-fed-uuid:' .. message.from.id
+ assert.are.equal('Spamming links', env.redis.store[ban_key])
+ end)
+ end)
+
+ describe('allowlist bypass', function()
+ it('should allow banned users who are on the allowlist', function()
+ env.redis.setex(
+ 'cache:setting:' .. message.chat.id .. ':federation_id',
+ 300, 'test-fed-uuid'
+ )
+ env.redis.set('fban:test-fed-uuid:' .. message.from.id, 'Spamming')
+ -- User is allowlisted
+ env.redis.set('fallowlist:test-fed-uuid:' .. message.from.id, '1')
+
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ test_helper.assert_api_not_called(env.api, 'ban_chat_member')
+ end)
+
+ it('should query DB for allowlist on cache miss', function()
+ env.redis.setex(
+ 'cache:setting:' .. message.chat.id .. ':federation_id',
+ 300, 'test-fed-uuid'
+ )
+ env.redis.set('fban:test-fed-uuid:' .. message.from.id, 'Reason')
+ -- No allowlist cache
+ -- DB says user IS allowlisted
+ env.db.set_next_result({ { ['1'] = 1 } })
+
+ local new_ctx, should_continue = federation_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+end)
diff --git a/spec/middleware/language_spec.lua b/spec/middleware/language_spec.lua
new file mode 100644
index 0000000..4f82dbd
--- /dev/null
+++ b/spec/middleware/language_spec.lua
@@ -0,0 +1,139 @@
+--[[
+ Tests for src/middleware/language.lua
+ Tests user language selection, group language override.
+]]
+
+describe('middleware.language', function()
+ local language_mw
+ local test_helper = require('spec.helpers.test_helper')
+ local env, ctx, message
+
+ local mock_en_gb = { errors = { connection = 'Connection error.' } }
+ local mock_de_de = { errors = { connection = 'Verbindungsfehler.' } }
+ local mock_pt_br = { errors = { connection = 'Erro de conexao.' } }
+
+ before_each(function()
+ package.loaded['src.middleware.language'] = nil
+ package.loaded['src.core.i18n'] = {
+ get = function(code)
+ if code == 'de_de' then return mock_de_de end
+ if code == 'pt_br' then return mock_pt_br end
+ return mock_en_gb
+ end,
+ exists = function(code)
+ return code == 'en_gb' or code == 'de_de' or code == 'pt_br'
+ end,
+ }
+ package.loaded['src.core.session'] = {
+ get_setting = function(id, key)
+ -- Return nil by default, tests override via redis
+ return env.redis.get('cache:setting:' .. tostring(id) .. ':' .. key)
+ end,
+ }
+
+ language_mw = require('src.middleware.language')
+ 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 "language"', function()
+ assert.are.equal('language', language_mw.name)
+ end)
+ end)
+
+ describe('default language', function()
+ it('should default to en_gb', function()
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('en_gb', new_ctx.lang_code)
+ end)
+
+ it('should set lang table in context', function()
+ local new_ctx = language_mw.run(ctx, message)
+ assert.is_not_nil(new_ctx.lang)
+ end)
+ end)
+
+ describe('user language', function()
+ it('should use user language setting when set', function()
+ env.redis.setex('cache:setting:' .. message.from.id .. ':language', 300, 'de_de')
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('de_de', new_ctx.lang_code)
+ assert.are.same(mock_de_de, new_ctx.lang)
+ end)
+
+ it('should fall back to Telegram language code when no setting', function()
+ message.from.language_code = 'de_de'
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('de_de', new_ctx.lang_code)
+ end)
+
+ it('should ignore unsupported Telegram language code', function()
+ message.from.language_code = 'zz_zz'
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('en_gb', new_ctx.lang_code)
+ end)
+
+ it('should prefer user setting over Telegram language', function()
+ message.from.language_code = 'de_de'
+ env.redis.setex('cache:setting:' .. message.from.id .. ':language', 300, 'pt_br')
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('pt_br', new_ctx.lang_code)
+ end)
+ end)
+
+ describe('group language override', function()
+ it('should use group language when force is set', function()
+ ctx.is_group = true
+ env.redis.setex('cache:setting:' .. message.chat.id .. ':force group language', 300, 'true')
+ env.redis.setex('cache:setting:' .. message.chat.id .. ':group language', 300, 'de_de')
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('de_de', new_ctx.lang_code)
+ end)
+
+ it('should not override in private chats', function()
+ ctx.is_group = false
+ env.redis.setex('cache:setting:' .. message.from.id .. ':language', 300, 'pt_br')
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('pt_br', new_ctx.lang_code)
+ end)
+
+ it('should not override when force is not set', function()
+ ctx.is_group = true
+ env.redis.setex('cache:setting:' .. message.from.id .. ':language', 300, 'pt_br')
+ env.redis.setex('cache:setting:' .. message.chat.id .. ':group language', 300, 'de_de')
+ -- No 'force group language' key
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('pt_br', new_ctx.lang_code)
+ end)
+
+ it('should fall back to en_gb when forced but no group lang set', function()
+ ctx.is_group = true
+ env.redis.setex('cache:setting:' .. message.chat.id .. ':force group language', 300, 'true')
+ -- No 'group language' set
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('en_gb', new_ctx.lang_code)
+ end)
+ end)
+
+ describe('when message has no from', function()
+ it('should still set a default language', function()
+ message.from = nil
+ local new_ctx = language_mw.run(ctx, message)
+ assert.are.equal('en_gb', new_ctx.lang_code)
+ assert.is_not_nil(new_ctx.lang)
+ end)
+ end)
+
+ describe('always continues', function()
+ it('should always return true', function()
+ local _, should_continue = language_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+end)
diff --git a/spec/middleware/rate_limit_spec.lua b/spec/middleware/rate_limit_spec.lua
new file mode 100644
index 0000000..3e667a1
--- /dev/null
+++ b/spec/middleware/rate_limit_spec.lua
@@ -0,0 +1,190 @@
+--[[
+ Tests for src/middleware/rate_limit.lua
+ Tests counter increment, warning threshold, blocklist threshold.
+]]
+
+describe('middleware.rate_limit', function()
+ local rate_limit
+ local test_helper = require('spec.helpers.test_helper')
+ local mock_redis = require('spec.helpers.mock_redis')
+ local env, ctx, message
+
+ -- We need to mock session module to use our redis
+ local increment_rate_count = {}
+
+ before_each(function()
+ package.loaded['src.middleware.rate_limit'] = nil
+ package.loaded['src.core.session'] = {
+ increment_rate = function(chat_id, user_id, ttl)
+ local key = tostring(chat_id) .. ':' .. tostring(user_id)
+ increment_rate_count[key] = (increment_rate_count[key] or 0) + 1
+ return increment_rate_count[key]
+ end,
+ set_global_blocklist = function(user_id, duration)
+ -- Track blocklist calls
+ _G._blocklist_set = { user_id = user_id, duration = duration }
+ end,
+ }
+ package.loaded['src.core.logger'] = {
+ debug = function() end,
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ }
+
+ rate_limit = require('src.middleware.rate_limit')
+ env = test_helper.setup()
+ increment_rate_count = {}
+ _G._blocklist_set = nil
+
+ message = test_helper.make_message()
+ ctx = test_helper.make_ctx(env)
+ end)
+
+ after_each(function()
+ test_helper.teardown(env)
+ _G._blocklist_set = nil
+ end)
+
+ describe('name', function()
+ it('should be "rate_limit"', function()
+ assert.are.equal('rate_limit', rate_limit.name)
+ end)
+ end)
+
+ describe('when message has no from', function()
+ it('should continue processing', function()
+ message.from = nil
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('global admin bypass', function()
+ it('should not rate limit global admins', function()
+ ctx.is_global_admin = true
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('forwarded messages', function()
+ it('should not rate limit forwarded messages (forward_from)', function()
+ message.forward_from = { id = 999 }
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+
+ it('should not rate limit forwarded messages (forward_from_chat)', function()
+ message.forward_from_chat = { id = -100999 }
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('counter increment', function()
+ it('should increment the rate counter', function()
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ assert.are.equal(1, new_ctx.message_rate)
+ end)
+
+ it('should track rate in context', function()
+ local new_ctx = rate_limit.run(ctx, message)
+ assert.is_not_nil(new_ctx.message_rate)
+ end)
+ end)
+
+ describe('warning threshold (10)', function()
+ it('should send warning at threshold in private chat', function()
+ message.chat.type = 'private'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 9 -- Next increment will be 10
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ test_helper.assert_api_called(env.api, 'send_message')
+ end)
+
+ it('should not send warning below threshold', function()
+ message.chat.type = 'private'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 4 -- Next increment will be 5
+ rate_limit.run(ctx, message)
+ test_helper.assert_api_not_called(env.api, 'send_message')
+ end)
+
+ it('should not send warning in group chats', function()
+ message.chat.type = 'supergroup'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 9 -- Next will be 10
+ rate_limit.run(ctx, message)
+ test_helper.assert_api_not_called(env.api, 'send_message')
+ end)
+
+ it('should include username in warning message', function()
+ message.chat.type = 'private'
+ message.from.username = 'testuser'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 9
+ rate_limit.run(ctx, message)
+ test_helper.assert_sent_message_matches(env.api, '@testuser')
+ end)
+
+ it('should use first_name when no username', function()
+ message.chat.type = 'private'
+ message.from.username = nil
+ message.from.first_name = 'Alice'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 9
+ rate_limit.run(ctx, message)
+ test_helper.assert_sent_message_matches(env.api, 'Alice')
+ end)
+ end)
+
+ describe('blocklist threshold (25)', function()
+ it('should blocklist user at threshold in private chat', function()
+ message.chat.type = 'private'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 24 -- Next increment will be 25
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_false(should_continue)
+ assert.is_not_nil(_G._blocklist_set)
+ assert.are.equal(message.from.id, _G._blocklist_set.user_id)
+ end)
+
+ it('should blocklist for 24 hours', function()
+ message.chat.type = 'private'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 24
+ rate_limit.run(ctx, message)
+ assert.are.equal(86400, _G._blocklist_set.duration)
+ end)
+
+ it('should send blocklist notification', function()
+ message.chat.type = 'private'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 24
+ rate_limit.run(ctx, message)
+ test_helper.assert_api_called(env.api, 'send_message')
+ test_helper.assert_sent_message_matches(env.api, 'blocklisted')
+ end)
+
+ it('should not blocklist in group chats', function()
+ message.chat.type = 'supergroup'
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 24
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ assert.is_nil(_G._blocklist_set)
+ end)
+ end)
+
+ describe('normal operation', function()
+ it('should allow messages below thresholds', function()
+ local key = tostring(message.chat.id) .. ':' .. tostring(message.from.id)
+ increment_rate_count[key] = 2 -- Next will be 3
+ local new_ctx, should_continue = rate_limit.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+end)
diff --git a/spec/middleware/stats_spec.lua b/spec/middleware/stats_spec.lua
new file mode 100644
index 0000000..e3043f5
--- /dev/null
+++ b/spec/middleware/stats_spec.lua
@@ -0,0 +1,247 @@
+--[[
+ Tests for src/middleware/stats.lua
+ Tests message counter increment, command tracking, flush to PostgreSQL.
+]]
+
+describe('middleware.stats', function()
+ local stats_mw
+ local test_helper = require('spec.helpers.test_helper')
+ local env, ctx, message
+
+ before_each(function()
+ package.loaded['src.middleware.stats'] = nil
+ package.loaded['src.core.logger'] = {
+ debug = function() end,
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ }
+
+ stats_mw = require('src.middleware.stats')
+ 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 "stats"', function()
+ assert.are.equal('stats', stats_mw.name)
+ end)
+ end)
+
+ describe('when message has no from or chat', function()
+ it('should continue when no from', function()
+ message.from = nil
+ local _, should_continue = stats_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+
+ it('should continue when no chat', function()
+ message.chat = nil
+ local _, should_continue = stats_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+
+ it('should not increment counters when no from', function()
+ message.from = nil
+ stats_mw.run(ctx, message)
+ assert.are.equal(0, #env.redis.commands)
+ end)
+ end)
+
+ describe('message counter', function()
+ it('should increment message counter in Redis', function()
+ stats_mw.run(ctx, message)
+ -- Check that an incr command was issued
+ assert.is_true(env.redis.has_command('incr'))
+ end)
+
+ it('should use correct key format', function()
+ stats_mw.run(ctx, message)
+ local date = os.date('!%Y-%m-%d')
+ local expected_prefix = 'stats:msg:' .. message.chat.id .. ':' .. date
+ local found = false
+ for k in pairs(env.redis.store) do
+ if type(k) == 'string' and k:match('^stats:msg:') then
+ found = true
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should set 24h TTL on first increment', function()
+ stats_mw.run(ctx, message)
+ -- Find the stats key
+ for k, ttl in pairs(env.redis.ttls) do
+ if type(k) == 'string' and k:match('^stats:msg:') then
+ assert.are.equal(86400, ttl)
+ end
+ end
+ end)
+ end)
+
+ describe('command tracking', function()
+ it('should track command usage for / prefixed messages', function()
+ message.text = '/ping'
+ stats_mw.run(ctx, message)
+ local found = false
+ for k in pairs(env.redis.store) do
+ if type(k) == 'string' and k:match('^stats:cmd:ping:') then
+ found = true
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should track command usage for ! prefixed messages', function()
+ message.text = '!help'
+ stats_mw.run(ctx, message)
+ local found = false
+ for k in pairs(env.redis.store) do
+ if type(k) == 'string' and k:match('^stats:cmd:help:') then
+ found = true
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should track command usage for # prefixed messages', function()
+ message.text = '#ban user'
+ stats_mw.run(ctx, message)
+ local found = false
+ for k in pairs(env.redis.store) do
+ if type(k) == 'string' and k:match('^stats:cmd:ban:') then
+ found = true
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should lowercase command names', function()
+ message.text = '/PING'
+ stats_mw.run(ctx, message)
+ local found = false
+ for k in pairs(env.redis.store) do
+ if type(k) == 'string' and k:match('^stats:cmd:ping:') then
+ found = true
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should not track non-command messages', function()
+ message.text = 'hello world'
+ stats_mw.run(ctx, message)
+ local found = false
+ for k in pairs(env.redis.store) do
+ if type(k) == 'string' and k:match('^stats:cmd:') then
+ found = true
+ end
+ end
+ assert.is_false(found)
+ end)
+
+ it('should set 24h TTL on first command counter', function()
+ message.text = '/ping'
+ stats_mw.run(ctx, message)
+ for k, ttl in pairs(env.redis.ttls) do
+ if type(k) == 'string' and k:match('^stats:cmd:') then
+ assert.are.equal(86400, ttl)
+ end
+ end
+ end)
+ end)
+
+ describe('always continues', function()
+ it('should always return true', function()
+ local _, should_continue = stats_mw.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('flush()', function()
+ it('should flush message stats to PostgreSQL', function()
+ -- Set up some stats keys
+ local date = os.date('!%Y-%m-%d')
+ env.redis.set('stats:msg:-100123:' .. date .. ':456', '10')
+ env.redis.set('stats:msg:-100123:' .. date .. ':789', '5')
+
+ stats_mw.flush(env.db, env.redis)
+
+ -- Should have executed SQL insert/upsert
+ local sql_count = 0
+ for _, q in ipairs(env.db.queries) do
+ if q.sql and q.sql:match('message_stats') then
+ sql_count = sql_count + 1
+ end
+ end
+ assert.is_true(sql_count > 0)
+ end)
+
+ it('should flush command stats to PostgreSQL', function()
+ local date = os.date('!%Y-%m-%d')
+ env.redis.set('stats:cmd:ping:-100123:' .. date, '25')
+
+ stats_mw.flush(env.db, env.redis)
+
+ local sql_count = 0
+ for _, q in ipairs(env.db.queries) do
+ if q.sql and q.sql:match('command_stats') then
+ sql_count = sql_count + 1
+ end
+ end
+ assert.is_true(sql_count > 0)
+ end)
+
+ it('should delete Redis keys after flushing', function()
+ local date = os.date('!%Y-%m-%d')
+ local key = 'stats:msg:-100123:' .. date .. ':456'
+ env.redis.set(key, '10')
+
+ stats_mw.flush(env.db, env.redis)
+
+ assert.is_nil(env.redis.store[key])
+ end)
+
+ it('should skip keys with zero count', function()
+ local date = os.date('!%Y-%m-%d')
+ env.redis.set('stats:msg:-100123:' .. date .. ':456', '0')
+
+ stats_mw.flush(env.db, env.redis)
+
+ -- Should not have executed any message_stats SQL
+ local sql_count = 0
+ for _, q in ipairs(env.db.queries) do
+ if q.sql and q.sql:match('message_stats') then
+ sql_count = sql_count + 1
+ end
+ end
+ assert.are.equal(0, sql_count)
+ end)
+
+ it('should handle empty stats gracefully', function()
+ assert.has_no.errors(function()
+ stats_mw.flush(env.db, env.redis)
+ end)
+ end)
+
+ it('should use ON CONFLICT upsert SQL', function()
+ local date = os.date('!%Y-%m-%d')
+ env.redis.set('stats:msg:-100123:' .. date .. ':456', '10')
+
+ stats_mw.flush(env.db, env.redis)
+
+ local found = false
+ for _, q in ipairs(env.db.queries) do
+ if q.sql and q.sql:match('ON CONFLICT') then
+ found = true
+ end
+ end
+ assert.is_true(found)
+ end)
+ end)
+end)
diff --git a/spec/middleware/user_tracker_spec.lua b/spec/middleware/user_tracker_spec.lua
new file mode 100644
index 0000000..3d374f6
--- /dev/null
+++ b/spec/middleware/user_tracker_spec.lua
@@ -0,0 +1,221 @@
+--[[
+ Tests for src/middleware/user_tracker.lua
+ Tests debouncing, upsert on first message, username mapping.
+]]
+
+describe('middleware.user_tracker', function()
+ local user_tracker
+ local test_helper = require('spec.helpers.test_helper')
+ local env, ctx, message
+
+ before_each(function()
+ package.loaded['src.middleware.user_tracker'] = nil
+ package.loaded['src.core.logger'] = {
+ debug = function() end,
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ }
+
+ user_tracker = require('src.middleware.user_tracker')
+ 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 "user_tracker"', function()
+ assert.are.equal('user_tracker', user_tracker.name)
+ end)
+ end)
+
+ describe('when message has no from', function()
+ it('should continue processing', function()
+ message.from = nil
+ local new_ctx, should_continue = user_tracker.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+
+ describe('debouncing', function()
+ it('should skip DB upsert when dedup key exists', function()
+ -- Set dedup key to simulate recent activity
+ local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
+ env.redis.set(dedup_key, '1')
+
+ user_tracker.run(ctx, message)
+
+ -- Should NOT have done any upserts
+ local upsert_count = 0
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' then upsert_count = upsert_count + 1 end
+ end
+ assert.are.equal(0, upsert_count)
+ end)
+
+ it('should still update username mapping when debounced', function()
+ local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
+ env.redis.set(dedup_key, '1')
+
+ user_tracker.run(ctx, message)
+
+ -- Should have set username mapping
+ assert.is_not_nil(env.redis.store['username:testuser'])
+ end)
+
+ it('should upsert on first message (no dedup key)', function()
+ user_tracker.run(ctx, message)
+
+ -- Should have upserted user
+ local user_upserted = false
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'users' then
+ user_upserted = true
+ end
+ end
+ assert.is_true(user_upserted)
+ end)
+
+ it('should set dedup key with 60s TTL', function()
+ user_tracker.run(ctx, message)
+
+ local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
+ assert.is_not_nil(env.redis.store[dedup_key])
+ assert.are.equal(60, env.redis.ttls[dedup_key])
+ end)
+ end)
+
+ describe('user upsert', function()
+ it('should upsert user data to PostgreSQL', function()
+ user_tracker.run(ctx, message)
+
+ local found = false
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'users' then
+ found = true
+ assert.are.equal(message.from.id, q.data.user_id)
+ assert.are.equal('testuser', q.data.username)
+ assert.are.equal('Test', q.data.first_name)
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should handle users without username', function()
+ message.from.username = nil
+ user_tracker.run(ctx, message)
+
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'users' then
+ assert.is_nil(q.data.username)
+ end
+ end
+ end)
+
+ it('should lowercase username', function()
+ message.from.username = 'TestUser'
+ user_tracker.run(ctx, message)
+
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'users' then
+ assert.are.equal('testuser', q.data.username)
+ end
+ end
+ end)
+
+ it('should use correct conflict and update keys', function()
+ user_tracker.run(ctx, message)
+
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'users' then
+ assert.are.same({ 'user_id' }, q.conflict_keys)
+ -- Update keys should include username, first_name, etc.
+ local has_username = false
+ for _, k in ipairs(q.update_keys) do
+ if k == 'username' then has_username = true end
+ end
+ assert.is_true(has_username)
+ end
+ end
+ end)
+ end)
+
+ describe('chat upsert', function()
+ it('should upsert chat data for group messages', function()
+ user_tracker.run(ctx, message)
+
+ local found = false
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'chats' then
+ found = true
+ assert.are.equal(message.chat.id, q.data.chat_id)
+ assert.are.equal('Test Group', q.data.title)
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should not upsert chat for private messages', function()
+ message.chat.type = 'private'
+ user_tracker.run(ctx, message)
+
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'chats' then
+ assert.fail('should not upsert chat for private messages')
+ end
+ end
+ end)
+
+ it('should track user-chat membership', function()
+ user_tracker.run(ctx, message)
+
+ local found = false
+ for _, q in ipairs(env.db.queries) do
+ if q.op == 'upsert' and q.table_name == 'chat_members' then
+ found = true
+ assert.are.equal(message.chat.id, q.data.chat_id)
+ assert.are.equal(message.from.id, q.data.user_id)
+ end
+ end
+ assert.is_true(found)
+ end)
+ end)
+
+ describe('username mapping', function()
+ it('should set username -> user_id mapping in Redis', function()
+ user_tracker.run(ctx, message)
+ local stored_id = env.redis.store['username:testuser']
+ assert.is_not_nil(stored_id)
+ assert.are.equal(tostring(message.from.id), stored_id)
+ end)
+
+ it('should not set mapping when username is nil', function()
+ message.from.username = nil
+ user_tracker.run(ctx, message)
+ -- No username:* key should exist
+ local found = false
+ for k in pairs(env.redis.store) do
+ if k:match('^username:') then found = true end
+ end
+ assert.is_false(found)
+ end)
+
+ it('should lowercase username in mapping', function()
+ message.from.username = 'TestUser'
+ user_tracker.run(ctx, message)
+ assert.is_not_nil(env.redis.store['username:testuser'])
+ assert.is_nil(env.redis.store['username:TestUser'])
+ end)
+ end)
+
+ describe('always continues', function()
+ it('should always return true for should_continue', function()
+ local _, should_continue = user_tracker.run(ctx, message)
+ assert.is_true(should_continue)
+ end)
+ end)
+end)
diff --git a/spec/plugins/admin/ban_spec.lua b/spec/plugins/admin/ban_spec.lua
new file mode 100644
index 0000000..5e15f5d
--- /dev/null
+++ b/spec/plugins/admin/ban_spec.lua
@@ -0,0 +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 == 'insert' and q.table_name == 'bans' then
+ found = true
+ assert.are.equal('spamming links', q.data.reason)
+ 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 == 'insert' and q.table_name == 'bans' then
+ found = true
+ assert.are.equal('being disruptive', q.data.reason)
+ 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 == 'insert' and q.table_name == 'bans' then
+ found = true
+ assert.are.equal('spamming', q.data.reason)
+ 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])
+ 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 == 'insert' and q.table_name == 'bans' then
+ found = true
+ assert.are.equal(message.chat.id, q.data.chat_id)
+ assert.are.equal(222222, q.data.user_id)
+ assert.are.equal(message.from.id, q.data.banned_by)
+ 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 == 'insert' and q.table_name == 'admin_actions' then
+ found = true
+ assert.are.equal('ban', q.data.action)
+ assert.are.equal(message.from.id, q.data.admin_id)
+ assert.are.equal(222222, q.data.target_id)
+ 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/admin/federation_spec.lua b/spec/plugins/admin/federation_spec.lua
new file mode 100644
index 0000000..d293b9a
--- /dev/null
+++ b/spec/plugins/admin/federation_spec.lua
@@ -0,0 +1,427 @@
+--[[
+ Tests for federation plugins: newfed, joinfed, fban, unfban, fbaninfo.
+]]
+
+describe('plugins.admin.federation', function()
+ local test_helper = require('spec.helpers.test_helper')
+ local env, ctx, message
+
+ before_each(function()
+ -- Mock shared dependencies
+ 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,
+ }
+ 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() return false end,
+ can_restrict = function() return true end,
+ }
+
+ 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('newfed', function()
+ local newfed
+
+ before_each(function()
+ package.loaded['src.plugins.admin.federation.newfed'] = nil
+ newfed = require('src.plugins.admin.federation.newfed')
+ end)
+
+ it('should have correct metadata', function()
+ assert.are.equal('newfed', newfed.name)
+ assert.are.same({ 'newfed' }, newfed.commands)
+ end)
+
+ it('should require a name argument', function()
+ message.args = nil
+ newfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'specify a name')
+ end)
+
+ it('should require a name argument when empty string', function()
+ message.args = ''
+ newfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'specify a name')
+ end)
+
+ it('should reject names longer than 128 characters', function()
+ message.args = string.rep('a', 129)
+ newfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, '128 characters')
+ end)
+
+ it('should allow names of exactly 128 characters', function()
+ message.args = string.rep('a', 128)
+ env.db.queue_result({ { count = 0 } }) -- existing count
+ env.db.queue_result({ { id = 'test-uuid' } }) -- insert result
+ newfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'created successfully')
+ end)
+
+ it('should reject when user already owns 5 federations', function()
+ message.args = 'Test Fed'
+ env.db.set_next_result({ { count = 5 } })
+ newfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'maximum')
+ end)
+
+ it('should create federation and return ID', function()
+ message.args = 'My Federation'
+ env.db.queue_result({ { count = 0 } })
+ env.db.queue_result({ { id = 'uuid-1234' } })
+ newfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'created successfully')
+ test_helper.assert_sent_message_matches(env.api, 'uuid%-1234')
+ end)
+
+ it('should handle DB failure gracefully', function()
+ message.args = 'Test Fed'
+ env.db.queue_result({ { count = 0 } })
+ env.db.queue_result({}) -- empty result = failure
+ newfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed')
+ end)
+ end)
+
+ describe('joinfed', function()
+ local joinfed
+
+ before_each(function()
+ package.loaded['src.plugins.admin.federation.joinfed'] = nil
+ joinfed = require('src.plugins.admin.federation.joinfed')
+ end)
+
+ it('should have correct metadata', function()
+ assert.are.equal('joinfed', joinfed.name)
+ assert.is_true(joinfed.group_only)
+ assert.is_true(joinfed.admin_only)
+ end)
+
+ it('should require federation ID argument', function()
+ message.args = nil
+ joinfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'specify the federation ID')
+ end)
+
+ it('should reject when chat is already in a federation', function()
+ message.args = 'new-fed-id'
+ env.db.set_next_result({ { id = 'old-fed-id', name = 'Old Fed' } })
+ joinfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'already part')
+ end)
+
+ it('should reject when federation does not exist', function()
+ message.args = 'nonexistent-id'
+ env.db.queue_result({}) -- not in federation
+ env.db.queue_result({}) -- federation not found
+ joinfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not found')
+ end)
+
+ it('should successfully join a federation', function()
+ message.args = 'fed-uuid'
+ env.db.queue_result({}) -- not in federation
+ env.db.queue_result({ { id = 'fed-uuid', name = 'Test Fed' } }) -- fed exists
+ env.db.queue_result({ {} }) -- insert result
+ joinfed.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'has joined')
+ end)
+ end)
+
+ describe('fban', function()
+ local fban
+
+ before_each(function()
+ package.loaded['src.plugins.admin.federation.fban'] = nil
+ fban = require('src.plugins.admin.federation.fban')
+ end)
+
+ it('should have correct metadata', function()
+ assert.are.equal('fban', fban.name)
+ assert.are.same({ 'fban' }, fban.commands)
+ end)
+
+ it('should require chat to be in a federation', function()
+ env.db.set_next_result({}) -- no federation
+ fban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not part of any federation')
+ end)
+
+ it('should require federation admin/owner permission', function()
+ env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = 999 } })
+ env.db.set_next_result({}) -- not a fed admin
+ message.from.id = 111111
+ fban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'federation owner or a federation admin')
+ end)
+
+ it('should require a target user', function()
+ env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
+ message.args = nil
+ message.reply = nil
+ fban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'specify a user')
+ end)
+
+ it('should not ban the federation owner', function()
+ message.from.id = 111111
+ env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
+ message.reply = { from = { id = 111111, first_name = 'Owner' }, message_id = 1 }
+ fban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'cannot federation%-ban the federation owner')
+ end)
+
+ it('should not ban allowlisted users', function()
+ message.from.id = 111111
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
+ env.db.queue_result({ { ['1'] = 1 } }) -- is allowlisted
+ message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
+ fban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'allowlist')
+ end)
+
+ it('should ban user across federation chats', function()
+ message.from.id = 111111
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } }) -- get fed
+ env.db.queue_result({}) -- not allowlisted
+ env.db.queue_result({}) -- not already banned
+ env.db.queue_result({}) -- insert ban
+ env.db.queue_result({ { chat_id = -100111 }, { chat_id = -100222 } }) -- fed chats
+
+ message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
+ fban.on_message(env.api, message, ctx)
+
+ assert.are.equal(2, env.api.count_calls('ban_chat_member'))
+ test_helper.assert_sent_message_matches(env.api, 'Federation Ban')
+ end)
+
+ it('should invalidate Redis cache after fban', function()
+ message.from.id = 111111
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
+ env.db.queue_result({}) -- not allowlisted
+ env.db.queue_result({}) -- not already banned
+ env.db.queue_result({}) -- insert
+ env.db.queue_result({}) -- chats
+
+ message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
+ fban.on_message(env.api, message, ctx)
+
+ test_helper.assert_redis_command(env.redis, 'del')
+ end)
+
+ it('should include reason in ban record', function()
+ message.from.id = 111111
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
+ env.db.queue_result({}) -- not allowlisted
+ env.db.queue_result({}) -- not already banned
+ env.db.queue_result({}) -- insert
+ env.db.queue_result({}) -- chats
+
+ message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
+ message.args = 'spamming links'
+ fban.on_message(env.api, message, ctx)
+
+ test_helper.assert_sent_message_matches(env.api, 'spamming links')
+ end)
+
+ it('should resolve user from username', function()
+ message.from.id = 111111
+ env.redis.set('username:targetuser', '333333')
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
+ env.db.queue_result({}) -- not allowlisted
+ env.db.queue_result({}) -- not already banned
+ env.db.queue_result({}) -- insert
+ env.db.queue_result({}) -- chats
+
+ message.args = '@targetuser reason'
+ message.reply = nil
+ fban.on_message(env.api, message, ctx)
+
+ test_helper.assert_sent_message_matches(env.api, 'Federation Ban')
+ end)
+ end)
+
+ describe('unfban', function()
+ local unfban
+
+ before_each(function()
+ package.loaded['src.plugins.admin.federation.unfban'] = nil
+ unfban = require('src.plugins.admin.federation.unfban')
+ end)
+
+ it('should have correct metadata', function()
+ assert.are.equal('unfban', unfban.name)
+ assert.is_true(unfban.group_only)
+ end)
+
+ it('should require federation membership', function()
+ env.db.set_next_result({})
+ unfban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not part of any federation')
+ end)
+
+ it('should require federation admin permission', function()
+ env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = 999 } })
+ env.db.set_next_result({}) -- not a fed admin
+ message.from.id = 111111
+ unfban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'federation owner or a federation admin')
+ end)
+
+ it('should require a target user', function()
+ env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
+ message.args = nil
+ message.reply = nil
+ unfban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'specify a user')
+ end)
+
+ it('should report when user is not banned', function()
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
+ env.db.queue_result({}) -- not banned
+ message.args = '222222'
+ unfban.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not banned')
+ end)
+
+ it('should unban user across federation chats', function()
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
+ env.db.queue_result({ { ['1'] = 1 } }) -- is banned
+ env.db.queue_result({}) -- delete ban
+ env.db.queue_result({ { chat_id = -100111 }, { chat_id = -100222 } }) -- fed chats
+
+ message.args = '222222'
+ unfban.on_message(env.api, message, ctx)
+
+ assert.are.equal(2, env.api.count_calls('unban_chat_member'))
+ test_helper.assert_sent_message_matches(env.api, 'Federation Unban')
+ end)
+
+ it('should invalidate Redis cache after unfban', function()
+ env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
+ env.db.queue_result({ { ['1'] = 1 } })
+ env.db.queue_result({})
+ env.db.queue_result({})
+
+ message.args = '222222'
+ unfban.on_message(env.api, message, ctx)
+
+ test_helper.assert_redis_command(env.redis, 'del')
+ end)
+ end)
+
+ describe('fbaninfo', function()
+ local fbaninfo
+
+ before_each(function()
+ package.loaded['src.plugins.admin.federation.fbaninfo'] = nil
+ fbaninfo = require('src.plugins.admin.federation.fbaninfo')
+ end)
+
+ it('should have correct metadata', function()
+ assert.are.equal('fbaninfo', fbaninfo.name)
+ assert.are.same({ 'fbaninfo' }, fbaninfo.commands)
+ end)
+
+ it('should default to sender when no user specified', function()
+ message.args = nil
+ message.reply = nil
+ ctx.is_group = true
+ env.db.set_next_result({}) -- no bans found
+ fbaninfo.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not banned')
+ end)
+
+ it('should show ban info for banned user (group context)', function()
+ ctx.is_group = true
+ env.db.set_next_result({
+ {
+ reason = 'Spamming',
+ banned_by = 111111,
+ banned_at = '2024-01-01',
+ name = 'Test Fed',
+ id = 'fed-uuid',
+ }
+ })
+ message.args = '222222'
+ fbaninfo.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Federation Ban Info')
+ test_helper.assert_sent_message_matches(env.api, 'Spamming')
+ end)
+
+ it('should show no-ban message for clean user', function()
+ ctx.is_group = true
+ env.db.set_next_result({})
+ message.args = '222222'
+ fbaninfo.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not banned')
+ end)
+
+ it('should resolve user from reply', function()
+ ctx.is_group = true
+ message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
+ message.args = nil
+ env.db.set_next_result({})
+ fbaninfo.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not banned')
+ end)
+
+ it('should query all federations in private chat', function()
+ ctx.is_group = false
+ ctx.is_private = true
+ message.chat.type = 'private'
+ env.db.set_next_result({})
+ message.args = '222222'
+ fbaninfo.on_message(env.api, message, ctx)
+ -- Verify it queried without chat_id constraint
+ local found_private_query = false
+ for _, q in ipairs(env.db.queries) do
+ if q.sql and q.sql:match('federation_bans') and not q.sql:match('fc%.chat_id') then
+ found_private_query = true
+ end
+ end
+ assert.is_true(found_private_query)
+ end)
+
+ it('should show multiple bans', function()
+ ctx.is_group = false
+ ctx.is_private = true
+ message.chat.type = 'private'
+ env.db.set_next_result({
+ { reason = 'Reason 1', name = 'Fed A', id = 'fed-1', banned_by = 111, banned_at = '2024-01-01' },
+ { reason = 'Reason 2', name = 'Fed B', id = 'fed-2', banned_by = 222, banned_at = '2024-02-01' },
+ })
+ message.args = '222222'
+ fbaninfo.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Fed A')
+ test_helper.assert_sent_message_matches(env.api, 'Fed B')
+ end)
+ end)
+end)
diff --git a/spec/plugins/fun/flip_spec.lua b/spec/plugins/fun/flip_spec.lua
new file mode 100644
index 0000000..5e88acb
--- /dev/null
+++ b/spec/plugins/fun/flip_spec.lua
@@ -0,0 +1,113 @@
+--[[
+ Tests for src/plugins/fun/flip.lua
+ Tests text flipping, reply handling, error cases.
+]]
+
+describe('plugins.fun.flip', function()
+ local flip_plugin
+ local test_helper = require('spec.helpers.test_helper')
+ local env, ctx, message
+
+ before_each(function()
+ package.loaded['src.plugins.fun.flip'] = nil
+ flip_plugin = require('src.plugins.fun.flip')
+ 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 "flip"', function()
+ assert.are.equal('flip', flip_plugin.name)
+ end)
+
+ it('should be in fun category', function()
+ assert.are.equal('fun', flip_plugin.category)
+ end)
+
+ it('should have flip and reverse commands', function()
+ assert.are.same({ 'flip', 'reverse' }, flip_plugin.commands)
+ end)
+
+ it('should have help text', function()
+ assert.is_truthy(flip_plugin.help:match('/flip'))
+ end)
+ end)
+
+ describe('on_message', function()
+ it('should require text input', function()
+ message.args = nil
+ message.reply = nil
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'provide some text')
+ end)
+
+ it('should require text input when args is empty', function()
+ message.args = ''
+ message.reply = nil
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'provide some text')
+ end)
+
+ it('should flip text from args', function()
+ message.args = 'hello'
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_api_called(env.api, 'send_message')
+ -- The output should not be 'hello' (it's reversed and flipped)
+ local call = env.api.get_call('send_message')
+ assert.are_not.equal('hello', call.args[2])
+ end)
+
+ it('should flip text from reply', function()
+ message.args = nil
+ message.reply = { text = 'world', message_id = 1 }
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_api_called(env.api, 'send_message')
+ end)
+
+ it('should prefer reply text over args', function()
+ message.args = 'from_args'
+ message.reply = { text = 'from_reply', message_id = 1 }
+ flip_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ -- The output should be the flipped version of 'from_reply' not 'from_args'
+ assert.is_not_nil(call.args[2])
+ end)
+
+ it('should handle single character', function()
+ message.args = 'a'
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_api_called(env.api, 'send_message')
+ end)
+
+ it('should handle numbers', function()
+ message.args = '123'
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_api_called(env.api, 'send_message')
+ end)
+
+ it('should handle mixed case', function()
+ message.args = 'Hello World'
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_api_called(env.api, 'send_message')
+ end)
+
+ it('should send to correct chat', function()
+ message.args = 'test'
+ flip_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 skip reply with empty text', function()
+ message.args = nil
+ message.reply = { text = '', message_id = 1 }
+ flip_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'provide some text')
+ end)
+ end)
+end)
diff --git a/spec/plugins/utility/help_spec.lua b/spec/plugins/utility/help_spec.lua
new file mode 100644
index 0000000..1e825bb
--- /dev/null
+++ b/spec/plugins/utility/help_spec.lua
@@ -0,0 +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])
+ 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
new file mode 100644
index 0000000..00d50b2
--- /dev/null
+++ b/spec/plugins/utility/ping_spec.lua
@@ -0,0 +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])
+ 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/config.lua b/src/core/config.lua
new file mode 100644
index 0000000..44769b2
--- /dev/null
+++ b/src/core/config.lua
@@ -0,0 +1,163 @@
+--[[
+ mattata v2.0 - Configuration Module
+ Reads configuration from .env file with os.getenv() fallback.
+ Provides typed access to all configuration values.
+]]
+
+local config = {}
+
+local env_values = {}
+local loaded = false
+
+-- Parse a .env file into a table
+local function parse_env_file(path)
+ local values = {}
+ local file = io.open(path, 'r')
+ if not file then
+ return values
+ end
+ for line in file:lines() do
+ line = line:match('^%s*(.-)%s*$') -- trim
+ if line ~= '' and not line:match('^#') then
+ local key, value = line:match('^([%w_]+)%s*=%s*(.*)$')
+ if key then
+ -- Strip surrounding quotes
+ value = value:match('^"(.*)"$') or value:match("^'(.*)'$") or value
+ -- Strip inline comments (only for unquoted values)
+ value = value:match('^(.-)%s+#') or value
+ values[key] = value
+ end
+ end
+ end
+ file:close()
+ return values
+end
+
+-- Load .env file (called once)
+function config.load(path)
+ path = path or '.env'
+ env_values = parse_env_file(path)
+ loaded = true
+end
+
+-- Get a string value with optional default
+function config.get(key, default)
+ if not loaded then
+ config.load()
+ end
+ local value = env_values[key]
+ if value == nil or value == '' then
+ value = os.getenv(key)
+ end
+ if value == nil or value == '' then
+ return default
+ end
+ return value
+end
+
+-- Get a numeric value
+function config.get_number(key, default)
+ local value = config.get(key)
+ if value == nil then
+ return default
+ end
+ return tonumber(value) or default
+end
+
+-- Get a boolean value
+function config.is_enabled(key)
+ local value = config.get(key)
+ if value == nil then
+ return false
+ end
+ value = value:lower()
+ return value == 'true' or value == '1' or value == 'yes'
+end
+
+-- Get a comma-separated list as a table
+function config.get_list(key)
+ local value = config.get(key)
+ if not value or value == '' then
+ return {}
+ end
+ local list = {}
+ for item in value:gmatch('[^,]+') do
+ item = item:match('^%s*(.-)%s*$')
+ if item ~= '' then
+ local num = tonumber(item)
+ table.insert(list, num or item)
+ end
+ end
+ return list
+end
+
+-- Convenience accessors for common config groups
+function config.bot_token()
+ return config.get('BOT_TOKEN')
+end
+
+function config.bot_admins()
+ return config.get_list('BOT_ADMINS')
+end
+
+function config.bot_name()
+ return config.get('BOT_NAME', 'mattata')
+end
+
+function config.database()
+ return {
+ host = config.get('DATABASE_HOST', 'postgres'),
+ port = config.get_number('DATABASE_PORT', 5432),
+ database = config.get('DATABASE_NAME', 'mattata'),
+ user = config.get('DATABASE_USER', 'mattata'),
+ password = config.get('DATABASE_PASSWORD', 'changeme')
+ }
+end
+
+function config.redis_config()
+ return {
+ host = config.get('REDIS_HOST', 'redis'),
+ port = config.get_number('REDIS_PORT', 6379),
+ password = config.get('REDIS_PASSWORD'),
+ db = config.get_number('REDIS_DB', 0)
+ }
+end
+
+function config.polling()
+ return {
+ timeout = config.get_number('POLLING_TIMEOUT', 60),
+ limit = config.get_number('POLLING_LIMIT', 100)
+ }
+end
+
+function config.webhook()
+ return {
+ enabled = config.is_enabled('WEBHOOK_ENABLED'),
+ url = config.get('WEBHOOK_URL'),
+ port = config.get_number('WEBHOOK_PORT', 8443),
+ secret = config.get('WEBHOOK_SECRET')
+ }
+end
+
+function config.ai()
+ return {
+ enabled = config.is_enabled('AI_ENABLED'),
+ openai_key = config.get('OPENAI_API_KEY'),
+ openai_model = config.get('OPENAI_MODEL', 'gpt-4o'),
+ anthropic_key = config.get('ANTHROPIC_API_KEY'),
+ anthropic_model = config.get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929')
+ }
+end
+
+function config.debug()
+ return config.is_enabled('DEBUG')
+end
+
+function config.log_chat()
+ return config.get_number('LOG_CHAT')
+end
+
+-- Version constant
+config.VERSION = '2.0'
+
+return config
diff --git a/src/core/database.lua b/src/core/database.lua
new file mode 100644
index 0000000..d832951
--- /dev/null
+++ b/src/core/database.lua
@@ -0,0 +1,283 @@
+--[[
+ mattata v2.0 - PostgreSQL Database Module
+ Uses pgmoon for async-compatible PostgreSQL connections.
+ Implements connection pooling, automatic reconnection, and transaction helpers.
+]]
+
+local database = {}
+
+local pgmoon = require('pgmoon')
+local config = require('src.core.config')
+local logger = require('src.core.logger')
+
+local pool = {}
+local pool_size = 10
+local pool_timeout = 30000
+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)
+ 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()
+ 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 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
+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, partial, num_queries = 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)
+ pg, err = create_connection()
+ if pg then
+ result, query_err = pg:query(sql)
+ if result then
+ database.release(pg)
+ return result
+ end
+ 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, err = 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)
+ local new_pg
+ new_pg, err = create_connection()
+ if new_pg then
+ 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, err = 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
+
+-- 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 = {}
+ logger.info('Disconnected from PostgreSQL (pool drained)')
+end
+
+return database
diff --git a/src/core/i18n.lua b/src/core/i18n.lua
new file mode 100644
index 0000000..5f1dbe8
--- /dev/null
+++ b/src/core/i18n.lua
@@ -0,0 +1,93 @@
+--[[
+ mattata v2.0 - Internationalisation Module
+ Manages language files and provides translation lookups.
+]]
+
+local i18n = {}
+
+local config = require('src.core.config')
+local logger = require('src.core.logger')
+
+local languages = {}
+local default_lang = 'en_gb'
+
+-- Load all available language files from src/languages/
+function i18n.init()
+ local lang_registry = require('src.languages.init')
+ for code, path in pairs(lang_registry) do
+ local ok, lang = pcall(require, path)
+ if ok and type(lang) == 'table' then
+ languages[code] = lang
+ logger.debug('Loaded language: %s', code)
+ else
+ logger.warn('Failed to load language %s: %s', code, tostring(lang))
+ end
+ end
+ logger.info('Loaded %d language(s)', i18n.count())
+end
+
+-- Get a language table by code
+function i18n.get(code)
+ code = code or default_lang
+ return languages[code] or languages[default_lang]
+end
+
+-- Check if a language exists
+function i18n.exists(code)
+ return languages[code] ~= nil
+end
+
+-- Get all available language codes
+function i18n.available()
+ local codes = {}
+ for code in pairs(languages) do
+ table.insert(codes, code)
+ end
+ table.sort(codes)
+ return codes
+end
+
+-- Count loaded languages
+function i18n.count()
+ local count = 0
+ for _ in pairs(languages) do
+ count = count + 1
+ end
+ return count
+end
+
+-- Translate a key with optional interpolation
+-- Usage: i18n.t(lang, 'errors', 'connection') or i18n.t(lang, 'ban', 'success', {name = 'John'})
+function i18n.t(lang_table, ...)
+ if type(lang_table) == 'string' then
+ lang_table = i18n.get(lang_table)
+ end
+ if not lang_table then
+ lang_table = languages[default_lang]
+ end
+ local args = { ... }
+ local value = lang_table
+ local interpolation = nil
+ for i, key in ipairs(args) do
+ if type(key) == 'table' then
+ interpolation = key
+ break
+ end
+ if type(value) == 'table' then
+ value = value[key]
+ else
+ return nil
+ end
+ end
+ if type(value) ~= 'string' then
+ return nil
+ end
+ if interpolation then
+ for k, v in pairs(interpolation) do
+ value = value:gsub('{' .. k .. '}', tostring(v))
+ end
+ end
+ return value
+end
+
+return i18n
diff --git a/src/core/loader.lua b/src/core/loader.lua
new file mode 100644
index 0000000..e149c9c
--- /dev/null
+++ b/src/core/loader.lua
@@ -0,0 +1,174 @@
+--[[
+ 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 config = require('src.core.config')
+
+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 api, db, redis
+
+local PERMANENT_PLUGINS = { 'help', 'about', 'plugins' }
+
+local CATEGORIES = { 'admin', 'utility', 'fun', 'media', 'ai' }
+
+function loader.init(api_ref, db_ref, redis_ref)
+ api = api_ref
+ db = db_ref
+ redis = redis_ref
+ plugins = {}
+ by_command = {}
+ by_name = {}
+ categories = {}
+
+ 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
+
+ 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
+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
+ 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/logger.lua b/src/core/logger.lua
new file mode 100644
index 0000000..5684b05
--- /dev/null
+++ b/src/core/logger.lua
@@ -0,0 +1,75 @@
+--[[
+ mattata v2.0 - Structured Logging Module
+]]
+
+local logger = {}
+
+local config = require('src.core.config')
+
+local LEVELS = {
+ DEBUG = 1,
+ INFO = 2,
+ WARN = 3,
+ ERROR = 4
+}
+
+local COLORS = {
+ DEBUG = '\27[36m',
+ INFO = '\27[32m',
+ WARN = '\27[33m',
+ ERROR = '\27[31m',
+ RESET = '\27[0m'
+}
+
+local current_level = LEVELS.INFO
+
+function logger.set_level(level)
+ level = level:upper()
+ if LEVELS[level] then
+ current_level = LEVELS[level]
+ end
+end
+
+local function log(level, fmt, ...)
+ if LEVELS[level] < current_level then
+ return
+ end
+ local msg
+ if select('#', ...) > 0 then
+ msg = string.format(fmt, ...)
+ else
+ msg = tostring(fmt)
+ end
+ local timestamp = os.date('%Y-%m-%d %H:%M:%S')
+ io.write(string.format(
+ '%s[%s]%s [%s] %s\n',
+ COLORS[level], level, COLORS.RESET,
+ timestamp, msg
+ ))
+ io.flush()
+end
+
+function logger.debug(fmt, ...)
+ log('DEBUG', fmt, ...)
+end
+
+function logger.info(fmt, ...)
+ log('INFO', fmt, ...)
+end
+
+function logger.warn(fmt, ...)
+ log('WARN', fmt, ...)
+end
+
+function logger.error(fmt, ...)
+ log('ERROR', fmt, ...)
+end
+
+-- Initialize log level from config
+function logger.init()
+ if config.debug() then
+ logger.set_level('DEBUG')
+ end
+end
+
+return logger
diff --git a/src/core/middleware.lua b/src/core/middleware.lua
new file mode 100644
index 0000000..8bc178e
--- /dev/null
+++ b/src/core/middleware.lua
@@ -0,0 +1,57 @@
+--[[
+ mattata v2.0 - Middleware Pipeline
+ Runs an ordered chain of middleware functions before plugin dispatch.
+ Each middleware receives (ctx, message) and returns (ctx, should_continue).
+]]
+
+local middleware = {}
+
+local logger = require('src.core.logger')
+
+local chain = {}
+
+-- Register a middleware (order matters)
+function middleware.use(mw)
+ if type(mw) ~= 'table' or type(mw.run) ~= 'function' then
+ logger.error('Invalid middleware: must be a table with a run(ctx, message) function')
+ return
+ end
+ table.insert(chain, mw)
+ logger.debug('Registered middleware: %s', mw.name or '(unnamed)')
+end
+
+-- Run the full middleware chain
+-- Returns the modified ctx, and whether processing should continue
+function middleware.run(ctx, message)
+ for _, mw in ipairs(chain) do
+ local ok, err = pcall(function()
+ local new_ctx, should_continue = mw.run(ctx, message)
+ if new_ctx then
+ ctx = new_ctx
+ end
+ if should_continue == false then
+ ctx._stopped = true
+ ctx._stopped_by = mw.name
+ end
+ end)
+ if not ok then
+ logger.error('Middleware %s failed: %s', mw.name or '(unnamed)', tostring(err))
+ end
+ if ctx._stopped then
+ return ctx, false
+ end
+ end
+ return ctx, true
+end
+
+-- Reset chain (useful for testing)
+function middleware.reset()
+ chain = {}
+end
+
+-- Get count of registered middleware
+function middleware.count()
+ return #chain
+end
+
+return middleware
diff --git a/src/core/permissions.lua b/src/core/permissions.lua
new file mode 100644
index 0000000..9a3c5f9
--- /dev/null
+++ b/src/core/permissions.lua
@@ -0,0 +1,129 @@
+--[[
+ 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')
+
+-- 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
+ end
+ end
+ return false
+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.execute(
+ "SELECT 1 FROM chat_members WHERE chat_id = $1 AND user_id = $2 AND role = '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.execute(
+ "SELECT 1 FROM chat_members WHERE chat_id = $1 AND user_id = $2 AND role = 'trusted'",
+ { 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, err = 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
new file mode 100644
index 0000000..5d940d8
--- /dev/null
+++ b/src/core/redis.lua
@@ -0,0 +1,257 @@
+--[[
+ mattata v2.0 - Redis Connection Module
+ Redis is used as cache/session store only. PostgreSQL is the primary database.
+ Includes 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 client = nil
+local redis_cfg = nil
+local reconnect_attempts = 0
+local MAX_RECONNECT_ATTEMPTS = 10
+
+-- 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
+})
+
+local function do_connect()
+ if not redis_cfg then
+ redis_cfg = config.redis_config()
+ end
+ local ok, err = pcall(function()
+ client = redis_lib.connect({
+ host = redis_cfg.host,
+ port = redis_cfg.port
+ })
+ end)
+ if not ok then
+ return false, err
+ end
+ if redis_cfg.password and redis_cfg.password ~= '' then
+ client:auth(redis_cfg.password)
+ end
+ if redis_cfg.db and redis_cfg.db ~= 0 then
+ client:select(redis_cfg.db)
+ end
+ reconnect_attempts = 0
+ return true
+end
+
+-- Automatic reconnection with exponential backoff
+local function ensure_connected()
+ if client then
+ -- Quick ping check
+ local ok = pcall(function() client:ping() end)
+ if ok then return true end
+ logger.warn('Redis connection lost, attempting reconnect...')
+ client = nil
+ end
+ while reconnect_attempts < MAX_RECONNECT_ATTEMPTS do
+ reconnect_attempts = reconnect_attempts + 1
+ local backoff = math.min(2 ^ reconnect_attempts, 30)
+ logger.info('Redis reconnect attempt %d/%d (backoff: %ds)', reconnect_attempts, MAX_RECONNECT_ATTEMPTS, backoff)
+ local ok, err = do_connect()
+ if ok then
+ logger.info('Redis reconnected successfully')
+ return true
+ end
+ logger.warn('Redis reconnect failed: %s', tostring(err))
+ local socket = require('socket')
+ socket.sleep(backoff)
+ end
+ logger.error('Redis reconnection failed after %d attempts', MAX_RECONNECT_ATTEMPTS)
+ return false
+end
+
+-- Safe command wrapper with auto-reconnect
+local function safe_call(method, ...)
+ if not ensure_connected() then
+ return nil
+ end
+ local ok, result = pcall(method, client, ...)
+ if not ok then
+ -- Connection may have dropped mid-call
+ logger.warn('Redis command failed: %s — retrying after reconnect', tostring(result))
+ client = nil
+ if ensure_connected() then
+ ok, result = pcall(method, client, ...)
+ if ok then return result end
+ end
+ logger.error('Redis command failed after reconnect: %s', tostring(result))
+ return nil
+ end
+ return result
+end
+
+function redis_mod.connect()
+ redis_cfg = config.redis_config()
+ local ok, err = do_connect()
+ if not ok then
+ logger.error('Failed to connect to Redis: %s', tostring(err))
+ return false, err
+ end
+ logger.info('Connected to Redis at %s:%d (db %d)', redis_cfg.host, redis_cfg.port, redis_cfg.db or 0)
+ return true
+end
+
+-- Get the raw redis client
+function redis_mod.client()
+ ensure_connected()
+ return client
+end
+
+-- Proxy common operations with auto-reconnect
+function redis_mod.get(key)
+ return safe_call(client.get, key)
+end
+
+function redis_mod.set(key, value)
+ return safe_call(client.set, key, value)
+end
+
+function redis_mod.setex(key, ttl, value)
+ return safe_call(client.setex, key, ttl, value)
+end
+
+function redis_mod.setnx(key, value)
+ return safe_call(client.setnx, key, value)
+end
+
+function redis_mod.del(key)
+ return safe_call(client.del, key)
+end
+
+function redis_mod.exists(key)
+ return safe_call(client.exists, key)
+end
+
+function redis_mod.expire(key, ttl)
+ return safe_call(client.expire, key, ttl)
+end
+
+function redis_mod.incr(key)
+ return safe_call(client.incr, key)
+end
+
+function redis_mod.incrby(key, amount)
+ return safe_call(client.incrby, key, amount)
+end
+
+function redis_mod.hget(key, field)
+ return safe_call(client.hget, key, field)
+end
+
+function redis_mod.hset(key, field, value)
+ return safe_call(client.hset, key, field, value)
+end
+
+function redis_mod.hdel(key, field)
+ return safe_call(client.hdel, key, field)
+end
+
+function redis_mod.hgetall(key)
+ return safe_call(client.hgetall, key)
+end
+
+function redis_mod.hexists(key, field)
+ return safe_call(client.hexists, key, field)
+end
+
+function redis_mod.hincrby(key, field, increment)
+ return safe_call(client.hincrby, key, field, increment)
+end
+
+function redis_mod.sadd(key, value)
+ return safe_call(client.sadd, key, value)
+end
+
+function redis_mod.srem(key, value)
+ return safe_call(client.srem, key, value)
+end
+
+function redis_mod.sismember(key, value)
+ return safe_call(client.sismember, key, value)
+end
+
+function redis_mod.smembers(key)
+ return safe_call(client.smembers, key)
+end
+
+-- List operations (used by AI plugin)
+function redis_mod.rpush(key, value)
+ return safe_call(client.rpush, key, value)
+end
+
+function redis_mod.lrange(key, start, stop)
+ return safe_call(client.lrange, key, start, stop)
+end
+
+function redis_mod.ltrim(key, start, stop)
+ return safe_call(client.ltrim, key, start, stop)
+end
+
+-- SCAN-based iteration — replaces all KEYS usage
+-- Returns all keys matching pattern without blocking
+function redis_mod.scan(pattern)
+ if not ensure_connected() then
+ return {}
+ end
+ local results = {}
+ local cursor = '0'
+ repeat
+ local ok, reply = pcall(function()
+ return client:scan(cursor, { match = pattern, count = 100 })
+ end)
+ if not ok or not reply then break end
+ cursor = reply[1]
+ for _, key in ipairs(reply[2]) do
+ table.insert(results, key)
+ end
+ until cursor == '0'
+ 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)
+ if not ensure_connected() then
+ return nil
+ end
+ local pipeline = client:pipeline()
+ fn(pipeline)
+ local ok, results = pcall(function()
+ return pipeline:execute()
+ end)
+ if not ok then
+ logger.error('Redis pipeline failed: %s', tostring(results))
+ return nil
+ end
+ return results
+end
+
+function redis_mod.disconnect()
+ if client then
+ pcall(function() client:quit() end)
+ client = nil
+ logger.info('Disconnected from Redis')
+ end
+end
+
+return redis_mod
diff --git a/src/core/router.lua b/src/core/router.lua
new file mode 100644
index 0000000..dba32b0
--- /dev/null
+++ b/src/core/router.lua
@@ -0,0 +1,430 @@
+--[[
+ mattata v2.0 - Event Router
+ Dispatches Telegram updates through middleware pipeline to plugins.
+ Handles messages, callback queries, inline queries, and other events.
+]]
+
+local router = {}
+
+local json = require('dkjson')
+local socket = require('socket')
+local config = require('src.core.config')
+local logger = require('src.core.logger')
+local middleware_pipeline = require('src.core.middleware')
+local session = require('src.core.session')
+local permissions = require('src.core.permissions')
+local i18n = require('src.core.i18n')
+local tools
+
+local api, loader, ctx_base
+
+-- Import middleware modules
+local mw_blocklist = require('src.middleware.blocklist')
+local mw_rate_limit = require('src.middleware.rate_limit')
+local mw_user_tracker = require('src.middleware.user_tracker')
+local mw_language = require('src.middleware.language')
+local mw_federation = require('src.middleware.federation')
+local mw_captcha = require('src.middleware.captcha')
+local mw_stats = require('src.middleware.stats')
+
+function router.init(api_ref, tools_ref, loader_ref, ctx_base_ref)
+ api = api_ref
+ tools = tools_ref
+ loader = loader_ref
+ ctx_base = ctx_base_ref
+
+ -- Register middleware in order
+ middleware_pipeline.use(mw_blocklist)
+ middleware_pipeline.use(mw_rate_limit)
+ middleware_pipeline.use(mw_federation)
+ middleware_pipeline.use(mw_captcha)
+ middleware_pipeline.use(mw_user_tracker)
+ middleware_pipeline.use(mw_language)
+ middleware_pipeline.use(mw_stats)
+end
+
+-- Build a fresh context for each update
+-- Admin check is lazy — only resolved when ctx:check_admin() is called
+local function build_ctx(message)
+ local ctx = {}
+ for k, v in pairs(ctx_base) do
+ ctx[k] = v
+ end
+ ctx.is_group = message.chat and message.chat.type ~= 'private'
+ ctx.is_supergroup = message.chat and message.chat.type == 'supergroup'
+ ctx.is_private = message.chat and message.chat.type == 'private'
+ ctx.is_global_admin = message.from and permissions.is_global_admin(message.from.id) or false
+
+ -- Lazy admin check: only makes API call when first accessed
+ -- Caches result for the lifetime of this context
+ local admin_resolved = false
+ local admin_value = false
+ ctx.is_admin = false -- default for non-admin reads
+
+ function ctx:check_admin()
+ if admin_resolved then
+ return admin_value
+ end
+ admin_resolved = true
+ if ctx.is_global_admin then
+ admin_value = true
+ elseif ctx.is_group and message.from then
+ admin_value = permissions.is_group_admin(api, message.chat.id, message.from.id)
+ end
+ ctx.is_admin = admin_value
+ return admin_value
+ end
+
+ -- For backward compat: admin plugins that check ctx.is_admin will still
+ -- need to call ctx:check_admin() first. The router does this for admin_only plugins.
+ ctx.is_mod = false
+ return ctx
+end
+
+-- Sort/normalise a message object (ported from v1 mattata.sort_message)
+local function sort_message(message)
+ message.text = message.text or message.caption or ''
+ -- Normalise /command_arg to /command arg
+ message.text = message.text:gsub('^(/[%a]+)_', '%1 ')
+ -- Deep-link support
+ if message.text:match('^[/!#]start .-$') then
+ message.text = '/' .. message.text:match('^[/!#]start (.-)$')
+ end
+ -- Shorthand reply alias
+ if message.reply_to_message then
+ message.reply = message.reply_to_message
+ message.reply_to_message = nil
+ end
+ -- Normalise language code
+ if message.from and message.from.language_code then
+ local lc = message.from.language_code:lower():gsub('%-', '_')
+ if #lc == 2 and lc ~= 'en' then
+ lc = lc .. '_' .. lc
+ elseif #lc == 2 or lc == 'root' then
+ lc = 'en_us'
+ end
+ message.from.language_code = lc
+ end
+ -- Detect media
+ message.is_media = message.photo or message.video or message.audio or message.voice
+ or message.document or message.sticker or message.animation or message.video_note or false
+ -- Detect service messages
+ message.is_service_message = (message.new_chat_members or message.left_chat_member
+ or message.new_chat_title or message.new_chat_photo or message.pinned_message
+ or message.group_chat_created or message.supergroup_chat_created) and true or false
+ -- Entity-based text mentions -> ID substitution
+ if message.entities then
+ for _, entity in ipairs(message.entities) do
+ if entity.type == 'text_mention' and entity.user then
+ local name = message.text:sub(entity.offset + 1, entity.offset + entity.length)
+ message.text = message.text:gsub(name, tostring(entity.user.id), 1)
+ end
+ end
+ end
+ -- Process caption entities as entities
+ if message.caption_entities then
+ message.entities = message.caption_entities
+ message.caption_entities = nil
+ end
+ -- Sort reply recursively
+ if message.reply then
+ message.reply = sort_message(message.reply)
+ end
+ return message
+end
+
+-- Extract command from message text
+local function extract_command(text, bot_username)
+ if not text then return nil, nil end
+ local cmd, args = text:match('^[/!#]([%w_]+)@?' .. (bot_username or '') .. '%s*(.*)')
+ if not cmd then
+ cmd, args = text:match('^[/!#]([%w_]+)%s*(.*)')
+ end
+ if cmd then
+ cmd = cmd:lower()
+ args = args ~= '' and args or nil
+ end
+ return cmd, args
+end
+
+-- Resolve aliases for a chat (with Redis caching)
+local function resolve_alias(message, redis_mod)
+ if not message.text:match('^[/!#][%w_]+') then return message end
+ if not message.chat or message.chat.type == 'private' then return message end
+
+ local command, rest = message.text:lower():match('^[/!#]([%w_]+)(.*)')
+ if not command then return message end
+
+ -- Cache alias lookups with TTL instead of hgetall on every message
+ local cache_key = 'cache:aliases:' .. message.chat.id
+ local cached_aliases = redis_mod.get(cache_key)
+ local aliases
+ if cached_aliases then
+ local ok, decoded = pcall(json.decode, cached_aliases)
+ if ok and decoded then
+ aliases = decoded
+ end
+ end
+
+ if not aliases then
+ aliases = redis_mod.hgetall('chat:' .. message.chat.id .. ':aliases')
+ if type(aliases) == 'table' then
+ pcall(function()
+ redis_mod.setex(cache_key, 300, json.encode(aliases))
+ end)
+ end
+ end
+
+ if type(aliases) == 'table' then
+ for alias, original in pairs(aliases) do
+ if command == alias then
+ message.text = '/' .. original .. (rest or '')
+ message.is_alias = true
+ break
+ end
+ end
+ end
+ return message
+end
+
+-- Process action state (multi-step commands)
+-- Fixed: save message_id before nil'ing message.reply
+local function process_action(message, ctx)
+ if message.text and message.chat and message.reply
+ and message.reply.from and message.reply.from.id == api.info.id then
+ local reply_message_id = message.reply.message_id
+ local action = session.get_action(message.chat.id, reply_message_id)
+ if action then
+ message.text = action .. ' ' .. message.text
+ message.reply = nil
+ session.del_action(message.chat.id, reply_message_id)
+ end
+ end
+ return message
+end
+
+-- Handle a message update
+local function on_message(message)
+ -- Validate
+ if not message or not message.from then return end
+ if message.date and message.date < os.time() - 10 then return end
+
+ -- Sort/normalise
+ message = sort_message(message)
+ message = process_action(message, ctx_base)
+ message = resolve_alias(message, ctx_base.redis)
+
+ -- Build context and run middleware
+ local ctx = build_ctx(message)
+ local should_continue
+ ctx, should_continue = middleware_pipeline.run(ctx, message)
+ if not should_continue then return end
+
+ -- Dispatch command to matching plugin
+ local cmd, args = extract_command(message.text, api.info.username)
+ local command_handled = false
+
+ if cmd then
+ local plugin = loader.get_by_command(cmd)
+ if plugin and plugin.on_message then
+ if not session.is_plugin_disabled(message.chat.id, plugin.name) or loader.is_permanent(plugin.name) then
+ -- Check permission requirements
+ if plugin.global_admin_only and not ctx.is_global_admin then
+ return
+ end
+ -- Resolve admin status only for admin_only plugins (lazy check)
+ if plugin.admin_only then
+ ctx:check_admin()
+ if not ctx.is_admin and not ctx.is_global_admin then
+ return api.send_message(message.chat.id, ctx.lang and ctx.lang.errors and ctx.lang.errors.admin or 'You need to be an admin to use this command.')
+ end
+ end
+ if plugin.group_only and ctx.is_private then
+ return api.send_message(message.chat.id, ctx.lang and ctx.lang.errors and ctx.lang.errors.supergroup or 'This command can only be used in groups.')
+ end
+
+ message.command = cmd
+ message.args = args
+ local ok, err = pcall(plugin.on_message, api, message, ctx)
+ if not ok then
+ logger.error('Plugin %s.on_message error: %s', plugin.name, tostring(err))
+ if config.log_chat() then
+ api.send_message(config.log_chat(), string.format(
+ '<pre>[%s] %s error:\n%s\nFrom: %s\nText: %s</pre>',
+ os.date('%X'), plugin.name,
+ tools.escape_html(tostring(err)),
+ message.from.id,
+ tools.escape_html(message.text or '')
+ ), 'html')
+ end
+ end
+ command_handled = true
+ end
+ end
+ end
+
+ -- Run passive handlers (on_new_message) for all non-disabled plugins
+ for _, plugin in ipairs(loader.get_plugins()) do
+ if plugin.on_new_message and not session.is_plugin_disabled(message.chat.id, plugin.name) then
+ local ok, err = pcall(plugin.on_new_message, api, message, ctx)
+ if not ok then
+ logger.error('Plugin %s.on_new_message error: %s', plugin.name, tostring(err))
+ end
+ end
+ -- Handle member join events
+ if message.new_chat_members and plugin.on_member_join then
+ local ok, err = pcall(plugin.on_member_join, api, message, ctx)
+ if not ok then
+ logger.error('Plugin %s.on_member_join error: %s', plugin.name, tostring(err))
+ end
+ end
+ end
+end
+
+-- Handle callback query (routed through middleware for blocklist + rate limit)
+local function on_callback_query(callback_query)
+ if not callback_query or not callback_query.from then return end
+ if not callback_query.data then return end
+
+ local message = callback_query.message or {
+ chat = {},
+ message_id = callback_query.inline_message_id,
+ from = callback_query.from
+ }
+
+ -- Parse plugin_name:data format
+ local plugin_name, cb_data = callback_query.data:match('^(.-):(.*)$')
+ if not plugin_name then return end
+
+ local plugin = loader.get_by_name(plugin_name)
+ if not plugin or not plugin.on_callback_query then return end
+
+ callback_query.data = cb_data
+
+ -- Build context and run basic middleware (blocklist + rate limit)
+ local ctx = build_ctx(message)
+
+ -- Check blocklist for callback user
+ if session.is_globally_blocklisted(callback_query.from.id) then
+ return
+ end
+
+ -- Load language for callback user
+ local lang_code = session.get_setting(callback_query.from.id, 'language') or 'en_gb'
+ ctx.lang = i18n.get(lang_code)
+
+ local ok, err = pcall(plugin.on_callback_query, api, callback_query, message, ctx)
+ if not ok then
+ logger.error('Plugin %s.on_callback_query error: %s', plugin_name, tostring(err))
+ end
+end
+
+-- Handle inline query
+local function on_inline_query(inline_query)
+ if not inline_query or not inline_query.from then return end
+ if session.is_globally_blocklisted(inline_query.from.id) then return end
+
+ local ctx = build_ctx({ from = inline_query.from, chat = { type = 'private' } })
+ local lang_code = session.get_setting(inline_query.from.id, 'language') or 'en_gb'
+ ctx.lang = i18n.get(lang_code)
+
+ for _, plugin in ipairs(loader.get_plugins()) do
+ if plugin.on_inline_query then
+ local ok, err = pcall(plugin.on_inline_query, api, inline_query, ctx)
+ if not ok then
+ logger.error('Plugin %s.on_inline_query error: %s', plugin.name, tostring(err))
+ end
+ end
+ end
+end
+
+-- Run cron jobs asynchronously in coroutines
+local function run_cron_async()
+ for _, plugin in ipairs(loader.get_plugins()) do
+ if plugin.cron then
+ local co = coroutine.create(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)
+ coroutine.resume(co)
+ end
+ end
+end
+
+-- Main polling loop
+function router.run()
+ local last_update = 0
+ local last_cron = os.date('%M')
+ local last_stats_flush = 0
+ local polling = config.polling()
+
+ while true do
+ local success = api.get_updates(
+ polling.timeout,
+ last_update + 1,
+ polling.limit,
+ json.encode({
+ 'message', 'edited_message', 'callback_query', 'inline_query',
+ 'chat_join_request', 'chat_member', 'my_chat_member',
+ 'message_reaction'
+ })
+ )
+
+ if success and success.result then
+ for _, update in ipairs(success.result) do
+ last_update = update.update_id
+ local start_time = socket.gettime()
+
+ if update.message or update.edited_message then
+ local msg = update.message or update.edited_message
+ if update.edited_message then
+ msg.is_edited = true
+ end
+ local ok, err = pcall(on_message, msg)
+ if not ok then
+ logger.error('on_message error: %s', tostring(err))
+ end
+ elseif update.callback_query then
+ local ok, err = pcall(on_callback_query, update.callback_query)
+ if not ok then
+ logger.error('on_callback_query error: %s', tostring(err))
+ end
+ elseif update.inline_query then
+ local ok, err = pcall(on_inline_query, update.inline_query)
+ if not ok then
+ logger.error('on_inline_query error: %s', tostring(err))
+ end
+ end
+
+ if config.debug() then
+ logger.debug('Update #%d processed in %.3fs', update.update_id, socket.gettime() - start_time)
+ end
+ end
+ else
+ logger.error('Failed to retrieve updates from Telegram API')
+ end
+
+ -- Minutely cron jobs (async via coroutines)
+ if last_cron ~= os.date('%M') then
+ last_cron = os.date('%M')
+ run_cron_async()
+ end
+
+ -- Flush stats counters to PostgreSQL every 5 minutes
+ local now = os.time()
+ if now - last_stats_flush >= 300 then
+ last_stats_flush = now
+ local co = coroutine.create(function()
+ 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)
+ coroutine.resume(co)
+ end
+ end
+end
+
+return router
diff --git a/src/core/session.lua b/src/core/session.lua
new file mode 100644
index 0000000..2841ee6
--- /dev/null
+++ b/src/core/session.lua
@@ -0,0 +1,215 @@
+--[[
+ 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
+
+-- 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.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
+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/data/join_messages.lua b/src/data/join_messages.lua
new file mode 100644
index 0000000..da96fad
--- /dev/null
+++ b/src/data/join_messages.lua
@@ -0,0 +1,17 @@
+--[[
+ mattata v2.0 - Join Messages
+ Templates use {NAME} for the new member's name.
+]]
+
+return {
+ 'Welcome, {NAME}!',
+ 'Hello, {NAME}!',
+ 'Enjoy your stay, {NAME}!',
+ 'I\'m glad you joined, {NAME}!',
+ 'Howdy, {NAME}!',
+ 'Hi, {NAME}!',
+ 'Hey there, {NAME}!',
+ 'Good to see you, {NAME}!',
+ 'Nice of you to join us, {NAME}!',
+ 'Glad you could make it, {NAME}!'
+}
diff --git a/src/data/slaps.lua b/src/data/slaps.lua
new file mode 100644
index 0000000..4457894
--- /dev/null
+++ b/src/data/slaps.lua
@@ -0,0 +1,132 @@
+--[[
+ mattata v2.0 - Slap Messages
+ Templates use {ME} for slapper and {THEM} for target.
+]]
+
+return {
+ '{THEM} was shot by {ME}.',
+ '{THEM} was pricked to death.',
+ '{THEM} walked into a cactus while trying to escape {ME}.',
+ '{THEM} drowned.',
+ '{THEM} drowned whilst trying to escape {ME}.',
+ '{THEM} blew up.',
+ '{THEM} was blown up by {ME}.',
+ '{THEM} hit the ground too hard.',
+ '{THEM} fell from a high place.',
+ '{THEM} fell off a ladder.',
+ '{THEM} fell into a patch of cacti.',
+ '{THEM} was doomed to fall by {ME}.',
+ '{THEM} was blown from a high place by {ME}.',
+ '{THEM} was squashed by a falling anvil.',
+ '{THEM} went up in flames.',
+ '{THEM} burned to death.',
+ '{THEM} was burnt to a crisp whilst fighting {ME}.',
+ '{THEM} walked into a fire whilst fighting {ME}.',
+ '{THEM} tried to swim in lava.',
+ '{THEM} tried to swim in lava whilst trying to escape {ME}.',
+ '{THEM} was struck by lightning.',
+ '{THEM} was slain by {ME}.',
+ '{THEM} got finished off by {ME}.',
+ '{THEM} was killed by magic.',
+ '{THEM} was killed by {ME} using magic.',
+ '{THEM} starved to death.',
+ '{THEM} suffocated in a wall.',
+ '{THEM} fell out of the world.',
+ '{THEM} was knocked into the void by {ME}.',
+ '{THEM} withered away.',
+ '{THEM} was pummeled by {ME}.',
+ '{THEM} was fragged by {ME}.',
+ '{THEM} was desynchronized.',
+ '{THEM} was wasted.',
+ '{THEM} was busted.',
+ '{THEM}\'s bones are scraped clean by the desolate wind.',
+ '{THEM} has died of dysentery.',
+ '{THEM} fainted.',
+ '{THEM} is out of usable Pokemon! {THEM} whited out!',
+ '{THEM} is out of usable Pokemon! {THEM} blacked out!',
+ '{THEM} whited out!',
+ '{THEM} blacked out!',
+ '{THEM} says goodbye to this cruel world.',
+ '{THEM} got rekt.',
+ '{THEM} was sawn in half by {ME}.',
+ '{THEM} died. I blame {ME}.',
+ '{THEM} was axe-murdered by {ME}.',
+ '{THEM}\'s melon was split by {ME}.',
+ '{THEM} was sliced and diced by {ME}.',
+ '{THEM} was split from crotch to sternum by {ME}.',
+ '{THEM}\'s death put another notch in {ME}\'s axe.',
+ '{THEM} died impossibly!',
+ '{THEM} died from {ME}\'s mysterious tropical disease.',
+ '{THEM} escaped infection by dying.',
+ '{THEM} played hot-potato with a grenade.',
+ '{THEM} was knifed by {ME}.',
+ '{THEM} fell on his sword.',
+ '{THEM} ate a grenade.',
+ '{THEM} practiced being {ME}\'s clay pigeon.',
+ '{THEM} is what\'s for dinner!',
+ '{THEM} was terminated by {ME}.',
+ '{THEM} was shot before being thrown out of a plane.',
+ '{THEM} was not invincible.',
+ '{THEM} has encountered an error.',
+ '{THEM} died and reincarnated as a goat.',
+ '{ME} threw {THEM} off a building.',
+ '{THEM} is sleeping with the fishes.',
+ '{THEM} got a premature burial.',
+ '{ME} replaced all of {THEM}\'s music with Nickelback.',
+ '{ME} spammed {THEM}\'s email.',
+ '{ME} made {THEM} a knuckle sandwich.',
+ '{ME} slapped {THEM} with pure nothing.',
+ '{ME} hit {THEM} with a small, interstellar spaceship.',
+ '{THEM} was quickscoped by {ME}.',
+ '{ME} put {THEM} in check-mate.',
+ '{ME} RSA-encrypted {THEM} and deleted the private key.',
+ '{ME} put {THEM} in the friendzone.',
+ '{ME} slaps {THEM} with a DMCA takedown request!',
+ '{THEM} became a corpse blanket for {ME}.',
+ 'Death is when the monsters get you. Death comes for {THEM}.',
+ 'Cowards die many times before their death. {THEM} never tasted death but once.',
+ '{THEM} died of hospital gangrene.',
+ '{THEM} got a house call from Doctor {ME}.',
+ '{ME} beheaded {THEM}.',
+ '{THEM} got stoned...by an angry mob.',
+ '{ME} sued the pants off {THEM}.',
+ '{THEM} was impeached.',
+ '{THEM} was beaten to a pulp by {ME}.',
+ '{THEM} was one-hit KO\'d by {ME}.',
+ '{ME} sent {THEM} to /dev/null.',
+ '{ME} sent {THEM} down the memory hole.',
+ '{THEM} was a mistake.',
+ '{ME} checkmated {THEM} in two moves.',
+ '{ME} mass-reported {THEM}\'s Telegram account.',
+ '{THEM} ran into a mass of cobwebs.',
+ '{THEM} experienced kinetic energy.',
+ '{ME} yeeted {THEM} into the sun.',
+ '{THEM} was erased from existence by {ME}.',
+ '{ME} mass-deleted {THEM}\'s Spotify playlists.',
+ '{ME} slaps {THEM} around a bit with a large trout.',
+ '{ME} roundhouse kicks {THEM} into the sun.',
+ '{ME} pushes {THEM} off a cliff.',
+ '{ME} DDoSes {THEM}\'s network.',
+ '{ME} force-feeds {THEM} a cactus.',
+ '{ME} puts laxatives in {THEM}\'s coffee.',
+ '{ME} sells {THEM} to Santa.',
+ '{ME} steals {THEM}\'s lunch money.',
+ '{ME} slaps {THEM} with a rubber chicken.',
+ '{ME} pokes {THEM} in the eye.',
+ '{ME} throws {THEM} into a swimming pool full of piranhas.',
+ '{ME} ties {THEM} to a rocket and launches it into space.',
+ '{ME} drops a piano on {THEM}\'s head.',
+ '{ME} sets {THEM}\'s homework on fire.',
+ '{ME} sends {THEM} to the shadow realm.',
+ '{ME} releases a horde of chickens on {THEM}.',
+ '{ME} challenges {THEM} to mortal combat and wins flawlessly.',
+ '{ME} makes {THEM} step on a Lego.',
+ '{ME} throws {THEM}\'s phone into a volcano.',
+ '{ME} replaces all of {THEM}\'s music with Rick Astley.',
+ '{ME} fills {THEM}\'s car with bees.',
+ '{ME} steals {THEM}\'s WiFi password.',
+ '{ME} uninstalls {THEM}\'s favourite game.',
+ '{ME} swaps {THEM}\'s sugar with salt.',
+ '{ME} hits {THEM} over the head with a frying pan.',
+ '{ME} throws a banana peel at {THEM}\'s feet.'
+}
diff --git a/src/db/init.lua b/src/db/init.lua
new file mode 100644
index 0000000..7c03ba1
--- /dev/null
+++ b/src/db/init.lua
@@ -0,0 +1,85 @@
+--[[
+ 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' }
+}
+
+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 and execute each statement
+ for statement in sql:gmatch('[^;]+') do
+ statement = statement:match('^%s*(.-)%s*$')
+ if statement ~= '' then
+ local result, err = db.query(statement)
+ if not result and err then
+ migration_ok = false
+ migration_err = err
+ break
+ end
+ 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/001_initial_schema.lua b/src/db/migrations/001_initial_schema.lua
new file mode 100644
index 0000000..100fecc
--- /dev/null
+++ b/src/db/migrations/001_initial_schema.lua
@@ -0,0 +1,184 @@
+--[[
+ Migration 001 - Core Tables
+ Creates the fundamental tables for users, chats, settings, and admin features.
+]]
+
+local migration = {}
+
+function migration.up()
+ return [[
+ CREATE TABLE IF NOT EXISTS users (
+ user_id BIGINT PRIMARY KEY,
+ username VARCHAR(255),
+ first_name VARCHAR(255),
+ last_name VARCHAR(255),
+ language_code VARCHAR(10),
+ is_bot BOOLEAN DEFAULT FALSE,
+ nickname VARCHAR(128),
+ last_seen TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
+
+ CREATE TABLE IF NOT EXISTS chats (
+ chat_id BIGINT PRIMARY KEY,
+ title VARCHAR(255),
+ chat_type VARCHAR(20) NOT NULL DEFAULT 'supergroup',
+ username VARCHAR(255),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_chats_username ON chats (username);
+
+ CREATE TABLE IF NOT EXISTS chat_settings (
+ chat_id BIGINT NOT NULL REFERENCES chats(chat_id) ON DELETE CASCADE,
+ key VARCHAR(255) NOT NULL,
+ value TEXT,
+ PRIMARY KEY (chat_id, key)
+ );
+
+ CREATE TABLE IF NOT EXISTS chat_members (
+ chat_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ role VARCHAR(20) DEFAULT 'member',
+ last_seen TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ joined_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ PRIMARY KEY (chat_id, user_id)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_chat_members_user ON chat_members (user_id);
+
+ CREATE TABLE IF NOT EXISTS bans (
+ id SERIAL PRIMARY KEY,
+ chat_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ banned_by BIGINT,
+ reason TEXT,
+ expires_at TIMESTAMP WITH TIME ZONE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_bans_chat_user ON bans (chat_id, user_id);
+
+ CREATE TABLE IF NOT EXISTS warnings (
+ id SERIAL PRIMARY KEY,
+ chat_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ warned_by BIGINT,
+ reason TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_warnings_chat_user ON warnings (chat_id, user_id);
+
+ CREATE TABLE IF NOT EXISTS custom_commands (
+ id SERIAL PRIMARY KEY,
+ chat_id BIGINT NOT NULL,
+ command VARCHAR(64) NOT NULL,
+ response TEXT NOT NULL,
+ created_by BIGINT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ UNIQUE (chat_id, command)
+ );
+
+ CREATE TABLE IF NOT EXISTS filters (
+ id SERIAL PRIMARY KEY,
+ chat_id BIGINT NOT NULL,
+ pattern TEXT NOT NULL,
+ action VARCHAR(20) DEFAULT 'delete',
+ response TEXT,
+ created_by BIGINT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_filters_chat ON filters (chat_id);
+
+ CREATE TABLE IF NOT EXISTS rules (
+ chat_id BIGINT PRIMARY KEY,
+ rules_text TEXT NOT NULL,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS welcome_messages (
+ chat_id BIGINT PRIMARY KEY,
+ message TEXT NOT NULL,
+ parse_mode VARCHAR(10) DEFAULT 'html',
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS saved_notes (
+ id SERIAL PRIMARY KEY,
+ chat_id BIGINT NOT NULL,
+ note_name VARCHAR(64) NOT NULL,
+ content TEXT NOT NULL,
+ content_type VARCHAR(20) DEFAULT 'text',
+ file_id TEXT,
+ created_by BIGINT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ UNIQUE (chat_id, note_name)
+ );
+
+ CREATE TABLE IF NOT EXISTS user_locations (
+ user_id BIGINT PRIMARY KEY,
+ latitude DOUBLE PRECISION NOT NULL,
+ longitude DOUBLE PRECISION NOT NULL,
+ address TEXT,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS disabled_plugins (
+ chat_id BIGINT NOT NULL,
+ plugin_name VARCHAR(64) NOT NULL,
+ PRIMARY KEY (chat_id, plugin_name)
+ );
+
+ CREATE TABLE IF NOT EXISTS admin_actions (
+ id SERIAL PRIMARY KEY,
+ chat_id BIGINT NOT NULL,
+ admin_id BIGINT NOT NULL,
+ target_id BIGINT,
+ action VARCHAR(32) NOT NULL,
+ reason TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_admin_actions_chat ON admin_actions (chat_id, created_at);
+
+ CREATE TABLE IF NOT EXISTS group_blocklist (
+ chat_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ reason TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ PRIMARY KEY (chat_id, user_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS allowed_links (
+ chat_id BIGINT NOT NULL,
+ link VARCHAR(255) NOT NULL,
+ PRIMARY KEY (chat_id, link)
+ );
+
+ CREATE TABLE IF NOT EXISTS triggers (
+ id SERIAL PRIMARY KEY,
+ chat_id BIGINT NOT NULL,
+ pattern TEXT NOT NULL,
+ response TEXT NOT NULL,
+ is_media BOOLEAN DEFAULT FALSE,
+ file_id TEXT,
+ created_by BIGINT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_triggers_chat ON triggers (chat_id);
+
+ CREATE TABLE IF NOT EXISTS aliases (
+ chat_id BIGINT NOT NULL,
+ alias VARCHAR(64) NOT NULL,
+ command VARCHAR(64) NOT NULL,
+ PRIMARY KEY (chat_id, alias)
+ )
+ ]]
+end
+
+return migration
diff --git a/src/db/migrations/002_federation_tables.lua b/src/db/migrations/002_federation_tables.lua
new file mode 100644
index 0000000..3410338
--- /dev/null
+++ b/src/db/migrations/002_federation_tables.lua
@@ -0,0 +1,58 @@
+--[[
+ Migration 002 - Federation Tables
+ Federation system for cross-group ban management.
+ Column names match federation plugin code (id, owner_id, federation_id).
+]]
+
+local migration = {}
+
+function migration.up()
+ return [[
+ CREATE TABLE IF NOT EXISTS federations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ owner_id BIGINT NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_federations_owner ON federations (owner_id);
+
+ CREATE TABLE IF NOT EXISTS federation_admins (
+ federation_id UUID NOT NULL REFERENCES federations(id) ON DELETE CASCADE,
+ user_id BIGINT NOT NULL,
+ promoted_by BIGINT,
+ promoted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ PRIMARY KEY (federation_id, user_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS federation_bans (
+ federation_id UUID NOT NULL REFERENCES federations(id) ON DELETE CASCADE,
+ user_id BIGINT NOT NULL,
+ banned_by BIGINT,
+ reason TEXT,
+ banned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ PRIMARY KEY (federation_id, user_id)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_federation_bans_user ON federation_bans (user_id);
+
+ CREATE TABLE IF NOT EXISTS federation_chats (
+ federation_id UUID NOT NULL REFERENCES federations(id) ON DELETE CASCADE,
+ chat_id BIGINT NOT NULL,
+ joined_by BIGINT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ PRIMARY KEY (federation_id, chat_id)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_federation_chats_chat ON federation_chats (chat_id);
+
+ CREATE TABLE IF NOT EXISTS federation_allowlist (
+ federation_id UUID NOT NULL REFERENCES federations(id) ON DELETE CASCADE,
+ user_id BIGINT NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ PRIMARY KEY (federation_id, user_id)
+ )
+ ]]
+end
+
+return migration
diff --git a/src/db/migrations/003_statistics_tables.lua b/src/db/migrations/003_statistics_tables.lua
new file mode 100644
index 0000000..accf938
--- /dev/null
+++ b/src/db/migrations/003_statistics_tables.lua
@@ -0,0 +1,32 @@
+--[[
+ Migration 003 - Statistics Tables
+ Per-user/chat/day message stats and command usage tracking.
+]]
+
+local migration = {}
+
+function migration.up()
+ return [[
+ CREATE TABLE IF NOT EXISTS message_stats (
+ chat_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ date DATE NOT NULL DEFAULT CURRENT_DATE,
+ message_count INTEGER DEFAULT 1,
+ PRIMARY KEY (chat_id, user_id, date)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_message_stats_date ON message_stats (date);
+
+ CREATE TABLE IF NOT EXISTS command_stats (
+ chat_id BIGINT NOT NULL,
+ command VARCHAR(64) NOT NULL,
+ date DATE NOT NULL DEFAULT CURRENT_DATE,
+ use_count INTEGER DEFAULT 1,
+ PRIMARY KEY (chat_id, command, date)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_command_stats_date ON command_stats (date)
+ ]]
+end
+
+return migration
diff --git a/src/db/migrations/004_performance_indexes.lua b/src/db/migrations/004_performance_indexes.lua
new file mode 100644
index 0000000..58fe255
--- /dev/null
+++ b/src/db/migrations/004_performance_indexes.lua
@@ -0,0 +1,23 @@
+--[[
+ Migration 004 - Performance Indexes
+ Adds indexes for frequently queried columns to improve read performance.
+ Uses IF NOT EXISTS to be idempotent (some may already exist from earlier migrations).
+]]
+
+local migration = {}
+
+function migration.up()
+ return [[
+ CREATE INDEX IF NOT EXISTS idx_federation_bans_user ON federation_bans(user_id);
+ CREATE INDEX IF NOT EXISTS idx_federation_chats_chat ON federation_chats(chat_id);
+ CREATE INDEX IF NOT EXISTS idx_chat_settings_chat ON chat_settings(chat_id, key);
+ CREATE INDEX IF NOT EXISTS idx_warnings_chat_user ON warnings(chat_id, user_id);
+ CREATE INDEX IF NOT EXISTS idx_filters_chat ON filters(chat_id);
+ CREATE INDEX IF NOT EXISTS idx_triggers_chat ON triggers(chat_id);
+ CREATE INDEX IF NOT EXISTS idx_msg_stats_chat_date ON message_stats(chat_id, date);
+ CREATE INDEX IF NOT EXISTS idx_cmd_stats_date ON command_stats(date);
+ CREATE INDEX IF NOT EXISTS idx_disabled_plugins_chat ON disabled_plugins(chat_id)
+ ]]
+end
+
+return migration
diff --git a/src/languages/ar_ar.lua b/src/languages/ar_ar.lua
new file mode 100644
index 0000000..03b0262
--- /dev/null
+++ b/src/languages/ar_ar.lua
@@ -0,0 +1,3 @@
+-- mattata v2.0 - Arabic
+-- TODO: Full translation from en_gb
+return require('src.languages.en_gb')
diff --git a/src/languages/de_at.lua b/src/languages/de_at.lua
new file mode 100644
index 0000000..0514429
--- /dev/null
+++ b/src/languages/de_at.lua
@@ -0,0 +1,2 @@
+-- mattata v2.0 - Deutsch (Osterreich)
+return require('src.languages.de_de')
diff --git a/src/languages/de_de.lua b/src/languages/de_de.lua
new file mode 100644
index 0000000..9cb8b7a
--- /dev/null
+++ b/src/languages/de_de.lua
@@ -0,0 +1,3 @@
+-- mattata v2.0 - Deutsch (Deutschland)
+-- TODO: Full translation from en_gb
+return require('src.languages.en_gb')
diff --git a/src/languages/en_gb.lua b/src/languages/en_gb.lua
new file mode 100644
index 0000000..7dde624
--- /dev/null
+++ b/src/languages/en_gb.lua
@@ -0,0 +1,118 @@
+--[[
+ mattata v2.0 - English (GB) Language File
+]]
+
+return {
+ errors = {
+ connection = 'Connection error.',
+ results = 'I couldn\'t find any results for that.',
+ supergroup = 'This command can only be used in supergroups.',
+ admin = 'You need to be a moderator or an administrator to use this command.',
+ unknown = 'I don\'t recognise that user. Forward a message from them to any chat I\'m in.',
+ generic = 'An unexpected error occurred.',
+ private = 'You can only use this command in private chat.'
+ },
+ help = {
+ greeting = 'Hey %s! I\'m <b>%s</b>, a feature-rich Telegram bot.',
+ no_results = 'No commands found matching "%s".',
+ page_info = 'Page %d of %d',
+ commands = 'Commands',
+ admin_help = 'Admin Help',
+ links = 'Links',
+ settings = 'Settings',
+ back = 'Back',
+ next_page = 'Next',
+ prev_page = 'Previous'
+ },
+ afk = {
+ no_username = 'This feature requires a public @username.',
+ returned = '%s has returned after being AFK for %s!',
+ now_afk = '%s is now AFK.%s',
+ is_afk = '%s is currently AFK!',
+ note = '\nNote: %s'
+ },
+ weather = {
+ no_location = 'Please specify a location, or set your default with /setloc.',
+ format = 'Temperature: %s (feels like %s)\nConditions: %s\nWind: %s km/h\nHumidity: %s%%\nLocation: %s'
+ },
+ ban = {
+ specify = 'Please specify the user to ban.',
+ is_admin = 'I can\'t ban an admin or moderator.',
+ already_banned = 'That user is already banned.',
+ no_permission = 'I don\'t have permission to ban users.',
+ success = '%s has banned %s.',
+ log = '%s [%s] has banned %s [%s] from %s [%s]%s.'
+ },
+ warn = {
+ specify = 'Please specify the user to warn.',
+ success = '%s has warned %s%s. [%d/%d]',
+ threshold = '%d/%d warnings reached - user has been banned.',
+ reset = 'Warnings reset by %s!',
+ removed = 'Warning removed by %s! [%s/%s]'
+ },
+ kick = {
+ specify = 'Please specify the user to kick.',
+ not_in_chat = 'That user is not in this chat.',
+ success = '%s has kicked %s.'
+ },
+ mute = {
+ specify = 'Please specify the user to mute.',
+ success = '%s has muted %s%s.'
+ },
+ unmute = {
+ success = '%s has unmuted %s.'
+ },
+ rules = {
+ no_rules = 'No rules have been set for this group. Admins can set them with /setrules.',
+ header = '<b>Rules for %s:</b>\n\n%s'
+ },
+ welcome = {
+ default = 'Welcome to the group, {NAME}!',
+ set = 'Welcome message has been updated.',
+ current = 'Current welcome message:\n%s'
+ },
+ statistics = {
+ header = '<b>Message statistics for %s:</b>\n\n',
+ no_stats = 'No statistics available for this group.',
+ total = '\n<b>Total: %d messages</b>',
+ reset = 'Statistics have been reset.'
+ },
+ setlang = {
+ header = 'Select your preferred language:',
+ set = 'Language has been set to %s.'
+ },
+ setloc = {
+ no_input = 'Please specify a location.',
+ set = 'Your location has been set to: %s',
+ not_found = 'I couldn\'t find that location.'
+ },
+ remind = {
+ no_input = 'Please specify a duration and message. Example: /remind 2h Take out the bins',
+ set = 'Reminder set! I\'ll remind you in %s.',
+ fired = 'Reminder for %s: %s',
+ max_reached = 'You can only have %d active reminders per chat.',
+ list_header = '<b>Active reminders:</b>\n',
+ none = 'You have no active reminders.'
+ },
+ translate = {
+ no_input = 'Please provide text to translate, or reply to a message.',
+ result = '<b>Translation (%s):</b>\n%s'
+ },
+ nick = {
+ set = 'Your nickname has been set to: %s',
+ removed = 'Your nickname has been removed.',
+ current = 'Your current nickname is: %s',
+ too_long = 'Nicknames can\'t be longer than 128 characters.'
+ },
+ currency = {
+ no_input = 'Please use the format: /currency <amount> <FROM> to <TO>',
+ result = '%s %s = <b>%s %s</b>'
+ },
+ format_time = {
+ second = 'second', seconds = 'seconds',
+ minute = 'minute', minutes = 'minutes',
+ hour = 'hour', hours = 'hours',
+ day = 'day', days = 'days',
+ week = 'week', weeks = 'weeks'
+ }
+}
diff --git a/src/languages/en_us.lua b/src/languages/en_us.lua
new file mode 100644
index 0000000..6cac8f6
--- /dev/null
+++ b/src/languages/en_us.lua
@@ -0,0 +1,8 @@
+-- mattata v2.0 - English (US)
+-- Inherits from en_gb with US-specific overrides
+local lang = require('src.languages.en_gb')
+
+-- Override any US-specific strings here
+lang.errors.unknown = 'I don\'t recognize that user. Forward a message from them to any chat I\'m in.'
+
+return lang
diff --git a/src/languages/init.lua b/src/languages/init.lua
new file mode 100644
index 0000000..4fab5ce
--- /dev/null
+++ b/src/languages/init.lua
@@ -0,0 +1,17 @@
+--[[
+ mattata v2.0 - Language Registry
+ Maps language codes to their module paths.
+]]
+
+return {
+ en_us = 'src.languages.en_us',
+ en_gb = 'src.languages.en_gb',
+ de_de = 'src.languages.de_de',
+ de_at = 'src.languages.de_at',
+ ar_ar = 'src.languages.ar_ar',
+ pl_pl = 'src.languages.pl_pl',
+ pt_br = 'src.languages.pt_br',
+ pt_pt = 'src.languages.pt_pt',
+ tr_tr = 'src.languages.tr_tr',
+ scottish = 'src.languages.scottish'
+}
diff --git a/src/languages/pl_pl.lua b/src/languages/pl_pl.lua
new file mode 100644
index 0000000..8878b2f
--- /dev/null
+++ b/src/languages/pl_pl.lua
@@ -0,0 +1,3 @@
+-- mattata v2.0 - Polski
+-- TODO: Full translation from en_gb
+return require('src.languages.en_gb')
diff --git a/src/languages/pt_br.lua b/src/languages/pt_br.lua
new file mode 100644
index 0000000..d153f80
--- /dev/null
+++ b/src/languages/pt_br.lua
@@ -0,0 +1,3 @@
+-- mattata v2.0 - Portugues (Brasil)
+-- TODO: Full translation from en_gb
+return require('src.languages.en_gb')
diff --git a/src/languages/pt_pt.lua b/src/languages/pt_pt.lua
new file mode 100644
index 0000000..cb1addb
--- /dev/null
+++ b/src/languages/pt_pt.lua
@@ -0,0 +1,3 @@
+-- mattata v2.0 - Portugues (Portugal)
+-- TODO: Full translation from en_gb
+return require('src.languages.en_gb')
diff --git a/src/languages/scottish.lua b/src/languages/scottish.lua
new file mode 100644
index 0000000..84ebcdb
--- /dev/null
+++ b/src/languages/scottish.lua
@@ -0,0 +1,3 @@
+-- mattata v2.0 - Scottish
+-- TODO: Full translation from en_gb
+return require('src.languages.en_gb')
diff --git a/src/languages/tr_tr.lua b/src/languages/tr_tr.lua
new file mode 100644
index 0000000..cc20d1a
--- /dev/null
+++ b/src/languages/tr_tr.lua
@@ -0,0 +1,3 @@
+-- mattata v2.0 - Turkce
+-- TODO: Full translation from en_gb
+return require('src.languages.en_gb')
diff --git a/src/middleware/blocklist.lua b/src/middleware/blocklist.lua
new file mode 100644
index 0000000..9a09267
--- /dev/null
+++ b/src/middleware/blocklist.lua
@@ -0,0 +1,75 @@
+--[[
+ mattata v2.0 - Blocklist Middleware
+ Checks global bans, group bans, and SpamWatch. Stops if blocked.
+]]
+
+local blocklist = {}
+blocklist.name = 'blocklist'
+
+local config = require('src.core.config')
+local session = require('src.core.session')
+local logger = require('src.core.logger')
+
+function blocklist.run(ctx, message)
+ if not message.from then
+ return ctx, false
+ end
+
+ local user_id = message.from.id
+
+ -- Global admins are never blocked
+ if ctx.is_global_admin then
+ return ctx, true
+ end
+
+ -- Check global blocklist
+ if session.is_globally_blocklisted(user_id) then
+ ctx.is_blocklisted = true
+ return ctx, false
+ end
+
+ -- Check global ban (federation-level)
+ local global_ban = ctx.redis.get('global_ban:' .. user_id)
+ if global_ban then
+ ctx.is_globally_banned = true
+ -- Auto-ban in groups
+ if ctx.is_group then
+ pcall(function()
+ ctx.api.ban_chat_member(message.chat.id, user_id)
+ end)
+ end
+ return ctx, false
+ end
+
+ -- Check per-group blocklist
+ if ctx.is_group then
+ local group_blocked = ctx.redis.get('group_blocklist:' .. message.chat.id .. ':' .. user_id)
+ if group_blocked then
+ ctx.is_group_blocklisted = true
+ return ctx, false
+ end
+
+ -- Check blocklisted chats
+ local chat_blocked = ctx.redis.get('blocklisted_chats:' .. message.chat.id)
+ if chat_blocked then
+ pcall(function()
+ ctx.api.leave_chat(message.chat.id)
+ end)
+ return ctx, false
+ end
+ end
+
+ -- SpamWatch check (if configured)
+ local spamwatch_token = config.get('SPAMWATCH_TOKEN')
+ if spamwatch_token and spamwatch_token ~= '' then
+ local cached = ctx.redis.get('not_blocklisted:' .. user_id)
+ if not cached then
+ -- Check will be done asynchronously in future; for now just mark as not checked
+ ctx.spamwatch_checked = false
+ end
+ end
+
+ return ctx, true
+end
+
+return blocklist
diff --git a/src/middleware/captcha.lua b/src/middleware/captcha.lua
new file mode 100644
index 0000000..f41cb03
--- /dev/null
+++ b/src/middleware/captcha.lua
@@ -0,0 +1,37 @@
+--[[
+ 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
+ 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/federation.lua b/src/middleware/federation.lua
new file mode 100644
index 0000000..3beda5d
--- /dev/null
+++ b/src/middleware/federation.lua
@@ -0,0 +1,85 @@
+--[[
+ mattata v2.0 - Federation Middleware
+ Checks if incoming users are banned in the chat's federation.
+ Uses PostgreSQL as source of truth with Redis caching.
+]]
+
+local federation = {}
+federation.name = 'federation'
+
+local session = require('src.core.session')
+local logger = require('src.core.logger')
+
+function federation.run(ctx, message)
+ if not ctx.is_group or not message.from then
+ return ctx, true
+ end
+
+ -- Global admins bypass federation bans
+ if ctx.is_global_admin then
+ return ctx, true
+ end
+
+ local chat_id = message.chat.id
+ local user_id = message.from.id
+
+ -- Check if this chat belongs to a federation (cached)
+ local fed_id = session.get_cached_setting(chat_id, 'federation_id', function()
+ local result = ctx.db.execute(
+ 'SELECT federation_id FROM federation_chats WHERE chat_id = $1 LIMIT 1',
+ { chat_id }
+ )
+ if result and #result > 0 then
+ return result[1].federation_id
+ end
+ return nil
+ end, 300)
+
+ if not fed_id then
+ return ctx, true
+ end
+
+ ctx.federation_id = fed_id
+
+ -- Check if user is federation-banned (cached briefly)
+ local ban_key = string.format('fban:%s:%s', fed_id, user_id)
+ local is_banned = ctx.redis.get(ban_key)
+ if is_banned == nil then
+ local ban = ctx.db.execute(
+ 'SELECT reason FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
+ { fed_id, user_id }
+ )
+ if ban and #ban > 0 then
+ ctx.redis.setex(ban_key, 300, ban[1].reason or 'Federation ban')
+ is_banned = ban[1].reason or 'Federation ban'
+ else
+ ctx.redis.setex(ban_key, 300, '__not_banned__')
+ is_banned = '__not_banned__'
+ end
+ end
+
+ if is_banned and is_banned ~= '__not_banned__' then
+ -- Check allowlist
+ local allowlist_key = string.format('fallowlist:%s:%s', fed_id, user_id)
+ local is_allowed = ctx.redis.get(allowlist_key)
+ if is_allowed == nil then
+ local allowed = ctx.db.execute(
+ 'SELECT 1 FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
+ { fed_id, user_id }
+ )
+ is_allowed = (allowed and #allowed > 0) and '1' or '0'
+ ctx.redis.setex(allowlist_key, 300, is_allowed)
+ end
+ if is_allowed ~= '1' then
+ pcall(function()
+ ctx.api.ban_chat_member(chat_id, user_id)
+ end)
+ logger.info('Federation ban enforced: user %d in chat %d (fed %s)', user_id, chat_id, fed_id)
+ return ctx, false
+ end
+ end
+
+ return ctx, true
+end
+
+return federation
diff --git a/src/middleware/language.lua b/src/middleware/language.lua
new file mode 100644
index 0000000..5f6bbfb
--- /dev/null
+++ b/src/middleware/language.lua
@@ -0,0 +1,40 @@
+--[[
+ mattata v2.0 - Language Middleware
+ Loads the appropriate language file into ctx.lang based on user/group settings.
+]]
+
+local language_mw = {}
+language_mw.name = 'language'
+
+local i18n = require('src.core.i18n')
+local session = require('src.core.session')
+
+function language_mw.run(ctx, message)
+ local lang_code = 'en_gb'
+
+ if message.from then
+ -- Check user language setting
+ local user_lang = session.get_setting(message.from.id, 'language')
+ or (message.from.language_code and i18n.exists(message.from.language_code) and message.from.language_code)
+ if user_lang and i18n.exists(user_lang) then
+ lang_code = user_lang
+ end
+ end
+
+ -- Check group language override
+ if ctx.is_group and message.chat then
+ local force_group = session.get_setting(message.chat.id, 'force group language')
+ if force_group then
+ local group_lang = session.get_setting(message.chat.id, 'group language') or 'en_gb'
+ if i18n.exists(group_lang) then
+ lang_code = group_lang
+ end
+ end
+ end
+
+ ctx.lang_code = lang_code
+ ctx.lang = i18n.get(lang_code)
+ return ctx, true
+end
+
+return language_mw
diff --git a/src/middleware/rate_limit.lua b/src/middleware/rate_limit.lua
new file mode 100644
index 0000000..3f7f611
--- /dev/null
+++ b/src/middleware/rate_limit.lua
@@ -0,0 +1,57 @@
+--[[
+ mattata v2.0 - Rate Limit Middleware
+ Anti-spam via Redis TTL counters per user per chat.
+]]
+
+local rate_limit = {}
+rate_limit.name = 'rate_limit'
+
+local session = require('src.core.session')
+
+local RATE_TTL = 5 -- seconds
+local WARNING_THRESHOLD = 10 -- messages per TTL period
+local BLOCKLIST_THRESHOLD = 25 -- messages per TTL period
+local BLOCKLIST_DURATION = 86400 -- 24 hours
+
+function rate_limit.run(ctx, message)
+ if not message.from then
+ return ctx, true
+ end
+
+ -- Don't rate limit global admins
+ if ctx.is_global_admin then
+ return ctx, true
+ end
+
+ -- Don't rate limit forwarded messages
+ if message.forward_from or message.forward_from_chat then
+ return ctx, true
+ end
+
+ local count = session.increment_rate(message.chat.id, message.from.id, RATE_TTL)
+ ctx.message_rate = count
+
+ if count >= BLOCKLIST_THRESHOLD and message.chat.type == 'private' then
+ session.set_global_blocklist(message.from.id, BLOCKLIST_DURATION)
+ local name = message.from.username and ('@' .. message.from.username) or message.from.first_name
+ pcall(function()
+ ctx.api.send_message(
+ message.chat.id,
+ string.format('Sorry, %s, but you have been blocklisted for 24 hours for spamming.', name)
+ )
+ end)
+ return ctx, false
+ elseif count == WARNING_THRESHOLD and message.chat.type == 'private' then
+ local name = message.from.username and ('@' .. message.from.username) or message.from.first_name
+ pcall(function()
+ ctx.api.send_message(
+ message.chat.id,
+ string.format('Hey %s, please slow down or you\'ll be temporarily blocked!', name)
+ )
+ end)
+ end
+
+ return ctx, true
+end
+
+return rate_limit
diff --git a/src/middleware/stats.lua b/src/middleware/stats.lua
new file mode 100644
index 0000000..9990b94
--- /dev/null
+++ b/src/middleware/stats.lua
@@ -0,0 +1,102 @@
+--[[
+ 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
+ local msg_keys = redis.scan('stats:msg:*')
+ local flushed = 0
+ for _, key in ipairs(msg_keys) do
+ local count = tonumber(redis.get(key))
+ 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()
+ db.execute(
+ [[INSERT INTO message_stats (chat_id, user_id, date, message_count)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (chat_id, user_id, date) DO UPDATE SET
+ message_count = message_stats.message_count + $4]],
+ { tonumber(chat_id), tonumber(user_id), date, count }
+ )
+ end)
+ redis.del(key)
+ flushed = flushed + 1
+ end
+ end
+ end
+
+ -- Flush command stats
+ local cmd_keys = redis.scan('stats:cmd:*')
+ for _, key in ipairs(cmd_keys) do
+ local count = tonumber(redis.get(key))
+ 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()
+ db.execute(
+ [[INSERT INTO command_stats (chat_id, command, date, use_count)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (chat_id, command, date) DO UPDATE SET
+ use_count = command_stats.use_count + $4]],
+ { tonumber(chat_id), command, date, count }
+ )
+ end)
+ redis.del(key)
+ flushed = flushed + 1
+ end
+ end
+ end
+
+ if flushed > 0 then
+ logger.info('Flushed %d stats counters to PostgreSQL', flushed)
+ end
+end
+
+return stats_mw
diff --git a/src/middleware/user_tracker.lua b/src/middleware/user_tracker.lua
new file mode 100644
index 0000000..cd4ad8d
--- /dev/null
+++ b/src/middleware/user_tracker.lua
@@ -0,0 +1,83 @@
+--[[
+ mattata v2.0 - User Tracker Middleware
+ Upserts user and chat information to PostgreSQL with Redis-based debouncing.
+ Uses a 60s dedup key per user+chat to reduce DB writes by ~95%.
+]]
+
+local user_tracker = {}
+user_tracker.name = 'user_tracker'
+
+local logger = require('src.core.logger')
+
+function user_tracker.run(ctx, message)
+ if not message.from then
+ return ctx, true
+ end
+
+ local user = message.from
+ local user_id = user.id
+ local chat_id = message.chat and message.chat.id
+
+ -- Debounce: skip DB upserts if we've seen this user+chat in the last 60s
+ local dedup_key = string.format('seen:%s:%s', user_id, chat_id or 'private')
+ local already_seen = ctx.redis.exists(dedup_key)
+ if already_seen == 1 or already_seen == true then
+ -- Still update username->id mapping (cheap Redis SET)
+ if user.username then
+ ctx.redis.set('username:' .. user.username:lower(), user_id)
+ end
+ return ctx, true
+ end
+
+ -- Set dedup key with 60s TTL
+ ctx.redis.setex(dedup_key, 60, '1')
+
+ -- Upsert user to PostgreSQL
+ pcall(function()
+ ctx.db.upsert('users', {
+ user_id = user_id,
+ username = user.username and user.username:lower() or nil,
+ first_name = user.first_name,
+ last_name = user.last_name,
+ language_code = user.language_code,
+ is_bot = user.is_bot or false,
+ last_seen = os.date('!%Y-%m-%d %H:%M:%S')
+ }, { 'user_id' }, {
+ 'username', 'first_name', 'last_name', 'language_code', 'last_seen'
+ })
+ end)
+
+ -- Upsert chat to PostgreSQL (for groups)
+ if chat_id and message.chat.type ~= 'private' then
+ pcall(function()
+ ctx.db.upsert('chats', {
+ chat_id = chat_id,
+ title = message.chat.title,
+ chat_type = message.chat.type,
+ username = message.chat.username and message.chat.username:lower() or nil
+ }, { 'chat_id' }, {
+ 'title', 'chat_type', 'username'
+ })
+ end)
+
+ -- Track user<->chat membership
+ pcall(function()
+ ctx.db.upsert('chat_members', {
+ chat_id = chat_id,
+ user_id = user_id,
+ last_seen = os.date('!%Y-%m-%d %H:%M:%S')
+ }, { 'chat_id', 'user_id' }, {
+ 'last_seen'
+ })
+ end)
+ end
+
+ -- Keep Redis username->id mapping for quick lookups
+ if user.username then
+ ctx.redis.set('username:' .. user.username:lower(), user_id)
+ end
+
+ return ctx, true
+end
+
+return user_tracker
diff --git a/src/plugins/admin/addalias.lua b/src/plugins/admin/addalias.lua
new file mode 100644
index 0000000..5d8b318
--- /dev/null
+++ b/src/plugins/admin/addalias.lua
@@ -0,0 +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')
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/addtrigger.lua b/src/plugins/admin/addtrigger.lua
new file mode 100644
index 0000000..fce6689
--- /dev/null
+++ b/src/plugins/admin/addtrigger.lua
@@ -0,0 +1,131 @@
+--[[
+ 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.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\nThe 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.execute(
+ 'SELECT id, pattern FROM triggers WHERE chat_id = $1 ORDER BY created_at',
+ { 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.execute('DELETE FROM triggers WHERE id = $1', { 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')
+ 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
+ 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
+
+ -- Check for duplicate
+ local existing = ctx.db.execute(
+ 'SELECT id FROM triggers WHERE chat_id = $1 AND pattern = $2',
+ { message.chat.id, pattern }
+ )
+ if existing and #existing > 0 then
+ ctx.db.execute(
+ 'UPDATE triggers SET response = $1 WHERE chat_id = $2 AND pattern = $3',
+ { 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')
+ end
+
+ ctx.db.insert('triggers', {
+ chat_id = message.chat.id,
+ pattern = pattern,
+ response = response,
+ created_by = 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')
+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.execute(
+ 'SELECT pattern, response, is_media, file_id FROM triggers WHERE chat_id = $1',
+ { 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
+ 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)
+ else
+ api.send_message(message.chat.id, t.response, nil, nil, nil, message.message_id)
+ end
+ return
+ end
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/administration.lua b/src/plugins/admin/administration.lua
new file mode 100644
index 0000000..d537093
--- /dev/null
+++ b/src/plugins/admin/administration.lua
@@ -0,0 +1,190 @@
+--[[
+ 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.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = $2",
+ { 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))
+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.')
+ 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))
+ 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.execute(
+ "UPDATE chat_settings SET value = 'false' WHERE chat_id = $1 AND key = $2",
+ { message.chat.id, key }
+ )
+ else
+ ctx.db.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = key,
+ value = 'true'
+ }, { 'chat_id', 'key' }, { 'value' })
+ 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))
+
+ return api.answer_callback_query(callback_query.id, 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
new file mode 100644
index 0000000..dcd2d07
--- /dev/null
+++ b/src/plugins/admin/allowedlinks.lua
@@ -0,0 +1,35 @@
+--[[
+ 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.execute(
+ 'SELECT link FROM allowed_links WHERE chat_id = $1 ORDER BY link',
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/allowlink.lua b/src/plugins/admin/allowlink.lua
new file mode 100644
index 0000000..6ba2bee
--- /dev/null
+++ b/src/plugins/admin/allowlink.lua
@@ -0,0 +1,77 @@
+--[[
+ 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
+ local result = ctx.db.execute(
+ 'DELETE FROM allowed_links WHERE chat_id = $1 AND link = $2',
+ { message.chat.id, link }
+ )
+ -- Also try with lowercase
+ ctx.db.execute(
+ 'DELETE FROM allowed_links WHERE chat_id = $1 AND link = $2',
+ { 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')
+ end
+
+ -- Check if already allowed
+ local existing = ctx.db.execute(
+ 'SELECT 1 FROM allowed_links WHERE chat_id = $1 AND link = $2',
+ { 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.insert('allowed_links', {
+ chat_id = message.chat.id,
+ link = 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/allowlist.lua b/src/plugins/admin/allowlist.lua
new file mode 100644
index 0000000..5cac143
--- /dev/null
+++ b/src/plugins/admin/allowlist.lua
@@ -0,0 +1,86 @@
+--[[
+ 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.execute(
+ "SELECT user_id FROM chat_members WHERE chat_id = $1 AND role = 'allowlisted'",
+ { 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')
+ 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.upsert('chat_members', {
+ chat_id = message.chat.id,
+ user_id = user_id,
+ role = 'allowlisted'
+ }, { 'chat_id', 'user_id' }, { 'role' })
+
+ 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')
+
+ elseif action == 'remove' or action == 'del' or action == 'delete' then
+ ctx.db.execute(
+ "UPDATE chat_members SET role = 'member' WHERE chat_id = $1 AND user_id = $2 AND role = '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')
+
+ 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
new file mode 100644
index 0000000..c7e2b18
--- /dev/null
+++ b/src/plugins/admin/antilink.lua
@@ -0,0 +1,113 @@
+--[[
+ 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.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'antilink_enabled'",
+ { message.chat.id }
+ )
+ 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')
+ end
+
+ local arg = message.args:lower()
+ if arg == 'on' or arg == 'enable' then
+ ctx.db.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'antilink_enabled',
+ value = 'true'
+ }, { 'chat_id', 'key' }, { 'value' })
+ 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.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'antilink_enabled',
+ value = 'false'
+ }, { 'chat_id', 'key' }, { 'value' })
+ 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 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.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'antilink_enabled'",
+ { message.chat.id }
+ )
+ 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.execute(
+ 'SELECT 1 FROM allowed_links WHERE chat_id = $1 AND link = $2',
+ { 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')
+ return
+ end
+ end
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/antispam.lua b/src/plugins/admin/antispam.lua
new file mode 100644
index 0000000..4a4e260
--- /dev/null
+++ b/src/plugins/admin/antispam.lua
@@ -0,0 +1,81 @@
+--[[
+ 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.execute(
+ "SELECT key, value FROM chat_settings WHERE chat_id = $1 AND key LIKE 'antispam_%'",
+ { message.chat.id }
+ )
+ 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')
+ 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.execute(
+ "DELETE FROM chat_settings WHERE chat_id = $1 AND key = $2",
+ { 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')
+ 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.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = setting_key,
+ value = tostring(limit)
+ }, { 'chat_id', 'key' }, { 'value' })
+
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/ban.lua b/src/plugins/admin/ban.lua
new file mode 100644
index 0000000..6cb1083
--- /dev/null
+++ b/src/plugins/admin/ban.lua
@@ -0,0 +1,100 @@
+--[[
+ 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.insert('bans', {
+ chat_id = message.chat.id,
+ user_id = user_id,
+ banned_by = message.from.id,
+ reason = reason
+ })
+ ctx.db.insert('admin_actions', {
+ chat_id = message.chat.id,
+ admin_id = message.from.id,
+ target_id = user_id,
+ action = 'ban',
+ reason = 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')
+
+ -- 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
new file mode 100644
index 0000000..39594ae
--- /dev/null
+++ b/src/plugins/admin/blocklist.lua
@@ -0,0 +1,118 @@
+--[[
+ 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.execute(
+ 'SELECT user_id, reason, created_at FROM group_blocklist WHERE chat_id = $1 ORDER BY created_at DESC',
+ { 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')
+ 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.upsert('group_blocklist', {
+ chat_id = message.chat.id,
+ user_id = user_id,
+ reason = reason
+ }, { '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')
+ 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.execute(
+ 'DELETE FROM group_blocklist WHERE chat_id = $1 AND user_id = $2',
+ { 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')
+ 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
new file mode 100644
index 0000000..7b4735c
--- /dev/null
+++ b/src/plugins/admin/channel.lua
@@ -0,0 +1,60 @@
+--[[
+ 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 }
+ )
+ 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')
+ 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 }
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/demote.lua b/src/plugins/admin/demote.lua
new file mode 100644
index 0000000..dfbfeec
--- /dev/null
+++ b/src/plugins/admin/demote.lua
@@ -0,0 +1,59 @@
+--[[
+ 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.execute(
+ "UPDATE chat_members SET role = 'member' WHERE chat_id = $1 AND user_id = $2",
+ { message.chat.id, user_id }
+ )
+
+ pcall(function()
+ ctx.db.insert('admin_actions', {
+ chat_id = message.chat.id,
+ admin_id = message.from.id,
+ target_id = user_id,
+ action = 'demote'
+ })
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/delfed.lua b/src/plugins/admin/federation/delfed.lua
new file mode 100644
index 0000000..3e68ddf
--- /dev/null
+++ b/src/plugins/admin/federation/delfed.lua
@@ -0,0 +1,133 @@
+--[[
+ 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'
+ )
+ end
+
+ fed_id = fed_id:match('^(%S+)')
+
+ local fed = ctx.db.execute(
+ 'SELECT id, name, owner_id FROM federations WHERE id = $1',
+ { 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'
+ )
+ 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'
+ )
+ 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 }
+ } }
+ }
+
+ 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)
+ )
+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
+
+ 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.')
+ end
+
+ if data.action == 'cancel' then
+ api.answer_callback_query(callback_query.id, 'Deletion cancelled.')
+ return api.edit_message_text(
+ message.chat.id,
+ message.message_id,
+ 'Federation deletion cancelled.',
+ 'html'
+ )
+ end
+
+ if data.action == 'confirm' then
+ local fed = ctx.db.execute(
+ 'SELECT name, owner_id FROM federations WHERE id = $1',
+ { data.fed_id }
+ )
+ if not fed or #fed == 0 then
+ api.answer_callback_query(callback_query.id, 'Federation no longer exists.')
+ return api.edit_message_text(
+ message.chat.id,
+ message.message_id,
+ 'This federation no longer exists.',
+ '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.')
+ end
+
+ ctx.db.execute(
+ 'DELETE FROM federations WHERE id = $1',
+ { data.fed_id }
+ )
+
+ api.answer_callback_query(callback_query.id, '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'
+ )
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/fadmins.lua b/src/plugins/admin/federation/fadmins.lua
new file mode 100644
index 0000000..510d762
--- /dev/null
+++ b/src/plugins/admin/federation/fadmins.lua
@@ -0,0 +1,61 @@
+--[[
+ 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.execute(
+ 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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'
+ )
+ 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.execute(
+ 'SELECT fa.user_id, fa.promoted_at FROM federation_admins fa WHERE fa.federation_id = $1 ORDER BY fa.promoted_at ASC',
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/fallowlist.lua b/src/plugins/admin/federation/fallowlist.lua
new file mode 100644
index 0000000..5e27a01
--- /dev/null
+++ b/src/plugins/admin/federation/fallowlist.lua
@@ -0,0 +1,126 @@
+--[[
+ 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.execute(
+ 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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.execute(
+ 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
+ { 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'
+ )
+ 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'
+ )
+ 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'
+ )
+ end
+
+ -- Check if already allowlisted
+ local existing = ctx.db.execute(
+ 'SELECT 1 FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
+ { fed.id, target_id }
+ )
+
+ if existing and #existing > 0 then
+ -- Remove from allowlist
+ ctx.db.execute(
+ 'DELETE FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
+ { fed.id, target_id }
+ )
+ -- Invalidate Redis cache
+ 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'
+ )
+ else
+ -- Add to allowlist
+ ctx.db.execute(
+ 'INSERT INTO federation_allowlist (federation_id, user_id) VALUES ($1, $2)',
+ { fed.id, target_id }
+ )
+ -- Invalidate Redis cache
+ 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'
+ )
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/fban.lua b/src/plugins/admin/federation/fban.lua
new file mode 100644
index 0000000..721347c
--- /dev/null
+++ b/src/plugins/admin/federation/fban.lua
@@ -0,0 +1,184 @@
+--[[
+ 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.execute(
+ 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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.execute(
+ 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
+ { fed_id, user_id }
+ )
+ return result and #result > 0
+end
+
+local function is_allowlisted(db, fed_id, user_id)
+ local result = db.execute(
+ 'SELECT 1 FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
+ { 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'
+ )
+ 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'
+ )
+ 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'
+ )
+ 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'
+ )
+ end
+
+ -- Check allowlist
+ 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'
+ )
+ end
+
+ -- Extract reason (everything after the user identifier)
+ 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
+
+ -- Check if already banned
+ local existing_ban = ctx.db.execute(
+ 'SELECT 1 FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
+ { fed.id, target_id }
+ )
+ if existing_ban and #existing_ban > 0 then
+ -- Update reason if provided
+ if reason then
+ ctx.db.execute(
+ 'UPDATE federation_bans SET reason = $1, banned_by = $2, banned_at = NOW() WHERE federation_id = $3 AND user_id = $4',
+ { reason, from_id, fed.id, target_id }
+ )
+ end
+ else
+ ctx.db.execute(
+ 'INSERT INTO federation_bans (federation_id, user_id, reason, banned_by) VALUES ($1, $2, $3, $4)',
+ { fed.id, target_id, reason, from_id }
+ )
+ end
+
+ -- Invalidate Redis cache
+ ctx.redis.del(string.format('fban:%s:%s', fed.id, target_id))
+
+ -- Get all chats in the federation and ban the user
+ local chats = ctx.db.execute(
+ 'SELECT chat_id FROM federation_chats WHERE federation_id = $1',
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/fbaninfo.lua b/src/plugins/admin/federation/fbaninfo.lua
new file mode 100644
index 0000000..f53226b
--- /dev/null
+++ b/src/plugins/admin/federation/fbaninfo.lua
@@ -0,0 +1,107 @@
+--[[
+ 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
+ -- Default to the sender if no user specified
+ target_id = message.from.id
+ target_name = message.from.first_name
+ end
+
+ -- Find all federations this chat belongs to (or all federations if in private)
+ local bans
+ if ctx.is_group then
+ bans = ctx.db.execute(
+ [[SELECT fb.reason, fb.banned_by, fb.banned_at, f.name, f.id
+ FROM federation_bans fb
+ JOIN federations f ON fb.federation_id = f.id
+ JOIN federation_chats fc ON f.id = fc.federation_id
+ WHERE fb.user_id = $1 AND fc.chat_id = $2]],
+ { target_id, message.chat.id }
+ )
+ else
+ bans = ctx.db.execute(
+ [[SELECT fb.reason, fb.banned_by, fb.banned_at, f.name, f.id
+ FROM federation_bans fb
+ JOIN federations f ON fb.federation_id = f.id
+ WHERE fb.user_id = $1]],
+ { 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'
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/fdemote.lua b/src/plugins/admin/federation/fdemote.lua
new file mode 100644
index 0000000..54ee84c
--- /dev/null
+++ b/src/plugins/admin/federation/fdemote.lua
@@ -0,0 +1,104 @@
+--[[
+ 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.execute(
+ 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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'
+ )
+ end
+
+ if fed.owner_id ~= message.from.id then
+ return api.send_message(
+ message.chat.id,
+ 'Only the federation owner can demote admins.',
+ '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'
+ )
+ end
+
+ -- Check if actually an admin
+ local existing = ctx.db.execute(
+ 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
+ { 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'
+ )
+ end
+
+ ctx.db.execute(
+ 'DELETE FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
+ { 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'
+ )
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/feds.lua b/src/plugins/admin/federation/feds.lua
new file mode 100644
index 0000000..03173db
--- /dev/null
+++ b/src/plugins/admin/federation/feds.lua
@@ -0,0 +1,105 @@
+--[[
+ 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.execute(
+ 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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.execute(
+ 'SELECT id, name, owner_id, created_at FROM federations WHERE id = $1',
+ { 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'
+ )
+ 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'
+ )
+ end
+ -- Fetch created_at since get_chat_federation doesn't include it
+ local full = ctx.db.execute(
+ 'SELECT created_at FROM federations WHERE id = $1',
+ { 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'
+ )
+ end
+
+ -- Get counts
+ local admin_count = ctx.db.execute(
+ 'SELECT COUNT(*) AS count FROM federation_admins WHERE federation_id = $1',
+ { fed.id }
+ )
+ local chat_count = ctx.db.execute(
+ 'SELECT COUNT(*) AS count FROM federation_chats WHERE federation_id = $1',
+ { fed.id }
+ )
+ local ban_count = ctx.db.execute(
+ 'SELECT COUNT(*) AS count FROM federation_bans WHERE federation_id = $1',
+ { fed.id }
+ )
+
+ local admins = (admin_count and admin_count[1]) and tonumber(admin_count[1].count) or 0
+ local chats = (chat_count and chat_count[1]) and tonumber(chat_count[1].count) or 0
+ local bans = (ban_count and ban_count[1]) and tonumber(ban_count[1].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')
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/fpromote.lua b/src/plugins/admin/federation/fpromote.lua
new file mode 100644
index 0000000..4989785
--- /dev/null
+++ b/src/plugins/admin/federation/fpromote.lua
@@ -0,0 +1,112 @@
+--[[
+ 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.execute(
+ 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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'
+ )
+ end
+
+ if fed.owner_id ~= message.from.id then
+ return api.send_message(
+ message.chat.id,
+ 'Only the federation owner can promote admins.',
+ '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'
+ )
+ 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'
+ )
+ end
+
+ -- Check if already an admin
+ local existing = ctx.db.execute(
+ 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
+ { 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'
+ )
+ end
+
+ ctx.db.execute(
+ 'INSERT INTO federation_admins (federation_id, user_id, promoted_by) VALUES ($1, $2, $3)',
+ { 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'
+ )
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/init.lua b/src/plugins/admin/federation/init.lua
new file mode 100644
index 0000000..08188ee
--- /dev/null
+++ b/src/plugins/admin/federation/init.lua
@@ -0,0 +1,8 @@
+--[[
+ mattata v2.0 - Federation Sub-Package
+
+ Federation plugins are loaded via the parent admin init.lua,
+ not through this file.
+]]
+
+return {}
diff --git a/src/plugins/admin/federation/joinfed.lua b/src/plugins/admin/federation/joinfed.lua
new file mode 100644
index 0000000..6ddaf18
--- /dev/null
+++ b/src/plugins/admin/federation/joinfed.lua
@@ -0,0 +1,84 @@
+--[[
+ 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'
+ )
+ end
+
+ fed_id = fed_id:match('^(%S+)')
+ local chat_id = message.chat.id
+
+ -- Check if the chat is already in a federation
+ local existing = ctx.db.execute(
+ 'SELECT f.id, f.name FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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'
+ )
+ end
+
+ -- Check if the federation exists
+ local fed = ctx.db.execute(
+ 'SELECT id, name FROM federations WHERE id = $1',
+ { 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'
+ )
+ end
+
+ fed = fed[1]
+
+ local result = ctx.db.execute(
+ 'INSERT INTO federation_chats (federation_id, chat_id, joined_by) VALUES ($1, $2, $3)',
+ { 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'
+ )
+ end
+
+ return api.send_message(
+ chat_id,
+ string.format(
+ 'This chat has joined the federation <b>%s</b>.',
+ tools.escape_html(fed.name)
+ ),
+ 'html'
+ )
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/leavefed.lua b/src/plugins/admin/federation/leavefed.lua
new file mode 100644
index 0000000..9d97040
--- /dev/null
+++ b/src/plugins/admin/federation/leavefed.lua
@@ -0,0 +1,51 @@
+--[[
+ 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
+
+ -- Check if the chat is in a federation
+ local existing = ctx.db.execute(
+ 'SELECT f.id, f.name FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { chat_id }
+ )
+ if not existing or #existing == 0 then
+ return api.send_message(
+ chat_id,
+ 'This chat is not part of any federation.',
+ 'html'
+ )
+ end
+
+ local fed = existing[1]
+
+ ctx.db.execute(
+ 'DELETE FROM federation_chats WHERE federation_id = $1 AND chat_id = $2',
+ { 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'
+ )
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/myfeds.lua b/src/plugins/admin/federation/myfeds.lua
new file mode 100644
index 0000000..1b2c316
--- /dev/null
+++ b/src/plugins/admin/federation/myfeds.lua
@@ -0,0 +1,89 @@
+--[[
+ 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
+
+ -- Federations owned by the user
+ local owned = ctx.db.execute(
+ [[SELECT f.id, f.name,
+ (SELECT COUNT(*) FROM federation_chats WHERE federation_id = f.id) AS chat_count,
+ (SELECT COUNT(*) FROM federation_bans WHERE federation_id = f.id) AS ban_count
+ FROM federations f
+ WHERE f.owner_id = $1
+ ORDER BY f.created_at ASC]],
+ { user_id }
+ )
+
+ -- Federations where user is an admin (but not owner)
+ local admin_of = ctx.db.execute(
+ [[SELECT f.id, f.name, f.owner_id,
+ (SELECT COUNT(*) FROM federation_chats WHERE federation_id = f.id) AS chat_count,
+ (SELECT COUNT(*) FROM federation_bans WHERE federation_id = f.id) AS ban_count
+ FROM federations f
+ JOIN federation_admins fa ON f.id = fa.federation_id
+ WHERE fa.user_id = $1 AND f.owner_id != $1
+ ORDER BY fa.promoted_at ASC]],
+ { 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'
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/newfed.lua b/src/plugins/admin/federation/newfed.lua
new file mode 100644
index 0000000..7ac367d
--- /dev/null
+++ b/src/plugins/admin/federation/newfed.lua
@@ -0,0 +1,73 @@
+--[[
+ 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'
+ )
+ end
+
+ if #name > 128 then
+ return api.send_message(
+ message.chat.id,
+ 'Federation name must be 128 characters or fewer.',
+ 'html'
+ )
+ end
+
+ local user_id = message.from.id
+
+ -- Check how many federations this user already owns
+ local existing = ctx.db.execute(
+ 'SELECT COUNT(*) AS count FROM federations WHERE owner_id = $1',
+ { 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'
+ )
+ end
+
+ local result = ctx.db.execute(
+ 'INSERT INTO federations (name, owner_id) VALUES ($1, $2) RETURNING id',
+ { 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'
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/federation/unfban.lua b/src/plugins/admin/federation/unfban.lua
new file mode 100644
index 0000000..13de261
--- /dev/null
+++ b/src/plugins/admin/federation/unfban.lua
@@ -0,0 +1,145 @@
+--[[
+ 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.execute(
+ 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
+ { 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.execute(
+ 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
+ { 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'
+ )
+ 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'
+ )
+ 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'
+ )
+ end
+
+ -- Check if the user is actually banned
+ local ban = ctx.db.execute(
+ 'SELECT 1 FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
+ { 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'
+ )
+ end
+
+ -- Remove the ban record
+ ctx.db.execute(
+ 'DELETE FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
+ { fed.id, target_id }
+ )
+
+ -- Invalidate Redis cache
+ ctx.redis.del(string.format('fban:%s:%s', fed.id, target_id))
+
+ -- Unban in all federation chats
+ local chats = ctx.db.execute(
+ 'SELECT chat_id FROM federation_chats WHERE federation_id = $1',
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/filter.lua b/src/plugins/admin/filter.lua
new file mode 100644
index 0000000..bd40083
--- /dev/null
+++ b/src/plugins/admin/filter.lua
@@ -0,0 +1,82 @@
+--[[
+ 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
+
+local VALID_ACTIONS = {
+ delete = true,
+ warn = true,
+ ban = true,
+ kick = true,
+ mute = 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')
+ end
+
+ 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)$')
+ 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
+ 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
+
+ -- Check for duplicate
+ local existing = ctx.db.execute(
+ 'SELECT id FROM filters WHERE chat_id = $1 AND pattern = $2',
+ { message.chat.id, pattern }
+ )
+ if existing and #existing > 0 then
+ -- Update the action if filter already exists
+ ctx.db.execute(
+ 'UPDATE filters SET action = $1 WHERE chat_id = $2 AND pattern = $3',
+ { 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')
+ end
+
+ ctx.db.insert('filters', {
+ chat_id = message.chat.id,
+ pattern = pattern,
+ action = action,
+ created_by = 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/groups.lua b/src/plugins/admin/groups.lua
new file mode 100644
index 0000000..d8c06db
--- /dev/null
+++ b/src/plugins/admin/groups.lua
@@ -0,0 +1,59 @@
+--[[
+ 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.execute(
+ "SELECT chat_id, title, username FROM chats WHERE chat_type IN ('group', 'supergroup') AND LOWER(title) LIKE $1 ORDER BY title LIMIT 50",
+ { '%' .. search .. '%' }
+ )
+ else
+ result = ctx.db.execute(
+ "SELECT chat_id, title, username FROM chats WHERE chat_type IN ('group', 'supergroup') ORDER BY title LIMIT 50"
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/import.lua b/src/plugins/admin/import.lua
new file mode 100644
index 0000000..977c611
--- /dev/null
+++ b/src/plugins/admin/import.lua
@@ -0,0 +1,154 @@
+--[[
+ 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
+
+ local imported = {}
+
+ -- Import chat_settings
+ local settings = ctx.db.execute(
+ 'SELECT key, value FROM chat_settings WHERE chat_id = $1',
+ { 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 }
+ )
+ 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 }
+ )
+ 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 }
+ )
+ 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 }
+ )
+ 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 }
+ )
+ 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 }
+ )
+ 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 }
+ )
+ 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 }
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/init.lua b/src/plugins/admin/init.lua
new file mode 100644
index 0000000..74c699e
--- /dev/null
+++ b/src/plugins/admin/init.lua
@@ -0,0 +1,63 @@
+--[[
+ 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',
+ -- 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
new file mode 100644
index 0000000..afbef6b
--- /dev/null
+++ b/src/plugins/admin/join_captcha.lua
@@ -0,0 +1,166 @@
+--[[
+ mattata v2.0 - Join Captcha Plugin
+ Handles captcha verification for new members joining the group.
+]]
+
+local plugin = {}
+plugin.name = 'join_captcha'
+plugin.category = 'admin'
+plugin.description = 'Captcha challenge for new members'
+plugin.commands = {}
+plugin.help = ''
+plugin.group_only = true
+plugin.admin_only = false
+
+local json = require('dkjson')
+
+-- Generate a simple math captcha
+local function generate_captcha()
+ math.randomseed(os.time())
+ local a = math.random(1, 20)
+ local b = math.random(1, 20)
+ local operators = { '+', '-' }
+ local op = operators[math.random(1, 2)]
+ local answer
+ if op == '+' then
+ answer = a + b
+ else
+ -- Ensure non-negative result
+ if a < b then a, b = b, a end
+ answer = a - b
+ end
+ return string.format('%d %s %d', a, op, b), tostring(answer)
+end
+
+-- Generate wrong answers for the keyboard
+local function generate_options(correct_answer)
+ local options = { correct_answer }
+ local correct_num = tonumber(correct_answer)
+ while #options < 4 do
+ local wrong = correct_num + math.random(-5, 5)
+ if wrong ~= correct_num and wrong >= 0 then
+ local str = tostring(wrong)
+ local duplicate = false
+ for _, v in ipairs(options) do
+ if v == str then duplicate = true; break end
+ end
+ if not duplicate then
+ table.insert(options, str)
+ end
+ end
+ end
+ -- Shuffle
+ for i = #options, 2, -1 do
+ local j = math.random(1, i)
+ options[i], options[j] = options[j], options[i]
+ end
+ return options
+end
+
+function plugin.on_member_join(api, message, ctx)
+ if not ctx.is_group then return end
+
+ -- Check if captcha is enabled
+ local enabled = ctx.db.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'captcha_enabled'",
+ { message.chat.id }
+ )
+ if not enabled or #enabled == 0 or enabled[1].value ~= 'true' then
+ return
+ end
+
+ if not require('src.core.permissions').can_restrict(api, message.chat.id) then return end
+
+ local timeout_result = ctx.db.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'captcha_timeout'",
+ { message.chat.id }
+ )
+ local timeout = (timeout_result and #timeout_result > 0) and tonumber(timeout_result[1].value) or 300
+
+ for _, new_member in ipairs(message.new_chat_members) do
+ if new_member.is_bot then goto continue end
+
+ -- Restrict the new member
+ api.restrict_chat_member(message.chat.id, new_member.id, os.time() + timeout, {
+ can_send_messages = false,
+ can_send_media_messages = false,
+ can_send_other_messages = false,
+ can_add_web_page_previews = false
+ })
+
+ -- Generate captcha
+ local question, answer = generate_captcha()
+ local options = generate_options(answer)
+
+ -- Build keyboard
+ local keyboard = { inline_keyboard = { {} } }
+ for _, opt in ipairs(options) do
+ table.insert(keyboard.inline_keyboard[1], {
+ text = opt,
+ callback_data = string.format('join_captcha:%s:%s:%s', message.chat.id, new_member.id, opt)
+ })
+ end
+
+ local tools = require('telegram-bot-lua.tools')
+ local text = string.format(
+ 'Welcome, <a href="tg://user?id=%d">%s</a>! Please solve this to verify you\'re human:\n\n<b>What is %s?</b>\n\nYou have %d seconds.',
+ new_member.id,
+ tools.escape_html(new_member.first_name),
+ question,
+ timeout
+ )
+
+ local sent = api.send_message(message.chat.id, text, 'html', false, false, nil, json.encode(keyboard))
+
+ -- Store captcha state
+ if sent and sent.result then
+ ctx.session.set_captcha(message.chat.id, new_member.id, answer, sent.result.message_id, timeout)
+ end
+
+ ::continue::
+ end
+end
+
+function plugin.on_callback_query(api, callback_query, message, ctx)
+ local data = callback_query.data
+ if not data then return end
+
+ local chat_id, user_id, selected = data:match('^(%-?%d+):(%d+):(.+)$')
+ if not chat_id then return end
+
+ chat_id = tonumber(chat_id)
+ user_id = tonumber(user_id)
+
+ -- Only the joining user can answer
+ if callback_query.from.id ~= user_id then
+ return api.answer_callback_query(callback_query.id, 'This captcha is not for you.')
+ end
+
+ local captcha = ctx.session.get_captcha(chat_id, user_id)
+ if not captcha then
+ return api.answer_callback_query(callback_query.id, 'This captcha has expired.')
+ end
+
+ if selected == captcha.text then
+ -- Correct answer - unrestrict user
+ api.restrict_chat_member(chat_id, user_id, 0, {
+ can_send_messages = true,
+ can_send_media_messages = true,
+ can_send_other_messages = true,
+ can_add_web_page_previews = true
+ })
+ ctx.session.clear_captcha(chat_id, user_id)
+
+ local tools = require('telegram-bot-lua.tools')
+ api.edit_message_text(message.chat.id, message.message_id, string.format(
+ '<a href="tg://user?id=%d">%s</a> has been verified. Welcome!',
+ user_id, tools.escape_html(callback_query.from.first_name)
+ ), 'html')
+ api.answer_callback_query(callback_query.id, 'Correct! Welcome to the group.')
+ else
+ -- Wrong answer
+ api.answer_callback_query(callback_query.id, 'Wrong answer. Try again!')
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/kick.lua b/src/plugins/admin/kick.lua
new file mode 100644
index 0000000..8681f2e
--- /dev/null
+++ b/src/plugins/admin/kick.lua
@@ -0,0 +1,75 @@
+--[[
+ 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.insert('admin_actions', {
+ chat_id = message.chat.id, admin_id = message.from.id,
+ target_id = user_id, action = 'kick', reason = 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')
+ 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
new file mode 100644
index 0000000..d657ed0
--- /dev/null
+++ b/src/plugins/admin/link.lua
@@ -0,0 +1,68 @@
+--[[
+ 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 }
+ )
+ 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
new file mode 100644
index 0000000..ca5699a
--- /dev/null
+++ b/src/plugins/admin/logchat.lua
@@ -0,0 +1,58 @@
+--[[
+ 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.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'log_chat'",
+ { message.chat.id }
+ )
+ 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')
+ 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.execute(
+ "DELETE FROM chat_settings WHERE chat_id = $1 AND key = 'log_chat'",
+ { message.chat.id }
+ )
+ 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)
+ 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.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'log_chat',
+ value = tostring(log_chat_id)
+ }, { 'chat_id', 'key' }, { 'value' })
+
+ api.send_message(message.chat.id, string.format('Log chat set to <code>%d</code>.', log_chat_id), 'html')
+end
+
+return plugin
diff --git a/src/plugins/admin/mute.lua b/src/plugins/admin/mute.lua
new file mode 100644
index 0000000..8a1bdcb
--- /dev/null
+++ b/src/plugins/admin/mute.lua
@@ -0,0 +1,82 @@
+--[[
+ 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
+ }
+ 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.insert('admin_actions', {
+ chat_id = message.chat.id, admin_id = message.from.id,
+ target_id = user_id, action = 'mute', reason = 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/nodelete.lua b/src/plugins/admin/nodelete.lua
new file mode 100644
index 0000000..096adea
--- /dev/null
+++ b/src/plugins/admin/nodelete.lua
@@ -0,0 +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')
+ 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')
+ 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')
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/pin.lua b/src/plugins/admin/pin.lua
new file mode 100644
index 0000000..d8fc2eb
--- /dev/null
+++ b/src/plugins/admin/pin.lua
@@ -0,0 +1,72 @@
+--[[
+ 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)
+ 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
+ })
+ 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)
+ 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'
+ })
+ 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
new file mode 100644
index 0000000..435e70f
--- /dev/null
+++ b/src/plugins/admin/promote.lua
@@ -0,0 +1,61 @@
+--[[
+ 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.upsert('chat_members', {
+ chat_id = message.chat.id,
+ user_id = user_id,
+ role = 'moderator'
+ }, { 'chat_id', 'user_id' }, { 'role' })
+
+ pcall(function()
+ ctx.db.insert('admin_actions', {
+ chat_id = message.chat.id,
+ admin_id = message.from.id,
+ target_id = user_id,
+ action = 'promote'
+ })
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/purge.lua b/src/plugins/admin/purge.lua
new file mode 100644
index 0000000..6cfd735
--- /dev/null
+++ b/src/plugins/admin/purge.lua
@@ -0,0 +1,58 @@
+--[[
+ mattata v2.0 - Purge Plugin
+]]
+
+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
+
+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
+
+ for msg_id = start_id, end_id do
+ local success = api.delete_message(message.chat.id, msg_id)
+ if success then
+ count = count + 1
+ else
+ failed = failed + 1
+ end
+ end
+
+ pcall(function()
+ ctx.db.insert('admin_actions', {
+ chat_id = message.chat.id,
+ admin_id = message.from.id,
+ action = 'purge',
+ reason = string.format('Purged %d messages (%d failed)', count, failed)
+ })
+ end)
+
+ local status = api.send_message(message.chat.id, string.format('Purged <b>%d</b> message(s).', count), 'html')
+ -- Auto-delete the status message after a short delay
+ if status and status.result then
+ pcall(function()
+ local socket = require('socket')
+ socket.sleep(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
new file mode 100644
index 0000000..c8c44da
--- /dev/null
+++ b/src/plugins/admin/report.lua
@@ -0,0 +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)
+end
+
+return plugin
diff --git a/src/plugins/admin/rules.lua b/src/plugins/admin/rules.lua
new file mode 100644
index 0000000..922d321
--- /dev/null
+++ b/src/plugins/admin/rules.lua
@@ -0,0 +1,44 @@
+--[[
+ 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')
+ local permissions = require('src.core.permissions')
+
+ -- 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.upsert('rules', {
+ chat_id = message.chat.id,
+ rules_text = message.args
+ }, { 'chat_id' }, { 'rules_text' })
+ return api.send_message(message.chat.id, 'The rules have been updated.')
+ end
+
+ -- Retrieve rules
+ local result = ctx.db.execute(
+ 'SELECT rules_text FROM rules WHERE chat_id = $1',
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/save.lua b/src/plugins/admin/save.lua
new file mode 100644
index 0000000..a1e9bd5
--- /dev/null
+++ b/src/plugins/admin/save.lua
@@ -0,0 +1,122 @@
+--[[
+ 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')
+ local permissions = require('src.core.permissions')
+
+ if message.command == 'get' then
+ if not message.args then
+ -- List all saved notes
+ local notes = ctx.db.execute(
+ 'SELECT note_name FROM saved_notes WHERE chat_id = $1 ORDER BY note_name',
+ { 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')
+ end
+
+ local name = message.args:lower():match('^(%S+)')
+ local note = ctx.db.execute(
+ 'SELECT content, content_type, file_id FROM saved_notes WHERE chat_id = $1 AND note_name = $2',
+ { 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')
+ 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)
+ elseif n.content_type == 'document' and n.file_id then
+ api.send_document(message.chat.id, n.file_id, 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)
+ elseif n.content_type == 'audio' and n.file_id then
+ api.send_audio(message.chat.id, n.file_id, 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')
+ 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.upsert('saved_notes', {
+ chat_id = message.chat.id,
+ note_name = name,
+ content = content,
+ content_type = content_type,
+ file_id = file_id,
+ created_by = message.from.id
+ }, { 'chat_id', 'note_name' }, { 'content', 'content_type', 'file_id', 'created_by' })
+
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/setcaptcha.lua b/src/plugins/admin/setcaptcha.lua
new file mode 100644
index 0000000..8b74a6c
--- /dev/null
+++ b/src/plugins/admin/setcaptcha.lua
@@ -0,0 +1,65 @@
+--[[
+ 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 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\nUsage:\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')
+ 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
new file mode 100644
index 0000000..6340fb7
--- /dev/null
+++ b/src/plugins/admin/setgrouplang.lua
@@ -0,0 +1,120 @@
+--[[
+ 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))
+ 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.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'group_language',
+ value = lang_code
+ }, { 'chat_id', 'key' }, { 'value' })
+
+ ctx.db.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'force_group_language',
+ value = 'true'
+ }, { 'chat_id', 'key' }, { 'value' })
+
+ api.send_message(message.chat.id, string.format('Group language set to <b>%s</b>.', lang_code), '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.')
+ end
+
+ local lang_code = callback_query.data
+ if not lang_code then return end
+
+ ctx.db.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'group_language',
+ value = lang_code
+ }, { 'chat_id', 'key' }, { 'value' })
+
+ ctx.db.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'force_group_language',
+ value = 'true'
+ }, { 'chat_id', 'key' }, { 'value' })
+
+ -- Find language name
+ local lang_name = lang_code
+ for _, lang in ipairs(LANGUAGES) do
+ if lang.code == lang_code then
+ lang_name = lang.name
+ break
+ end
+ end
+
+ 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!')
+end
+
+return plugin
diff --git a/src/plugins/admin/setwelcome.lua b/src/plugins/admin/setwelcome.lua
new file mode 100644
index 0000000..de98c93
--- /dev/null
+++ b/src/plugins/admin/setwelcome.lua
@@ -0,0 +1,39 @@
+--[[
+ 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.execute(
+ 'SELECT message FROM welcome_messages WHERE chat_id = $1',
+ { 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')
+ end
+
+ if not message.args then
+ return api.send_message(message.chat.id, 'Please provide the welcome message text.\n\nPlaceholders: <code>$name</code>, <code>$title</code>, <code>$id</code>, <code>$username</code>, <code>$mention</code>', 'html')
+ end
+
+ ctx.db.upsert('welcome_messages', {
+ chat_id = message.chat.id,
+ message = message.args
+ }, { 'chat_id' }, { 'message' })
+
+ api.send_message(message.chat.id, 'The welcome message has been updated.')
+end
+
+return plugin
diff --git a/src/plugins/admin/staff.lua b/src/plugins/admin/staff.lua
new file mode 100644
index 0000000..9cbeac4
--- /dev/null
+++ b/src/plugins/admin/staff.lua
@@ -0,0 +1,77 @@
+--[[
+ 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.execute(
+ "SELECT user_id FROM chat_members WHERE chat_id = $1 AND role = 'moderator'",
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/tempban.lua b/src/plugins/admin/tempban.lua
new file mode 100644
index 0000000..2ffc115
--- /dev/null
+++ b/src/plugins/admin/tempban.lua
@@ -0,0 +1,88 @@
+--[[
+ 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)
+ if not success then
+ return api.send_message(message.chat.id, 'I don\'t have permission to ban users.')
+ end
+
+ pcall(function()
+ ctx.db.insert('bans', {
+ chat_id = message.chat.id, user_id = user_id,
+ banned_by = message.from.id, expires_at = 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/tempmute.lua b/src/plugins/admin/tempmute.lua
new file mode 100644
index 0000000..4def7bb
--- /dev/null
+++ b/src/plugins/admin/tempmute.lua
@@ -0,0 +1,78 @@
+--[[
+ 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)
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/triggers.lua b/src/plugins/admin/triggers.lua
new file mode 100644
index 0000000..ae7ff65
--- /dev/null
+++ b/src/plugins/admin/triggers.lua
@@ -0,0 +1,40 @@
+--[[
+ 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.execute(
+ 'SELECT id, pattern, response, created_by, created_at FROM triggers WHERE chat_id = $1 ORDER BY created_at',
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/trust.lua b/src/plugins/admin/trust.lua
new file mode 100644
index 0000000..9532808
--- /dev/null
+++ b/src/plugins/admin/trust.lua
@@ -0,0 +1,61 @@
+--[[
+ 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.upsert('chat_members', {
+ chat_id = message.chat.id,
+ user_id = user_id,
+ role = 'trusted'
+ }, { 'chat_id', 'user_id' }, { 'role' })
+
+ pcall(function()
+ ctx.db.insert('admin_actions', {
+ chat_id = message.chat.id,
+ admin_id = message.from.id,
+ target_id = user_id,
+ action = 'trust'
+ })
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/unban.lua b/src/plugins/admin/unban.lua
new file mode 100644
index 0000000..63d078a
--- /dev/null
+++ b/src/plugins/admin/unban.lua
@@ -0,0 +1,52 @@
+--[[
+ 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.insert('admin_actions', {
+ chat_id = message.chat.id, admin_id = message.from.id,
+ target_id = user_id, action = 'unban'
+ })
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/unfilter.lua b/src/plugins/admin/unfilter.lua
new file mode 100644
index 0000000..700eade
--- /dev/null
+++ b/src/plugins/admin/unfilter.lua
@@ -0,0 +1,66 @@
+--[[
+ 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.execute(
+ 'SELECT pattern, action FROM filters WHERE chat_id = $1 ORDER BY created_at',
+ { 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')
+ end
+
+ local pattern = message.args:match('^%s*(.-)%s*$')
+ local result = ctx.db.execute(
+ 'DELETE FROM filters WHERE chat_id = $1 AND pattern = $2',
+ { message.chat.id, pattern }
+ )
+
+ -- pgmoon returns the number of affected rows in the result
+ if result and result.affected_rows and tonumber(result.affected_rows) > 0 then
+ api.send_message(message.chat.id, string.format(
+ 'Filter <code>%s</code> has been removed.',
+ tools.escape_html(pattern)
+ ), 'html')
+ else
+ -- Try by index number
+ local index = tonumber(pattern)
+ if index then
+ local filters = ctx.db.execute(
+ 'SELECT id, pattern FROM filters WHERE chat_id = $1 ORDER BY created_at',
+ { message.chat.id }
+ )
+ if filters and filters[index] then
+ ctx.db.execute('DELETE FROM filters WHERE id = $1', { 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')
+ 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
new file mode 100644
index 0000000..a9af102
--- /dev/null
+++ b/src/plugins/admin/unmute.lua
@@ -0,0 +1,58 @@
+--[[
+ 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
+ }
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/untrust.lua b/src/plugins/admin/untrust.lua
new file mode 100644
index 0000000..49f60c5
--- /dev/null
+++ b/src/plugins/admin/untrust.lua
@@ -0,0 +1,59 @@
+--[[
+ 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.execute(
+ "UPDATE chat_members SET role = 'member' WHERE chat_id = $1 AND user_id = $2",
+ { message.chat.id, user_id }
+ )
+
+ pcall(function()
+ ctx.db.insert('admin_actions', {
+ chat_id = message.chat.id,
+ admin_id = message.from.id,
+ target_id = user_id,
+ action = 'untrust'
+ })
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/admin/warn.lua b/src/plugins/admin/warn.lua
new file mode 100644
index 0000000..d099a49
--- /dev/null
+++ b/src/plugins/admin/warn.lua
@@ -0,0 +1,137 @@
+--[[
+ 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.insert('warnings', {
+ chat_id = message.chat.id, user_id = user_id,
+ warned_by = message.from.id, reason = reason
+ })
+ ctx.db.insert('admin_actions', {
+ chat_id = message.chat.id, admin_id = message.from.id,
+ target_id = user_id, action = 'warn', reason = 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)
+ 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.')
+ 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')
+
+ 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.')
+ 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!')
+ 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')
+ end
+end
+
+return plugin
diff --git a/src/plugins/admin/wordfilter.lua b/src/plugins/admin/wordfilter.lua
new file mode 100644
index 0000000..e9cea85
--- /dev/null
+++ b/src/plugins/admin/wordfilter.lua
@@ -0,0 +1,111 @@
+--[[
+ 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.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'wordfilter_enabled'",
+ { message.chat.id }
+ )
+ local status = (enabled and #enabled > 0 and enabled[1].value == 'true') and 'enabled' or 'disabled'
+ return api.send_message(message.chat.id, string.format(
+ 'Word filter is currently <b>%s</b>.\nUsage: /wordfilter <on|off>', status
+ ), 'html')
+ end
+
+ local arg = message.args:lower()
+ if arg == 'on' or arg == 'enable' then
+ ctx.db.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'wordfilter_enabled',
+ value = 'true'
+ }, { 'chat_id', 'key' }, { 'value' })
+ 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.upsert('chat_settings', {
+ chat_id = message.chat.id,
+ key = 'wordfilter_enabled',
+ value = 'false'
+ }, { 'chat_id', 'key' }, { 'value' })
+ require('src.core.session').invalidate_setting(message.chat.id, 'wordfilter_enabled')
+ return api.send_message(message.chat.id, 'Word filter has been disabled.')
+ else
+ return api.send_message(message.chat.id, 'Usage: /wordfilter <on|off>')
+ end
+end
+
+function plugin.on_new_message(api, message, ctx)
+ if not ctx.is_group or not message.text or message.text == '' then return end
+ if ctx.is_admin or ctx.is_global_admin then return end
+ if not require('src.core.permissions').can_delete(api, message.chat.id) then return end
+
+ -- Check if wordfilter is enabled (cached)
+ local session = require('src.core.session')
+ local enabled = session.get_cached_setting(message.chat.id, 'wordfilter_enabled', function()
+ local result = ctx.db.execute(
+ "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'wordfilter_enabled'",
+ { message.chat.id }
+ )
+ 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.execute(
+ 'SELECT pattern, action FROM filters WHERE chat_id = $1',
+ { message.chat.id }
+ )
+ end, 300)
+ if not filters or #filters == 0 then return end
+
+ local text = message.text:lower()
+ for _, f in ipairs(filters) do
+ local match = pcall(function()
+ return text:match(f.pattern:lower())
+ end)
+ if match and text:match(f.pattern:lower()) then
+ -- Execute action
+ if f.action == 'delete' then
+ api.delete_message(message.chat.id, message.message_id)
+ elseif f.action == 'warn' then
+ api.delete_message(message.chat.id, message.message_id)
+ local hash = string.format('chat:%s:%s', message.chat.id, message.from.id)
+ ctx.redis.hincrby(hash, 'warnings', 1)
+ api.send_message(message.chat.id, string.format(
+ '<a href="tg://user?id=%d">%s</a> has been warned for using a filtered word.',
+ message.from.id, require('telegram-bot-lua.tools').escape_html(message.from.first_name)
+ ), 'html')
+ elseif f.action == 'ban' then
+ api.delete_message(message.chat.id, message.message_id)
+ api.ban_chat_member(message.chat.id, message.from.id)
+ elseif f.action == 'kick' then
+ api.delete_message(message.chat.id, message.message_id)
+ api.ban_chat_member(message.chat.id, message.from.id)
+ api.unban_chat_member(message.chat.id, message.from.id)
+ elseif f.action == 'mute' then
+ api.delete_message(message.chat.id, message.message_id)
+ api.restrict_chat_member(message.chat.id, message.from.id, os.time() + 3600, {
+ can_send_messages = false
+ })
+ end
+ return
+ end
+ end
+end
+
+return plugin
diff --git a/src/plugins/ai/ai.lua b/src/plugins/ai/ai.lua
new file mode 100644
index 0000000..854350c
--- /dev/null
+++ b/src/plugins/ai/ai.lua
@@ -0,0 +1,260 @@
+--[[
+ 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 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)
+ }
+ })
+
+ if not res or code ~= 200 then
+ return nil, 'OpenAI API request failed (HTTP ' .. tostring(code) .. ').'
+ end
+
+ local data = json.decode(table.concat(response_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 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)
+ }
+ })
+
+ if not res or code ~= 200 then
+ return nil, 'Anthropic API request failed (HTTP ' .. tostring(code) .. ').'
+ end
+
+ local data = json.decode(table.concat(response_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')
+ 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)
+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, err = 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)
+end
+
+return plugin
diff --git a/src/plugins/ai/init.lua b/src/plugins/ai/init.lua
new file mode 100644
index 0000000..9da0700
--- /dev/null
+++ b/src/plugins/ai/init.lua
@@ -0,0 +1,9 @@
+--[[
+ mattata v2.0 - AI Plugin Category
+]]
+
+return {
+ plugins = {
+ 'ai'
+ }
+}
diff --git a/src/plugins/fun/aesthetic.lua b/src/plugins/fun/aesthetic.lua
new file mode 100644
index 0000000..82e63bc
--- /dev/null
+++ b/src/plugins/fun/aesthetic.lua
@@ -0,0 +1,51 @@
+--[[
+ mattata v2.0 - Aesthetic Plugin
+ Converts ASCII text to fullwidth Unicode characters (vaporwave style).
+]]
+
+local plugin = {}
+plugin.name = 'aesthetic'
+plugin.category = 'fun'
+plugin.description = 'Convert text to fullwidth aesthetic characters'
+plugin.commands = { 'aesthetic', 'fullwidth', 'fw' }
+plugin.help = '/aesthetic <text> - Convert text to fullwidth vaporwave text. Use in reply to convert the replied message.'
+
+-- Fullwidth characters start at U+FF01 for '!' (0x21) through U+FF5E for '~' (0x7E).
+-- Space (0x20) maps to ideographic space U+3000.
+local function to_fullwidth(text)
+ local result = {}
+ for i = 1, #text do
+ local byte = text:byte(i)
+ if byte == 0x20 then
+ -- ASCII space -> ideographic space U+3000
+ table.insert(result, '\xE3\x80\x80')
+ elseif byte >= 0x21 and byte <= 0x7E then
+ -- ASCII printable -> fullwidth equivalent
+ -- U+FF01 + (byte - 0x21)
+ local codepoint = 0xFF01 + (byte - 0x21)
+ -- Encode as UTF-8 (3-byte sequence for U+FF01..U+FF5E)
+ local b1 = 0xE0 + math.floor(codepoint / 4096)
+ local b2 = 0x80 + math.floor((codepoint % 4096) / 64)
+ local b3 = 0x80 + (codepoint % 64)
+ table.insert(result, string.char(b1, b2, b3))
+ else
+ table.insert(result, string.char(byte))
+ end
+ end
+ return table.concat(result)
+end
+
+function plugin.on_message(api, message, ctx)
+ local input
+ if message.reply and message.reply.text and message.reply.text ~= '' then
+ input = message.reply.text
+ elseif message.args and message.args ~= '' then
+ input = message.args
+ else
+ return api.send_message(message.chat.id, 'Please provide some text, or use this command in reply to a message.')
+ end
+ local output = to_fullwidth(input)
+ return api.send_message(message.chat.id, output)
+end
+
+return plugin
diff --git a/src/plugins/fun/catfact.lua b/src/plugins/fun/catfact.lua
new file mode 100644
index 0000000..ba265c5
--- /dev/null
+++ b/src/plugins/fun/catfact.lua
@@ -0,0 +1,42 @@
+--[[
+ 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 response_body = {}
+ local res, code = https.request({
+ url = 'https://catfact.ninja/fact',
+ method = 'GET',
+ headers = {
+ ['Accept'] = 'application/json'
+ },
+ sink = ltn12.sink.table(response_body)
+ })
+
+ if not res or code ~= 200 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, _, err = 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')
+end
+
+return plugin
diff --git a/src/plugins/fun/copypasta.lua b/src/plugins/fun/copypasta.lua
new file mode 100644
index 0000000..e3f9b03
--- /dev/null
+++ b/src/plugins/fun/copypasta.lua
@@ -0,0 +1,78 @@
+--[[
+ mattata v2.0 - Copypasta Plugin
+ Adds random emoji throughout text for that classic copypasta feel.
+]]
+
+local plugin = {}
+plugin.name = 'copypasta'
+plugin.category = 'fun'
+plugin.description = 'Add random emoji throughout text'
+plugin.commands = { 'copypasta', 'pasta' }
+plugin.help = '/copypasta <text> - Sprinkle random emoji into text. Use in reply to copypasta the replied message.'
+
+local EMOJIS = {
+ '\xF0\x9F\x98\x82', -- U+1F602 face with tears of joy
+ '\xF0\x9F\x98\xA4', -- U+1F624 face with steam from nose
+ '\xF0\x9F\x98\xA1', -- U+1F621 pouting face
+ '\xF0\x9F\x98\x8E', -- U+1F60E sunglasses face
+ '\xF0\x9F\x98\x8D', -- U+1F60D heart eyes
+ '\xF0\x9F\x98\xAD', -- U+1F62D loudly crying face
+ '\xF0\x9F\x98\xB1', -- U+1F631 face screaming in fear
+ '\xF0\x9F\x98\xB3', -- U+1F633 flushed face
+ '\xF0\x9F\x98\xA9', -- U+1F629 weary face
+ '\xF0\x9F\x98\x8F', -- U+1F60F smirking face
+ '\xF0\x9F\x98\x9C', -- U+1F61C winking face with tongue
+ '\xF0\x9F\x94\xA5', -- U+1F525 fire
+ '\xF0\x9F\x92\xAF', -- U+1F4AF hundred points
+ '\xF0\x9F\x91\x8C', -- U+1F44C OK hand sign
+ '\xF0\x9F\x91\x80', -- U+1F440 eyes
+ '\xF0\x9F\x92\x80', -- U+1F480 skull
+ '\xF0\x9F\x92\xAA', -- U+1F4AA flexed biceps
+ '\xF0\x9F\x99\x8F', -- U+1F64F folded hands
+ '\xE2\x9C\x8A', -- U+270A raised fist
+ '\xF0\x9F\x98\xA0', -- U+1F620 angry face
+ '\xF0\x9F\x98\x88', -- U+1F608 smiling face with horns
+ '\xE2\x9C\xA8', -- U+2728 sparkles
+ '\xF0\x9F\x92\x85', -- U+1F485 nail polish
+ '\xF0\x9F\x91\x8F', -- U+1F44F clapping hands
+ '\xF0\x9F\x98\xAB', -- U+1F62B tired face
+}
+
+local function random_emoji()
+ return EMOJIS[math.random(#EMOJIS)]
+end
+
+local function copypasta(text)
+ math.randomseed(os.time() + os.clock() * 1000)
+ local words = {}
+ for word in text:gmatch('%S+') do
+ table.insert(words, word:upper())
+ end
+ local result = {}
+ for i, word in ipairs(words) do
+ table.insert(result, word)
+ -- Add 1-3 random emoji after each word
+ local emoji_count = math.random(1, 3)
+ local emojis = {}
+ for _ = 1, emoji_count do
+ table.insert(emojis, random_emoji())
+ end
+ table.insert(result, table.concat(emojis, ''))
+ end
+ return table.concat(result, ' ')
+end
+
+function plugin.on_message(api, message, ctx)
+ local input
+ if message.reply and message.reply.text and message.reply.text ~= '' then
+ input = message.reply.text
+ elseif message.args and message.args ~= '' then
+ input = message.args
+ else
+ return api.send_message(message.chat.id, 'Please provide some text, or use this command in reply to a message.')
+ end
+ local output = copypasta(input)
+ return api.send_message(message.chat.id, output)
+end
+
+return plugin
diff --git a/src/plugins/fun/dice.lua b/src/plugins/fun/dice.lua
new file mode 100644
index 0000000..ed5d2d9
--- /dev/null
+++ b/src/plugins/fun/dice.lua
@@ -0,0 +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)
+end
+
+return plugin
diff --git a/src/plugins/fun/doge.lua b/src/plugins/fun/doge.lua
new file mode 100644
index 0000000..6fbbb85
--- /dev/null
+++ b/src/plugins/fun/doge.lua
@@ -0,0 +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')
+end
+
+return plugin
diff --git a/src/plugins/fun/fact.lua b/src/plugins/fun/fact.lua
new file mode 100644
index 0000000..3614c51
--- /dev/null
+++ b/src/plugins/fun/fact.lua
@@ -0,0 +1,74 @@
+--[[
+ mattata v2.0 - Fact Plugin
+ Returns a random (fake) humorous "fact".
+]]
+
+local plugin = {}
+plugin.name = 'fact'
+plugin.category = 'fun'
+plugin.description = 'Get a random fake fact'
+plugin.commands = { 'fact' }
+plugin.help = '/fact - Get a random (definitely real) fact.'
+
+local FACTS = {
+ 'The average person walks the equivalent of five times around the Earth in a lifetime. The rest is by Uber.',
+ 'Honey never spoils. Archaeologists have found 3,000-year-old honey that still had a "best before" sticker on it.',
+ 'Bananas are berries, but strawberries aren\'t. Botanists are just chaotic people.',
+ 'A group of flamingos is called a "flamboyance." They named themselves.',
+ 'Octopuses have three hearts, which is two more than your ex.',
+ 'It takes 364 licks to get to the centre of a Tootsie Pop. Scientists counted instead of doing real science.',
+ 'The inventor of the Pringles can is buried in one. His family chose sour cream and onion.',
+ 'A jiffy is an actual unit of time: 1/100th of a second. So when someone says "be there in a jiffy," hold them to it.',
+ 'The original name for the butterfly was "flutterby." Someone had a speech impediment and it just stuck.',
+ 'Cows have best friends and get stressed when separated. They also have terrible taste in music.',
+ 'The dot over the letters "i" and "j" is called a "tittle." This is not one of the fake facts.',
+ 'The unicorn is the national animal of Scotland. They take their myths very seriously.',
+ 'Pigeons can do maths. They just choose not to because they don\'t have student loans.',
+ 'An octopus has a doughnut-shaped brain. Which explains why they look so confused all the time.',
+ 'Sloths can hold their breath longer than dolphins. They just can\'t be bothered to brag about it.',
+ 'Humans share 60% of their DNA with bananas. The other 40% is anxiety.',
+ 'A bolt of lightning contains enough energy to toast 100,000 slices of bread. Nobody has tested this.',
+ 'Astronauts grow up to 2 inches taller in space. NASA calls this a "stretch goal."',
+ 'There are more possible iterations of a game of chess than there are atoms in the observable universe. And you still lost.',
+ 'Cats can\'t taste sweetness. This explains their personality.',
+ 'The longest English word without a vowel is "rhythms." Welsh people consider this a short word.',
+ 'A cloud can weigh more than a million pounds. And yet it floats. Show-off.',
+ 'Sharks are older than trees. They also have much better dental records.',
+ 'Wombat poop is cube-shaped. Evolution is weird.',
+ 'A day on Venus is longer than a year on Venus. Scheduling meetings there is a nightmare.',
+ 'An ostrich\'s eye is bigger than its brain. This explains their life choices.',
+ 'Your brain uses 20% of your body\'s total energy. The other 80% goes to worrying about emails.',
+ 'The average person produces enough saliva in their lifetime to fill two swimming pools. You\'re welcome.',
+ 'A flock of crows is called a murder. They knew what they were doing when they picked that name.',
+ 'In Switzerland, it is illegal to own just one guinea pig. They get lonely. It\'s the law.',
+ 'Dolphins sleep with one eye open. Trust issues, apparently.',
+ 'The shortest war in history lasted 38 to 45 minutes. The losing side forgot to set their alarm.',
+ 'Cleopatra lived closer to the invention of the iPhone than to the building of the Great Pyramid. Time is weird.',
+ 'A snail can sleep for three years. Goals.',
+ 'Oxford University is older than the Aztec Empire. They still haven\'t updated the WiFi password.',
+ 'Sea otters hold hands while they sleep so they don\'t drift apart. They\'re better than most people.',
+ 'Vending machines are more deadly than sharks. Watch your back at the office.',
+ 'An average cumulus cloud weighs about 1.1 million pounds. And you thought you had a heavy week.',
+ 'Honey badgers can withstand bee stings, porcupine quills, and even snake venom. They just don\'t care.',
+ 'A group of pugs is called a "grumble." Accurate.',
+ 'The fingerprints of koalas are virtually indistinguishable from those of humans. Koalas could frame you for a crime.',
+ 'You can\'t hum while holding your nose. You just tried it.',
+ 'Turtles can breathe through their bums. Don\'t ask how they found this out.',
+ 'The inventor of the fire hydrant is unknown because the patent was destroyed in a fire. Irony at its finest.',
+ 'A teaspoon of neutron star weighs about 6 billion tonnes. Definitely not dishwasher safe.',
+ 'Cows produce more milk when listening to slow music. Heavy metal makes them nervous.',
+ 'It rains diamonds on Jupiter and Saturn. Estate agents there must be thrilled.',
+ 'Banging your head against a wall burns 150 calories an hour. This is not medical advice.',
+ 'The Twitter bird\'s official name is Larry. He\'s doing fine.',
+ 'There\'s a species of jellyfish that is immortal. It still can\'t get a mortgage though.',
+ 'If you lift a kangaroo\'s tail off the ground, it can\'t hop. This is a terrible party trick.',
+ 'The average person will spend six months of their life waiting for red lights to turn green. The other half is spent refreshing social media.',
+}
+
+function plugin.on_message(api, message, ctx)
+ math.randomseed(os.time() + os.clock() * 1000)
+ local fact = FACTS[math.random(#FACTS)]
+ return api.send_message(message.chat.id, fact)
+end
+
+return plugin
diff --git a/src/plugins/fun/flip.lua b/src/plugins/fun/flip.lua
new file mode 100644
index 0000000..6c5e512
--- /dev/null
+++ b/src/plugins/fun/flip.lua
@@ -0,0 +1,65 @@
+--[[
+ mattata v2.0 - Flip Plugin
+ Reverse and flip text using upside-down Unicode characters.
+]]
+
+local plugin = {}
+plugin.name = 'flip'
+plugin.category = 'fun'
+plugin.description = 'Flip text upside down'
+plugin.commands = { 'flip', 'reverse' }
+plugin.help = '/flip <text> - Flip text upside down. Use in reply to flip the replied message.'
+
+local FLIP_MAP = {
+ ['a'] = '\xC9\x90', ['b'] = 'q', ['c'] = '\xC9\x94', ['d'] = 'p',
+ ['e'] = '\xC7\x9D', ['f'] = '\xC9\x9F', ['g'] = '\xC6\x83', ['h'] = '\xC9\xA5',
+ ['i'] = '\xE1\xB4\x89', ['j'] = '\xC9\xBE', ['k'] = '\xCA\x9E', ['l'] = 'l',
+ ['m'] = '\xC9\xAF', ['n'] = 'u', ['o'] = 'o', ['p'] = 'd',
+ ['q'] = 'b', ['r'] = '\xC9\xB9', ['s'] = 's', ['t'] = '\xCA\x87',
+ ['u'] = 'n', ['v'] = '\xCA\x8C', ['w'] = '\xCA\x8D', ['x'] = 'x',
+ ['y'] = '\xCA\x8E', ['z'] = 'z',
+ ['A'] = '\xE2\x88\x80', ['B'] = '\xF0\x9D\x99\xB1', ['C'] = '\xC6\x86', ['D'] = '\xE1\x97\xA1',
+ ['E'] = '\xC6\x8E', ['F'] = '\xE2\x84\xB2', ['G'] = '\xE2\x85\x81', ['H'] = 'H',
+ ['I'] = 'I', ['J'] = '\xC5\xBF', ['K'] = '\xE2\x8B\x8A', ['L'] = '\xCB\xA5',
+ ['M'] = 'W', ['N'] = 'N', ['O'] = 'O', ['P'] = '\xC6\x8A',
+ ['Q'] = '\xD2\x8C', ['R'] = '\xCA\x81', ['S'] = 'S', ['T'] = '\xE2\x8A\xA5',
+ ['U'] = '\xE2\x88\xA9', ['V'] = '\xCE\x9B', ['W'] = 'M', ['X'] = 'X',
+ ['Y'] = '\xE2\x85\x84', ['Z'] = 'Z',
+ ['1'] = '\xC6\x96', ['2'] = '\xE1\x84\x85', ['3'] = '\xC6\x90', ['4'] = '\xE1\x84\x8D',
+ ['5'] = '\xC7\x82', ['6'] = '9', ['7'] = '\xE1\x84\x82', ['8'] = '8',
+ ['9'] = '6', ['0'] = '0',
+ ['.'] = '\xCB\x99', [','] = '\xCA\xBB', ['?'] = '\xC2\xBF', ['!'] = '\xC2\xA1',
+ ['\''] = ',', ['"'] = ',,', ['('] = ')', [')'] = '(',
+ ['['] = ']', [']'] = '[', ['{'] = '}', ['}'] = '{',
+ ['<'] = '>', ['>'] = '<', ['_'] = '\xE2\x80\xBE', [';'] = '\xD8\x9B',
+ ['&'] = '\xE2\x85\x8B',
+}
+
+local function flip_text(text)
+ local chars = {}
+ -- Iterate through UTF-8 characters
+ for char in text:gmatch('.') do
+ table.insert(chars, FLIP_MAP[char] or char)
+ end
+ -- Reverse the order
+ local reversed = {}
+ for i = #chars, 1, -1 do
+ table.insert(reversed, chars[i])
+ end
+ return table.concat(reversed)
+end
+
+function plugin.on_message(api, message, ctx)
+ local input
+ if message.reply and message.reply.text and message.reply.text ~= '' then
+ input = message.reply.text
+ elseif message.args and message.args ~= '' then
+ input = message.args
+ else
+ return api.send_message(message.chat.id, 'Please provide some text to flip, or use this command in reply to a message.')
+ end
+ local output = flip_text(input)
+ return api.send_message(message.chat.id, output)
+end
+
+return plugin
diff --git a/src/plugins/fun/game.lua b/src/plugins/fun/game.lua
new file mode 100644
index 0000000..e7048f5
--- /dev/null
+++ b/src/plugins/fun/game.lua
@@ -0,0 +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)
+ 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.')
+ 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.')
+ end
+
+ local game_state, _, err = json.decode(raw)
+ if not game_state then
+ return api.answer_callback_query(callback_query.id, '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.')
+ 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.')
+ end
+ return api.answer_callback_query(callback_query.id, '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.')
+ 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)
+ 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)
+end
+
+return plugin
diff --git a/src/plugins/fun/init.lua b/src/plugins/fun/init.lua
new file mode 100644
index 0000000..1090021
--- /dev/null
+++ b/src/plugins/fun/init.lua
@@ -0,0 +1,21 @@
+--[[
+ mattata v2.0 - Fun Plugin Category
+]]
+
+return {
+ plugins = {
+ 'slap',
+ 'dice',
+ 'flip',
+ 'mock',
+ 'aesthetic',
+ 'doge',
+ 'copypasta',
+ 'pun',
+ 'fact',
+ 'catfact',
+ 'inspirobot',
+ 'quote',
+ 'game'
+ }
+}
diff --git a/src/plugins/fun/inspirobot.lua b/src/plugins/fun/inspirobot.lua
new file mode 100644
index 0000000..c56829a
--- /dev/null
+++ b/src/plugins/fun/inspirobot.lua
@@ -0,0 +1,36 @@
+--[[
+ 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 response_body = {}
+ local res, code = https.request({
+ url = 'https://inspirobot.me/api?generate=true',
+ method = 'GET',
+ sink = ltn12.sink.table(response_body)
+ })
+
+ if not res or 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+', '')
+ 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)
+end
+
+return plugin
diff --git a/src/plugins/fun/mock.lua b/src/plugins/fun/mock.lua
new file mode 100644
index 0000000..75bcf84
--- /dev/null
+++ b/src/plugins/fun/mock.lua
@@ -0,0 +1,44 @@
+--[[
+ mattata v2.0 - Mock Plugin
+ SpOnGeBoB mOcKiNg TeXt generator.
+]]
+
+local plugin = {}
+plugin.name = 'mock'
+plugin.category = 'fun'
+plugin.description = 'Generate SpOnGeBoB mOcKiNg text'
+plugin.commands = { 'mock' }
+plugin.help = '/mock <text> - Convert text to mOcKiNg CaSe. Use in reply to mock the replied message.'
+
+local function mockify(text)
+ local result = {}
+ local i = 0
+ for char in text:gmatch('.') do
+ if char:match('%a') then
+ i = i + 1
+ if i % 2 == 0 then
+ table.insert(result, char:upper())
+ else
+ table.insert(result, char:lower())
+ end
+ else
+ table.insert(result, char)
+ end
+ end
+ return table.concat(result)
+end
+
+function plugin.on_message(api, message, ctx)
+ local input
+ if message.reply and message.reply.text and message.reply.text ~= '' then
+ input = message.reply.text
+ elseif message.args and message.args ~= '' then
+ input = message.args
+ else
+ return api.send_message(message.chat.id, 'Please provide some text to mock, or use this command in reply to a message.')
+ end
+ local output = mockify(input)
+ return api.send_message(message.chat.id, output)
+end
+
+return plugin
diff --git a/src/plugins/fun/pun.lua b/src/plugins/fun/pun.lua
new file mode 100644
index 0000000..f9e3ceb
--- /dev/null
+++ b/src/plugins/fun/pun.lua
@@ -0,0 +1,75 @@
+--[[
+ mattata v2.0 - Pun Plugin
+ Returns a random pun from a curated list.
+]]
+
+local plugin = {}
+plugin.name = 'pun'
+plugin.category = 'fun'
+plugin.description = 'Get a random pun'
+plugin.commands = { 'pun' }
+plugin.help = '/pun - Get a random pun.'
+
+local PUNS = {
+ 'I\'m reading a book about anti-gravity. It\'s impossible to put down.',
+ 'I used to hate facial hair, but then it grew on me.',
+ 'Did you hear about the claustrophobic astronaut? He just needed a little space.',
+ 'I\'m on a seafood diet. I see food and I eat it.',
+ 'Why don\'t scientists trust atoms? Because they make up everything.',
+ 'I told my wife she was drawing her eyebrows too high. She looked surprised.',
+ 'What do you call a fake noodle? An impasta.',
+ 'Why did the scarecrow win an award? He was outstanding in his field.',
+ 'I used to be a banker, but I lost interest.',
+ 'What do you call a bear with no teeth? A gummy bear.',
+ 'Why don\'t eggs tell jokes? They\'d crack each other up.',
+ 'I\'m afraid of elevators, so I\'m taking steps to avoid them.',
+ 'What do you call a dinosaur that crashes their car? Tyrannosaurus Wrecks.',
+ 'I got a job at a bakery because I kneaded dough.',
+ 'What do you call a sleeping dinosaur? A dino-snore.',
+ 'I used to play piano by ear, but now I use my hands.',
+ 'Why did the bicycle fall over? Because it was two-tired.',
+ 'What do you call a factory that makes okay products? A satisfactory.',
+ 'I tried to catch fog yesterday. Mist.',
+ 'What do you call a pile of cats? A meowtain.',
+ 'Why did the math book look so sad? Because it had too many problems.',
+ 'What do you call a belt made of watches? A waist of time.',
+ 'I don\'t trust stairs. They\'re always up to something.',
+ 'What do you call a can opener that doesn\'t work? A can\'t opener.',
+ 'Why couldn\'t the bicycle stand up by itself? It was two-tired.',
+ 'What did the ocean say to the beach? Nothing, it just waved.',
+ 'Why do cows have hooves instead of feet? Because they lactose.',
+ 'I once ate a clock. It was very time-consuming.',
+ 'What do you call a fish without eyes? A fsh.',
+ 'Why did the golfer bring two pairs of pants? In case he got a hole in one.',
+ 'What do you call a dog that does magic? A Labracadabrador.',
+ 'I used to be addicted to soap, but I\'m clean now.',
+ 'What do you call a bear in the rain? A drizzly bear.',
+ 'Why don\'t some couples go to the gym? Because some relationships don\'t work out.',
+ 'What did the grape do when it got stepped on? It let out a little wine.',
+ 'I\'m terrified of lifts. I\'m going to start taking steps to avoid them.',
+ 'What do you call a cow with no legs? Ground beef.',
+ 'Why did the tomato turn red? Because it saw the salad dressing.',
+ 'What do you call an alligator wearing a vest? An investigator.',
+ 'I told a chemistry joke. There was no reaction.',
+ 'What do you call a snowman with a six-pack? An abdominal snowman.',
+ 'Why did the coffee file a police report? It got mugged.',
+ 'What do you call a boomerang that doesn\'t come back? A stick.',
+ 'I used to be a shoe salesman, till they gave me the boot.',
+ 'What do lawyers wear to court? Lawsuits.',
+ 'What do you get when you cross a snowman with a vampire? Frostbite.',
+ 'Why did the stadium get hot after the game? All the fans left.',
+ 'What do you call a deer with no eyes? No idea.',
+ 'I wanted to be a doctor, but I didn\'t have the patience.',
+ 'What did the fish say when it hit a wall? Dam.',
+ 'Why don\'t skeletons fight each other? They don\'t have the guts.',
+ 'What did the janitor say when he jumped out of the cupboard? Supplies!',
+ 'I just got a job at a calendar factory. I can\'t wait to get a few days off.',
+}
+
+function plugin.on_message(api, message, ctx)
+ math.randomseed(os.time() + os.clock() * 1000)
+ local pun = PUNS[math.random(#PUNS)]
+ return api.send_message(message.chat.id, pun)
+end
+
+return plugin
diff --git a/src/plugins/fun/quote.lua b/src/plugins/fun/quote.lua
new file mode 100644
index 0000000..2fd5eec
--- /dev/null
+++ b/src/plugins/fun/quote.lua
@@ -0,0 +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.'
+
+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
+ -- 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.')
+ 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.')
+ end
+
+ math.randomseed(os.time() + os.clock() * 1000)
+ local raw = quotes[math.random(#quotes)]
+ local quote, _, err = 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')
+end
+
+return plugin
diff --git a/src/plugins/fun/slap.lua b/src/plugins/fun/slap.lua
new file mode 100644
index 0000000..124d6da
--- /dev/null
+++ b/src/plugins/fun/slap.lua
@@ -0,0 +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')
+end
+
+return plugin
diff --git a/src/plugins/media/cats.lua b/src/plugins/media/cats.lua
new file mode 100644
index 0000000..a7c8d59
--- /dev/null
+++ b/src/plugins/media/cats.lua
@@ -0,0 +1,50 @@
+--[[
+ 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 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'
+ }
+ })
+
+ if not res or code ~= 200 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, _, err = 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
new file mode 100644
index 0000000..f587ba8
--- /dev/null
+++ b/src/plugins/media/gif.lua
@@ -0,0 +1,63 @@
+--[[
+ mattata v2.0 - GIF Plugin
+ Searches for GIFs using the Tenor API and sends them as animations.
+]]
+
+local plugin = {}
+plugin.name = 'gif'
+plugin.category = 'media'
+plugin.description = 'Search for GIFs using Tenor'
+plugin.commands = { 'gif', 'tenor' }
+plugin.help = '/gif <query> - Search for a GIF and send it.'
+
+local TENOR_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ'
+
+function plugin.on_message(api, message, ctx)
+ local https = require('ssl.https')
+ local json = require('dkjson')
+ local url = require('socket.url')
+ local ltn12 = require('ltn12')
+
+ if not message.args or message.args == '' then
+ return api.send_message(message.chat.id, 'Please specify a search query, e.g. <code>/gif funny cats</code>.', 'html')
+ end
+
+ local query = url.escape(message.args)
+ local api_url = string.format(
+ 'https://tenor.googleapis.com/v2/search?q=%s&key=%s&limit=1&media_filter=gif',
+ query, TENOR_KEY
+ )
+
+ local response_body = {}
+ local res, code = https.request({
+ url = api_url,
+ method = 'GET',
+ sink = ltn12.sink.table(response_body),
+ headers = {
+ ['Accept'] = 'application/json'
+ }
+ })
+
+ if not res or code ~= 200 then
+ return api.send_message(message.chat.id, 'Failed to search Tenor. Please try again later.')
+ end
+
+ local body = table.concat(response_body)
+ local data, _, err = 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
new file mode 100644
index 0000000..a471ff3
--- /dev/null
+++ b/src/plugins/media/init.lua
@@ -0,0 +1,14 @@
+--[[
+ mattata v2.0 - Media Plugin Category
+]]
+
+return {
+ plugins = {
+ 'cats',
+ 'gif',
+ 'youtube',
+ 'spotify',
+ 'itunes',
+ 'sticker'
+ }
+}
diff --git a/src/plugins/media/itunes.lua b/src/plugins/media/itunes.lua
new file mode 100644
index 0000000..65310d9
--- /dev/null
+++ b/src/plugins/media/itunes.lua
@@ -0,0 +1,93 @@
+--[[
+ 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 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')
+ 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'
+ }
+ })
+
+ if not res or code ~= 200 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, _, err = 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')
+ if success then
+ return success
+ end
+ end
+
+ -- Fallback to text-only
+ return api.send_message(message.chat.id, output, 'html', true)
+end
+
+return plugin
diff --git a/src/plugins/media/spotify.lua b/src/plugins/media/spotify.lua
new file mode 100644
index 0000000..be0e7c9
--- /dev/null
+++ b/src/plugins/media/spotify.lua
@@ -0,0 +1,153 @@
+--[[
+ 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 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)
+ }
+ })
+
+ if not res or code ~= 200 then
+ return nil, 'Failed to authenticate with Spotify.'
+ end
+
+ local data = json.decode(table.concat(response_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 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')
+ 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'
+ }
+ })
+
+ 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'
+ }
+ })
+ end
+
+ if not res or 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)
+end
+
+return plugin
diff --git a/src/plugins/media/sticker.lua b/src/plugins/media/sticker.lua
new file mode 100644
index 0000000..e452f96
--- /dev/null
+++ b/src/plugins/media/sticker.lua
@@ -0,0 +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')
+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')
+ 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')
+ 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')
+ 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
new file mode 100644
index 0000000..a8b4b7f
--- /dev/null
+++ b/src/plugins/media/youtube.lua
@@ -0,0 +1,105 @@
+--[[
+ 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 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')
+ 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'
+ }
+ })
+
+ if not res or code ~= 200 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, _, err = 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 views = 'N/A'
+ if stats_res and stats_code == 200 then
+ local stats_data = json.decode(table.concat(stats_body))
+ 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)
+end
+
+return plugin
diff --git a/src/plugins/utility/about.lua b/src/plugins/utility/about.lua
new file mode 100644
index 0000000..27a5893
--- /dev/null
+++ b/src/plugins/utility/about.lua
@@ -0,0 +1,23 @@
+--[[
+ 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 tools = require('telegram-bot-lua.tools')
+ local config = require('src.core.config')
+ 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
+ )
+ return api.send_message(message.chat.id, output, 'html')
+end
+
+return plugin
diff --git a/src/plugins/utility/afk.lua b/src/plugins/utility/afk.lua
new file mode 100644
index 0000000..00ea42c
--- /dev/null
+++ b/src/plugins/utility/afk.lua
@@ -0,0 +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')
+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 not (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')
+ 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')
+ end
+ end
+ end
+ end
+end
+
+return plugin
diff --git a/src/plugins/utility/base64.lua b/src/plugins/utility/base64.lua
new file mode 100644
index 0000000..302e96f
--- /dev/null
+++ b/src/plugins/utility/base64.lua
@@ -0,0 +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'
+ )
+ 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'
+ )
+ end
+end
+
+return plugin
diff --git a/src/plugins/utility/calc.lua b/src/plugins/utility/calc.lua
new file mode 100644
index 0000000..f38ed30
--- /dev/null
+++ b/src/plugins/utility/calc.lua
@@ -0,0 +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 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)
+ 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'
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/commandstats.lua b/src/plugins/utility/commandstats.lua
new file mode 100644
index 0000000..31ffd24
--- /dev/null
+++ b/src/plugins/utility/commandstats.lua
@@ -0,0 +1,62 @@
+--[[
+ 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.execute(
+ 'DELETE FROM command_stats WHERE chat_id = $1',
+ { 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.execute(
+ [[SELECT command, SUM(use_count) AS total
+ FROM command_stats
+ WHERE chat_id = $1
+ GROUP BY command
+ ORDER BY total DESC
+ LIMIT 10]],
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/currency.lua b/src/plugins/utility/currency.lua
new file mode 100644
index 0000000..a49bbb4
--- /dev/null
+++ b/src/plugins/utility/currency.lua
@@ -0,0 +1,157 @@
+--[[
+ 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 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))
+ if not data then
+ return nil, 'Failed to parse conversion response.'
+ 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 get_supported_currencies()
+ local body = {}
+ local _, code = https.request({
+ url = 'https://api.frankfurter.app/currencies',
+ sink = ltn12.sink.table(body)
+ })
+ if code ~= 200 then return nil end
+ return json.decode(table.concat(body))
+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'
+ )
+ 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'
+ )
+ 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'
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/github.lua b/src/plugins/utility/github.lua
new file mode 100644
index 0000000..dbff37a
--- /dev/null
+++ b/src/plugins/utility/github.lua
@@ -0,0 +1,82 @@
+--[[
+ 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 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'
+ }
+ })
+ if not body or status ~= 200 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)
+end
+
+return plugin
diff --git a/src/plugins/utility/help.lua b/src/plugins/utility/help.lua
new file mode 100644
index 0000000..15706c6
--- /dev/null
+++ b/src/plugins/utility/help.lua
@@ -0,0 +1,162 @@
+--[[
+ 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 bot_name = ctx.config.bot_name()
+ 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)
+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)
+
+ 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)
+
+ elseif data == 'links' then
+ local keyboard = api.inline_keyboard():row(
+ api.row():url_button('Development', 'https://t.me/mattataDev')
+ :url_button('Channel', 'https://t.me/mattata')
+ ):row(
+ api.row():url_button('GitHub', 'https://github.com/wrxck/mattata')
+ :url_button('Support', 'https://t.me/mattataSupport')
+ ):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)
+
+ 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.')
+ 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')
+ ):row(
+ api.row():callback_data_button('Back', 'help:back')
+ )
+ return api.edit_message_reply_markup(message.chat.id, message.message_id, nil, 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)
+
+ 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
new file mode 100644
index 0000000..fcd1988
--- /dev/null
+++ b/src/plugins/utility/id.lua
@@ -0,0 +1,62 @@
+--[[
+ mattata v2.0 - ID Plugin
+ Returns user/chat ID and information.
+]]
+
+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 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
+ end
+
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+end
+
+return plugin
diff --git a/src/plugins/utility/info.lua b/src/plugins/utility/info.lua
new file mode 100644
index 0000000..c5d58a5
--- /dev/null
+++ b/src/plugins/utility/info.lua
@@ -0,0 +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.query('SELECT COUNT(*) AS count FROM users')
+ local chat_count = ctx.db.query('SELECT COUNT(*) AS count FROM 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/init.lua b/src/plugins/utility/init.lua
new file mode 100644
index 0000000..60d7fc3
--- /dev/null
+++ b/src/plugins/utility/init.lua
@@ -0,0 +1,37 @@
+--[[
+ 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'
+ }
+}
diff --git a/src/plugins/utility/karma.lua b/src/plugins/utility/karma.lua
new file mode 100644
index 0000000..8750fb0
--- /dev/null
+++ b/src/plugins/utility/karma.lua
@@ -0,0 +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'
+ )
+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'
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/lastfm.lua b/src/plugins/utility/lastfm.lua
new file mode 100644
index 0000000..52c7ac1
--- /dev/null
+++ b/src/plugins/utility/lastfm.lua
@@ -0,0 +1,116 @@
+--[[
+ 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 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'
+ )
+ 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
+ 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
+ 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)
+end
+
+return plugin
diff --git a/src/plugins/utility/nick.lua b/src/plugins/utility/nick.lua
new file mode 100644
index 0000000..eb77417
--- /dev/null
+++ b/src/plugins/utility/nick.lua
@@ -0,0 +1,59 @@
+--[[
+ 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.execute(
+ 'SELECT nickname FROM users WHERE user_id = $1',
+ { 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'
+ )
+ 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.execute(
+ 'UPDATE users SET nickname = NULL WHERE user_id = $1',
+ { 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.execute(
+ 'UPDATE users SET nickname = $1 WHERE user_id = $2',
+ { input, message.from.id }
+ )
+ return api.send_message(
+ message.chat.id,
+ string.format('Your nickname has been set to: <b>%s</b>', tools.escape_html(input)),
+ 'html'
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/ping.lua b/src/plugins/utility/ping.lua
new file mode 100644
index 0000000..7ea8f71
--- /dev/null
+++ b/src/plugins/utility/ping.lua
@@ -0,0 +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')
+end
+
+return plugin
diff --git a/src/plugins/utility/plugins.lua b/src/plugins/utility/plugins.lua
new file mode 100644
index 0000000..96b0f77
--- /dev/null
+++ b/src/plugins/utility/plugins.lua
@@ -0,0 +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)
+ else
+ return api.send_message(chat_id, text, nil, true, false, nil, 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.')
+ 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.')
+ end
+ local target = loader.get_by_name(plugin_name)
+ if not target then
+ return api.answer_callback_query(callback_query.id, '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.')
+ else
+ ctx.session.disable_plugin(message.chat.id, plugin_name)
+ api.answer_callback_query(callback_query.id, 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
new file mode 100644
index 0000000..e09aeef
--- /dev/null
+++ b/src/plugins/utility/pokedex.lua
@@ -0,0 +1,109 @@
+--[[
+ 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 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
+ 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')
+ end
+
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
+end
+
+return plugin
diff --git a/src/plugins/utility/remind.lua b/src/plugins/utility/remind.lua
new file mode 100644
index 0000000..0f696c4
--- /dev/null
+++ b/src/plugins/utility/remind.lua
@@ -0,0 +1,272 @@
+--[[
+ 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')
+ 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\nDurations: <code>30m</code>, <code>2h</code>, <code>1d</code>, <code>2h30m</code>\nMax: 7 days. Max 4 reminders per chat.\n\nExamples:\n<code>/remind 30m check the oven</code>\n<code>/remind 2h30m meeting with John</code>\n<code>/remind 1d renew subscription</code>',
+ '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'
+ )
+ 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'
+ )
+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)
+ )
+ local ok, err = pcall(function()
+ api.send_message(tonumber(chat_id), output, 'html')
+ end)
+ end
+
+ -- Delete the reminder
+ redis.del(key)
+ end
+ end
+ end
+end
+
+return plugin
diff --git a/src/plugins/utility/search.lua b/src/plugins/utility/search.lua
new file mode 100644
index 0000000..ad5a91e
--- /dev/null
+++ b/src/plugins/utility/search.lua
@@ -0,0 +1,138 @@
+--[[
+ 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 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))
+ if not data then
+ return nil, 'Failed to parse search results.'
+ 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'
+ )
+ 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
+ )
+ end
+
+ return api.send_message(message.chat.id, output, 'html', true)
+end
+
+return plugin
diff --git a/src/plugins/utility/sed.lua b/src/plugins/utility/sed.lua
new file mode 100644
index 0000000..0a2e49e
--- /dev/null
+++ b/src/plugins/utility/sed.lua
@@ -0,0 +1,67 @@
+--[[
+ 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
+
+ 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'
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/setlang.lua b/src/plugins/utility/setlang.lua
new file mode 100644
index 0000000..a2656a5
--- /dev/null
+++ b/src/plugins/utility/setlang.lua
@@ -0,0 +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
+ )
+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.')
+ 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 .. '!')
+ return api.edit_message_text(
+ message.chat.id,
+ message.message_id,
+ string.format('Language set to <b>%s</b>.', name),
+ 'html'
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/setloc.lua b/src/plugins/utility/setloc.lua
new file mode 100644
index 0000000..d626631
--- /dev/null
+++ b/src/plugins/utility/setloc.lua
@@ -0,0 +1,82 @@
+--[[
+ 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 url = require('socket.url')
+
+ local input = message.args
+ if not input or input == '' then
+ -- Show current location
+ local result = ctx.db.execute(
+ 'SELECT latitude, longitude, address FROM user_locations WHERE user_id = $1',
+ { 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'
+ )
+ 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
+ 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.execute(
+ [[INSERT INTO user_locations (user_id, latitude, longitude, address, updated_at)
+ VALUES ($1, $2, $3, $4, NOW())
+ ON CONFLICT (user_id) DO UPDATE
+ SET latitude = $2, longitude = $3, address = $4, updated_at = NOW()]],
+ { 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'
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/share.lua b/src/plugins/utility/share.lua
new file mode 100644
index 0000000..8159212
--- /dev/null
+++ b/src/plugins/utility/share.lua
@@ -0,0 +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
+ )
+end
+
+return plugin
diff --git a/src/plugins/utility/statistics.lua b/src/plugins/utility/statistics.lua
new file mode 100644
index 0000000..a978c81
--- /dev/null
+++ b/src/plugins/utility/statistics.lua
@@ -0,0 +1,92 @@
+--[[
+ 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.execute(
+ 'DELETE FROM message_stats WHERE chat_id = $1',
+ { 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.execute(
+ [[SELECT ms.user_id, SUM(ms.message_count) AS total,
+ u.first_name, u.last_name, u.username
+ FROM message_stats ms
+ LEFT JOIN users u ON ms.user_id = u.user_id
+ WHERE ms.chat_id = $1
+ GROUP BY ms.user_id, u.first_name, u.last_name, u.username
+ ORDER BY total DESC
+ LIMIT 10]],
+ { 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.execute(
+ 'SELECT SUM(message_count) AS total FROM message_stats WHERE chat_id = $1',
+ { message.chat.id }
+ )
+ local unique_result = ctx.db.execute(
+ 'SELECT COUNT(DISTINCT user_id) AS total FROM message_stats WHERE chat_id = $1',
+ { 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/time.lua b/src/plugins/utility/time.lua
new file mode 100644
index 0000000..a0a4237
--- /dev/null
+++ b/src/plugins/utility/time.lua
@@ -0,0 +1,166 @@
+--[[
+ 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 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
+ return nil, 'Geocoding request failed.'
+ end
+ local data = json.decode(table.concat(body))
+ if not data or #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))
+ if not data or not data.timeZone then
+ return nil, 'Could not determine timezone for this location.'
+ 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.execute(
+ 'SELECT latitude, longitude, address FROM user_locations WHERE user_id = $1',
+ { 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'
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/translate.lua b/src/plugins/utility/translate.lua
new file mode 100644
index 0000000..bb70a18
--- /dev/null
+++ b/src/plugins/utility/translate.lua
@@ -0,0 +1,171 @@
+--[[
+ 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 json = require('dkjson')
+local url = require('socket.url')
+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)
+ })
+ 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))
+ 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
+
+local function detect_language(text)
+ local request_body = json.encode({ q = text })
+ local body = {}
+ local _, code = https.request({
+ url = BASE_URL .. '/detect',
+ method = 'POST',
+ headers = {
+ ['Content-Type'] = 'application/json',
+ ['Content-Length'] = tostring(#request_body)
+ },
+ source = ltn12.source.string(request_body),
+ sink = ltn12.sink.table(body)
+ })
+ if code ~= 200 then
+ return 'auto'
+ end
+ local data = json.decode(table.concat(body))
+ if data and data[1] and data[1].language then
+ return data[1].language
+ end
+ return 'auto'
+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'
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/urbandictionary.lua b/src/plugins/utility/urbandictionary.lua
new file mode 100644
index 0000000..2c26bdd
--- /dev/null
+++ b/src/plugins/utility/urbandictionary.lua
@@ -0,0 +1,72 @@
+--[[
+ 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 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
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/weather.lua b/src/plugins/utility/weather.lua
new file mode 100644
index 0000000..78c34cb
--- /dev/null
+++ b/src/plugins/utility/weather.lua
@@ -0,0 +1,168 @@
+--[[
+ 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.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 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
+ return nil, 'Geocoding request failed.'
+ end
+ local data = json.decode(table.concat(body))
+ if not data or #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))
+ if not data or not data.current then
+ return nil, 'Failed to parse weather data.'
+ 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.execute(
+ 'SELECT latitude, longitude, address FROM user_locations WHERE user_id = $1',
+ { 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'
+ )
+ 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')
+end
+
+return plugin
diff --git a/src/plugins/utility/wikipedia.lua b/src/plugins/utility/wikipedia.lua
new file mode 100644
index 0000000..18fddd6
--- /dev/null
+++ b/src/plugins/utility/wikipedia.lua
@@ -0,0 +1,149 @@
+--[[
+ 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 url = require('socket.url')
+local ltn12 = require('ltn12')
+local tools = require('telegram-bot-lua.tools')
+
+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
+ 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
+ return search_wikipedia_fallback(query, lang)
+ end
+ return data
+end
+
+function search_wikipedia_fallback(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
+ 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
+ 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))
+ if not summary then
+ return nil, 'Failed to parse 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'
+ )
+ 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)
+ 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)
+end
+
+return plugin
diff --git a/src/plugins/utility/xkcd.lua b/src/plugins/utility/xkcd.lua
new file mode 100644
index 0000000..a27a1ac
--- /dev/null
+++ b/src/plugins/utility/xkcd.lua
@@ -0,0 +1,68 @@
+--[[
+ 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 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
+ 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
+ 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)
+ end
+
+ return api.send_message(message.chat.id, caption, 'html')
+end
+
+return plugin

File Metadata

Mime Type
text/x-diff
Expires
Sun, May 17, 4:41 AM (1 d, 21 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
62966
Default Alt Text
(711 KB)

Event Timeline