Page Menu
Home
Phabricator (Chris)
Search
Configure Global Search
Log In
Files
F119033
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
141 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/.env.example b/.env.example
index 89fbdb1..8c09d47 100644
--- a/.env.example
+++ b/.env.example
@@ -1,53 +1,55 @@
# mattata v2.2 Configuration
# Copy to .env and fill in required values
# Required
BOT_TOKEN=
BOT_ADMINS=221714512
BOT_NAME=mattata
# PostgreSQL (primary database)
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=mattata
DATABASE_USER=mattata
DATABASE_PASSWORD=changeme
# Redis (cache/sessions only)
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Mode: polling (default) or webhook
WEBHOOK_ENABLED=false
WEBHOOK_URL=
WEBHOOK_PORT=8443
WEBHOOK_SECRET=
POLLING_TIMEOUT=60
POLLING_LIMIT=100
# AI (disabled by default)
AI_ENABLED=false
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
# Optional API keys (core features work without any of these)
LASTFM_API_KEY=
YOUTUBE_API_KEY=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPAMWATCH_TOKEN=
TENOR_API_KEY=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
# Bot links (optional, defaults shown)
CHANNEL_URL=https://t.me/mattata
SUPPORT_URL=https://t.me/mattataSupport
GITHUB_URL=https://github.com/wrxck/mattata
DEV_URL=https://t.me/mattataDev
# Logging
LOG_CHAT=
DEBUG=false
diff --git a/README.md b/README.md
index 6e74f3a..2507db7 100644
--- a/README.md
+++ b/README.md
@@ -1,140 +1,185 @@
# 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 with built-in or custom captcha
- **Anti-Spam** - Rate limiting, word filters, link filtering, auto-delete
- **100+ Plugins** - Weather, translate, search, currency, Wikipedia, AI chat, and more
- **Async HTTP** - Non-blocking HTTP client for plugin API calls via copas
- **Stored Procedures** - All database operations use PostgreSQL stored procedures
- **Forum Topics** - Full support for forum topic management and slow mode
- **Reaction Karma** - Track karma via message reactions
- **RSS Feeds** - Subscribe to and monitor RSS/Atom feeds
- **Scheduled Messages** - Queue messages for future delivery
- **Inline Queries** - Inline search and sharing across chats
- **QR Codes** - Generate QR codes from text
- **Multi-Language** - 10 language packs included (EN, DE, AR, PL, PT, TR, Scottish)
- **PostgreSQL + Redis** - PostgreSQL for persistent data, Redis for caching
- **Hot-Reloadable Plugins** - Reload plugins without restarting the bot
- **Docker Ready** - One command deployment with Docker Compose
## Quick Start (Docker)
```bash
cp .env.example .env
# Edit .env and set BOT_TOKEN
docker compose up -d
```
## Quick Start (Manual)
### Prerequisites
- Lua 5.3+
- LuaRocks
- PostgreSQL 14+
- Redis 7+
### Installation
```bash
# Install Lua dependencies
luarocks install telegram-bot-lua
luarocks install pgmoon
luarocks install redis-lua
luarocks install dkjson
luarocks install luautf8
# Configure
cp .env.example .env
# Edit .env with your settings
# Start
lua main.lua
```
+## Upgrading from v1.5
+
+If you are upgrading from mattata v1.5, the migration is automatic. On first boot, mattata v2 detects v1.5 data and imports it into the new schema.
+
+### What gets migrated
+
+- **Chat settings** (antilink, welcome, captcha, max warnings, etc.)
+- **Welcome messages**
+- **Rules**
+- **Warnings** (per-user warning counts)
+- **Disabled plugins** (with name mapping to v2 plugin names)
+- **Filters** (word filters with actions)
+- **Triggers** (auto-responses)
+- **Bans** (group blocklist entries)
+- **Configuration** (`configuration.lua` converted to `.env` format)
+
+### How it works
+
+1. On startup, mattata scans Redis for v1-era key patterns and checks for `configuration.lua`
+2. If v1 data is found, it imports everything into PostgreSQL via a single transaction
+3. The migration is recorded in `schema_migrations` so it only runs once
+4. After success, v1 Redis keys are cleaned up and `configuration.lua` is renamed to `configuration.lua.v1.bak`
+
+### Prerequisites
+
+- Your v1.5 Redis instance must still be accessible (same host/port/db)
+- PostgreSQL must be running and configured in `.env` (or the migration will create `.env` from `configuration.lua`)
+
+### Config mapping reference
+
+| v1 (`configuration.lua`) | v2 (`.env`) |
+|---------------------------|-------------|
+| `bot_token` | `BOT_TOKEN` |
+| `admins` | `BOT_ADMINS` |
+| `redis.host` | `REDIS_HOST` |
+| `redis.port` | `REDIS_PORT` |
+| `redis.password` | `REDIS_PASSWORD` |
+| `keys.lastfm` | `LASTFM_API_KEY` |
+| `keys.youtube` | `YOUTUBE_API_KEY` |
+| `keys.weather` | `OPENWEATHERMAP_API_KEY` |
+| `keys.spotify_client_id` | `SPOTIFY_CLIENT_ID` |
+| `keys.spotify_client_secret` | `SPOTIFY_CLIENT_SECRET` |
+| `keys.spamwatch` | `SPAMWATCH_TOKEN` |
+| `log_channel` / `log_chat` | `LOG_CHAT` |
+
## Configuration
All configuration is managed through environment variables. See `.env.example` for the full reference.
### Required
| Variable | Description |
|----------|-------------|
| `BOT_TOKEN` | Telegram Bot API token from @BotFather |
### Optional API Keys
| Variable | Description | Used By |
|----------|-------------|---------|
| `LASTFM_API_KEY` | Last.fm API key | `/lastfm`, `/np` |
| `YOUTUBE_API_KEY` | YouTube Data API v3 key | `/youtube` |
| `SPOTIFY_CLIENT_ID` | Spotify app client ID | `/spotify` |
| `SPOTIFY_CLIENT_SECRET` | Spotify app client secret | `/spotify` |
| `SPAMWATCH_TOKEN` | SpamWatch API token | Anti-spam |
| `OPENAI_API_KEY` | OpenAI API key | `/ai` |
| `ANTHROPIC_API_KEY` | Anthropic API key | `/ai` |
## Architecture
```
mattata/
├── main.lua # Entry point
├── src/
│ ├── core/ # Framework modules
│ │ ├── config.lua # .env configuration reader
│ │ ├── loader.lua # Plugin discovery & hot-reload
│ │ ├── router.lua # Event dispatch
│ │ ├── middleware.lua # Middleware pipeline
│ │ ├── database.lua # PostgreSQL (pgmoon)
│ │ ├── redis.lua # Redis connection
│ │ ├── http.lua # Async HTTP client (copas)
│ │ ├── permissions.lua # Admin/mod/trusted checks
│ │ ├── session.lua # Redis session/cache management
│ │ ├── i18n.lua # Language manager
│ │ └── logger.lua # Structured logging
│ ├── middleware/ # Middleware chain
│ ├── plugins/ # Plugin categories
│ │ ├── admin/ # Group management (35+ plugins)
│ │ ├── utility/ # Tools & info (33+ plugins)
│ │ ├── fun/ # Entertainment (16 plugins)
│ │ ├── media/ # Media search (7 plugins)
│ │ └── ai/ # LLM integration
│ ├── db/migrations/ # PostgreSQL schema migrations
│ ├── languages/ # 10 language packs
│ └── data/ # Static data (slaps, join messages)
├── docker-compose.yml
├── docker-compose.matticate.yml
├── Dockerfile
└── .env.example
```
## Plugin Development
Plugins follow a simple contract:
```lua
local plugin = {}
plugin.name = 'myplugin'
plugin.category = 'utility'
plugin.description = 'Does something useful'
plugin.commands = { 'mycommand', 'alias' }
plugin.help = '/mycommand <args> - Does the thing.'
function plugin.on_message(api, message, ctx)
return api.send_message(message.chat.id, 'Hello!')
end
return plugin
```
Add your plugin to the category's `init.lua` manifest and it will be auto-loaded.
## License
MIT License - see [LICENSE](LICENSE) for details.
## Credits
Created by [Matt Hesketh](https://github.com/wrxck).
diff --git a/main.lua b/main.lua
index 7cde9aa..35bf9a8 100644
--- a/main.lua
+++ b/main.lua
@@ -1,120 +1,138 @@
--[[
_ _ _
_ __ ___ __ _| |_| |_ __ _| |_ __ _
| '_ ` _ \ / _` | __| __/ _` | __/ _` |
| | | | | | (_| | |_| || (_| | || (_| |
|_| |_| |_|\__,_|\__|\__\__,_|\__\__,_|
v2.1
Copyright 2020-2026 Matthew Hesketh <matthew@matthewhesketh.com>
See LICENSE for details
]]
local config = require('src.core.config')
local logger = require('src.core.logger')
local database = require('src.core.database')
local redis = require('src.core.redis')
local session = require('src.core.session')
local i18n = require('src.core.i18n')
local loader = require('src.core.loader')
local router = require('src.core.router')
local migrations = require('src.db.init')
-- 1. Load configuration
config.load('.env')
logger.init()
logger.info('mattata v%s starting...', config.VERSION)
-- 2. Validate required config
assert(config.bot_token(), 'BOT_TOKEN is required. Set it in .env or as an environment variable.')
-- 3. Configure telegram-bot-lua
local api = require('telegram-bot-lua').configure(config.bot_token())
local tools = require('telegram-bot-lua.tools')
logger.info('Bot: @%s (%s) [%d]', api.info.username, api.info.first_name, api.info.id)
-- 4. Connect to PostgreSQL
local db_ok, db_err = database.connect()
if not db_ok then
logger.error('Cannot start without PostgreSQL: %s', tostring(db_err))
os.exit(1)
end
-- 5. Run database migrations
migrations.run(database)
-- 6. Connect to Redis
local redis_ok, redis_err = redis.connect()
if not redis_ok then
logger.error('Cannot start without Redis: %s', tostring(redis_err))
os.exit(1)
end
session.init(redis)
+-- 6b. Check for v1.5 data migration
+local migrate = require('src.core.migrate')
+local v1_check = migrate.check(redis)
+if v1_check.detected then
+ logger.info('v1.5 installation detected (%d Redis keys). Running migration...', v1_check.key_count)
+ local v1_result = migrate.run(database, redis)
+ if v1_result.success then
+ if v1_result.already_migrated then
+ logger.debug('v1.5 migration already applied, skipping')
+ else
+ logger.info('v1.5 migration complete: %d records imported, %d keys cleaned',
+ v1_result.records_imported, v1_result.keys_cleaned)
+ end
+ else
+ logger.warn('v1.5 migration errors: %s', table.concat(v1_result.errors, '; '))
+ end
+end
+
-- 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. Register bot command menu with Telegram
local json = require('dkjson')
local user_commands = {}
local admin_commands = {}
for _, plugin in ipairs(loader.get_plugins()) do
if plugin.commands and #plugin.commands > 0 and plugin.description and plugin.description ~= '' then
local entry = { command = plugin.commands[1], description = plugin.description }
if plugin.admin_only or plugin.global_admin_only then
table.insert(admin_commands, entry)
else
table.insert(user_commands, entry)
end
end
end
-- Set default commands (visible to all users)
api.set_my_commands(json.encode(user_commands))
-- Set admin commands (visible to group admins only)
if #admin_commands > 0 then
-- Merge user + admin so admins see everything
local all_commands = {}
for _, cmd in ipairs(user_commands) do table.insert(all_commands, cmd) end
for _, cmd in ipairs(admin_commands) do table.insert(all_commands, cmd) end
api.set_my_commands(json.encode(all_commands), { type = 'all_chat_administrators' })
end
logger.info('Registered %d user commands and %d admin commands with Telegram', #user_commands, #admin_commands)
-- 11. Notify admins
local info_msg = string.format(
'<pre>mattata v%s connected!\n\n Username: @%s\n Name: %s\n ID: %d\n Plugins: %d</pre>',
config.VERSION,
tools.escape_html(api.info.username),
tools.escape_html(api.info.first_name),
api.info.id,
loader.count()
)
if config.log_chat() then
api.send_message(config.log_chat(), info_msg, 'html')
end
for _, admin_id in ipairs(config.bot_admins()) do
api.send_message(admin_id, info_msg, 'html')
end
-- 12. Start the bot
logger.info('Starting main loop...')
router.run()
diff --git a/spec/core/migrate_spec.lua b/spec/core/migrate_spec.lua
new file mode 100644
index 0000000..a86d46f
--- /dev/null
+++ b/spec/core/migrate_spec.lua
@@ -0,0 +1,547 @@
+--[[
+ Tests for src/core/migrate.lua - v1.5 to v2.x data migration
+]]
+
+describe('core.migrate', function()
+ local migrate
+ local mock_db = require('spec.helpers.mock_db')
+ local mock_redis = require('spec.helpers.mock_redis')
+ local db, redis
+ local tmpdir = os.tmpname():match('(.*/)')
+
+ -- Helper: write a temp file and return its path
+ local function write_temp(filename, content)
+ local path = tmpdir .. filename
+ local f = io.open(path, 'w')
+ f:write(content)
+ f:close()
+ return path
+ end
+
+ -- Helper: check if a file exists
+ local function file_exists(path)
+ local f = io.open(path, 'r')
+ if f then f:close(); return true end
+ return false
+ end
+
+ -- Helper: populate v1 Redis keys for scan detection
+ -- mock_redis.scan only checks redis.store, so we must set placeholder values there.
+ -- The actual data is in redis.hashes / redis.sets / redis.store (for strings).
+ local function set_v1_hash(r, key, data)
+ r.store[key] = 'hash' -- placeholder for scan detection
+ r.hashes[key] = data
+ end
+
+ local function set_v1_string(r, key, value)
+ r.store[key] = value
+ end
+
+ local function set_v1_set(r, key, members)
+ r.store[key] = 'set' -- placeholder for scan detection
+ r.sets[key] = {}
+ for _, m in ipairs(members) do
+ r.sets[key][tostring(m)] = true
+ end
+ end
+
+ before_each(function()
+ package.loaded['src.core.migrate'] = nil
+ package.loaded['src.core.logger'] = {
+ info = function() end,
+ warn = function() end,
+ error = function() end,
+ debug = function() end,
+ }
+ migrate = require('src.core.migrate')
+ db = mock_db.new()
+ redis = mock_redis.new()
+ end)
+
+ after_each(function()
+ db.reset()
+ redis.reset()
+ end)
+
+ -- ================================================================
+ -- Detection tests
+ -- ================================================================
+ describe('check()', function()
+ it('returns detected=false on clean install', function()
+ local result = migrate.check(redis, { v1_config_path = '/nonexistent/config.lua' })
+ assert.is_false(result.detected)
+ assert.is_nil(result.config_file)
+ assert.are.equal(0, result.key_count)
+ end)
+
+ it('detects v1 config file', function()
+ local path = write_temp('test_v1_config.lua', 'return { bot_token = "123:ABC", admins = {111} }')
+ local result = migrate.check(redis, { v1_config_path = path })
+ assert.is_true(result.detected)
+ assert.are.equal(path, result.config_file)
+ os.remove(path)
+ end)
+
+ it('detects v1 Redis keys', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { welcome = 'true' })
+ set_v1_string(redis, 'chat:-100123:welcome', 'Hello!')
+ local result = migrate.check(redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_true(result.detected)
+ assert.are.equal(2, result.key_count)
+ end)
+
+ it('counts keys accurately across categories', function()
+ set_v1_hash(redis, 'chat:-100123:settings', {})
+ set_v1_string(redis, 'chat:-100123:welcome', 'Hi')
+ set_v1_string(redis, 'chat:-100123:rules', 'Rule 1')
+ set_v1_string(redis, 'chat:-100123:warnings:456', '3')
+ set_v1_hash(redis, 'chat:-100123:filters', {})
+ local result = migrate.check(redis, { v1_config_path = '/nonexistent.lua' })
+ assert.are.equal(5, result.key_count)
+ end)
+
+ it('does not false-positive on v2 Redis key patterns', function()
+ -- v2 patterns: cache:setting:*, disabled_plugins:*
+ redis.store['cache:setting:-100123:welcome_enabled'] = 'true'
+ redis.store['disabled_plugins:-100123'] = 'set'
+ local result = migrate.check(redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_false(result.detected)
+ assert.are.equal(0, result.key_count)
+ end)
+ end)
+
+ -- ================================================================
+ -- Config detection tests
+ -- ================================================================
+ describe('detect_v1_config()', function()
+ it('parses valid configuration.lua', function()
+ local path = write_temp('test_detect_valid.lua',
+ 'return { bot_token = "123:ABC", admins = {111, 222}, redis = { host = "localhost", port = 6379 } }')
+ local config = migrate.detect_v1_config(path)
+ assert.is_not_nil(config)
+ assert.are.equal('123:ABC', config.bot_token)
+ assert.are.equal(2, #config.admins)
+ os.remove(path)
+ end)
+
+ it('returns nil for missing file', function()
+ local config = migrate.detect_v1_config('/nonexistent/config.lua')
+ assert.is_nil(config)
+ end)
+
+ it('returns nil for malformed Lua', function()
+ local path = write_temp('test_detect_malformed.lua', 'this is not valid lua {{{')
+ local config = migrate.detect_v1_config(path)
+ assert.is_nil(config)
+ os.remove(path)
+ end)
+
+ it('handles config with nested keys table', function()
+ local path = write_temp('test_detect_keys.lua',
+ 'return { bot_token = "123:ABC", admins = {111}, keys = { lastfm = "abc123", youtube = "xyz789" } }')
+ local config = migrate.detect_v1_config(path)
+ assert.is_not_nil(config)
+ assert.are.equal('abc123', config.keys.lastfm)
+ assert.are.equal('xyz789', config.keys.youtube)
+ os.remove(path)
+ end)
+ end)
+
+ -- ================================================================
+ -- Config conversion tests
+ -- ================================================================
+ describe('convert_config()', function()
+ it('converts bot_token and admins', function()
+ local env = migrate.convert_config({
+ bot_token = '123:ABC',
+ admins = { 111, 222 },
+ })
+ assert.matches('BOT_TOKEN=123:ABC', env)
+ assert.matches('BOT_ADMINS=111,222', env)
+ end)
+
+ it('converts Redis config', function()
+ local env = migrate.convert_config({
+ bot_token = '123:ABC',
+ admins = { 111 },
+ redis = { host = '10.0.0.1', port = 6380, password = 'secret' },
+ })
+ assert.matches('REDIS_HOST=10.0.0.1', env)
+ assert.matches('REDIS_PORT=6380', env)
+ assert.matches('REDIS_PASSWORD=secret', env)
+ end)
+
+ it('converts API keys', function()
+ local env = migrate.convert_config({
+ bot_token = '123:ABC',
+ admins = { 111 },
+ keys = {
+ lastfm = 'lf_key',
+ youtube = 'yt_key',
+ weather = 'wx_key',
+ spotify_client_id = 'sp_id',
+ spotify_client_secret = 'sp_secret',
+ spamwatch = 'sw_token',
+ },
+ })
+ assert.matches('LASTFM_API_KEY=lf_key', env)
+ assert.matches('YOUTUBE_API_KEY=yt_key', env)
+ assert.matches('OPENWEATHERMAP_API_KEY=wx_key', env)
+ assert.matches('SPOTIFY_CLIENT_ID=sp_id', env)
+ assert.matches('SPOTIFY_CLIENT_SECRET=sp_secret', env)
+ assert.matches('SPAMWATCH_TOKEN=sw_token', env)
+ end)
+
+ it('handles missing optional keys gracefully', function()
+ local env = migrate.convert_config({
+ bot_token = '123:ABC',
+ admins = { 111 },
+ })
+ assert.matches('BOT_TOKEN=123:ABC', env)
+ assert.is_not.matches('REDIS_HOST', env)
+ assert.is_not.matches('LASTFM_API_KEY', env)
+ end)
+
+ it('produces valid key=value format', function()
+ local env = migrate.convert_config({
+ bot_token = '123:ABC',
+ admins = { 111 },
+ log_chat = '-100999',
+ })
+ -- Every non-comment non-empty line should be KEY=VALUE
+ for line in env:gmatch('[^\n]+') do
+ if not line:match('^#') and line ~= '' then
+ assert.matches('^[A-Z_]+=.+$', line)
+ end
+ end
+ end)
+
+ it('skips empty/nil values', function()
+ local env = migrate.convert_config({
+ bot_token = '123:ABC',
+ admins = { 111 },
+ redis = { host = '', port = 6379 },
+ })
+ assert.is_not.matches('REDIS_HOST=\n', env)
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: chat settings
+ -- ================================================================
+ describe('import_chat_settings()', function()
+ it('imports hash fields and maps setting names', function()
+ set_v1_hash(redis, 'chat:-100123:settings', {
+ welcome = 'true',
+ antilink = 'true',
+ ['max warnings'] = '5',
+ })
+ local keys = { 'chat:-100123:settings' }
+ local count = migrate.import_chat_settings(db, redis, keys)
+ assert.are.equal(3, count)
+ -- Check that sp_upsert_chat was called (ensure_chat)
+ assert.is_true(db.has_query('sp_upsert_chat'))
+ -- Check that sp_upsert_chat_setting_if_missing was called
+ assert.is_true(db.has_query('sp_upsert_chat_setting_if_missing'))
+ end)
+
+ it('maps v1 names to v2 names', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { antilink = 'true' })
+ migrate.import_chat_settings(db, redis, { 'chat:-100123:settings' })
+ -- Find the call with the mapped name
+ local found = false
+ for _, q in ipairs(db.queries) do
+ if q.func_name == 'sp_upsert_chat_setting_if_missing' and q.params then
+ if q.params[2] == 'antilink_enabled' then
+ found = true
+ break
+ end
+ end
+ end
+ assert.is_true(found, 'Expected antilink to be mapped to antilink_enabled')
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: welcome messages
+ -- ================================================================
+ describe('import_welcome_messages()', function()
+ it('imports string to welcome_messages table', function()
+ set_v1_string(redis, 'chat:-100123:welcome', 'Welcome to the group!')
+ local count = migrate.import_welcome_messages(db, redis, { 'chat:-100123:welcome' })
+ assert.are.equal(1, count)
+ assert.is_true(db.has_query('sp_upsert_welcome_message'))
+ end)
+
+ it('skips empty welcome messages', function()
+ set_v1_string(redis, 'chat:-100123:welcome', '')
+ local count = migrate.import_welcome_messages(db, redis, { 'chat:-100123:welcome' })
+ assert.are.equal(0, count)
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: rules
+ -- ================================================================
+ describe('import_rules()', function()
+ it('imports string to rules table', function()
+ set_v1_string(redis, 'chat:-100123:rules', '1. Be nice\n2. No spam')
+ local count = migrate.import_rules(db, redis, { 'chat:-100123:rules' })
+ assert.are.equal(1, count)
+ assert.is_true(db.has_query('sp_upsert_rules'))
+ end)
+
+ it('skips empty rules', function()
+ set_v1_string(redis, 'chat:-100123:rules', '')
+ local count = migrate.import_rules(db, redis, { 'chat:-100123:rules' })
+ assert.are.equal(0, count)
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: warnings
+ -- ================================================================
+ describe('import_warnings()', function()
+ it('sets v2 Redis hash and inserts DB rows', function()
+ set_v1_string(redis, 'chat:-100123:warnings:456', '3')
+ local count = migrate.import_warnings(db, redis, { 'chat:-100123:warnings:456' })
+ assert.are.equal(3, count)
+ -- Check v2 Redis hash was set
+ assert.are.equal('3', redis.hashes['chat:-100123:456'] and redis.hashes['chat:-100123:456']['warnings'])
+ -- Check DB calls (sp_insert_warning called 3 times)
+ local warning_calls = 0
+ for _, q in ipairs(db.queries) do
+ if q.func_name == 'sp_insert_warning' then
+ warning_calls = warning_calls + 1
+ end
+ end
+ assert.are.equal(3, warning_calls)
+ end)
+
+ it('skips zero warnings', function()
+ set_v1_string(redis, 'chat:-100123:warnings:456', '0')
+ local count = migrate.import_warnings(db, redis, { 'chat:-100123:warnings:456' })
+ assert.are.equal(0, count)
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: disabled plugins
+ -- ================================================================
+ describe('import_disabled_plugins()', function()
+ it('inserts to DB table and Redis set', function()
+ set_v1_hash(redis, 'chat:-100123:disabled_plugins', {
+ welcome = 'true',
+ lastfm = 'true',
+ })
+ local count = migrate.import_disabled_plugins(db, redis, { 'chat:-100123:disabled_plugins' })
+ assert.are.equal(2, count)
+ -- Check Redis set was populated with v2 mapped names
+ assert.are.equal(1, redis.sismember('disabled_plugins:-100123', 'greeting'))
+ assert.are.equal(1, redis.sismember('disabled_plugins:-100123', 'lastfm'))
+ end)
+
+ it('maps v1 plugin names to v2 names', function()
+ set_v1_hash(redis, 'chat:-100123:disabled_plugins', { administration = 'true' })
+ migrate.import_disabled_plugins(db, redis, { 'chat:-100123:disabled_plugins' })
+ -- "administration" should map to "admin"
+ assert.are.equal(1, redis.sismember('disabled_plugins:-100123', 'admin'))
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: filters
+ -- ================================================================
+ describe('import_filters()', function()
+ it('imports pattern/action pairs', function()
+ set_v1_hash(redis, 'chat:-100123:filters', {
+ ['bad word'] = 'delete',
+ ['spam link'] = 'ban',
+ })
+ local count = migrate.import_filters(db, redis, { 'chat:-100123:filters' })
+ assert.are.equal(2, count)
+ assert.is_true(db.has_query('sp_insert_filter'))
+ end)
+
+ it('handles empty filter hash', function()
+ set_v1_hash(redis, 'chat:-100123:filters', {})
+ local count = migrate.import_filters(db, redis, { 'chat:-100123:filters' })
+ assert.are.equal(0, count)
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: triggers
+ -- ================================================================
+ describe('import_triggers()', function()
+ it('imports pattern/response pairs', function()
+ set_v1_hash(redis, 'chat:-100123:triggers', {
+ ['hello'] = 'Hi there!',
+ ['bye'] = 'Goodbye!',
+ })
+ local count = migrate.import_triggers(db, redis, { 'chat:-100123:triggers' })
+ assert.are.equal(2, count)
+ assert.is_true(db.has_query('sp_insert_trigger'))
+ end)
+
+ it('handles empty trigger hash', function()
+ set_v1_hash(redis, 'chat:-100123:triggers', {})
+ local count = migrate.import_triggers(db, redis, { 'chat:-100123:triggers' })
+ assert.are.equal(0, count)
+ end)
+ end)
+
+ -- ================================================================
+ -- Import tests: bans
+ -- ================================================================
+ describe('import_bans()', function()
+ it('imports set members to blocklist', function()
+ set_v1_set(redis, 'chat:-100123:bans', { 111, 222, 333 })
+ local count = migrate.import_bans(db, redis, { 'chat:-100123:bans' })
+ assert.are.equal(3, count)
+ assert.is_true(db.has_query('sp_upsert_blocklist_entry'))
+ end)
+
+ it('handles empty ban set', function()
+ set_v1_set(redis, 'chat:-100123:bans', {})
+ local count = migrate.import_bans(db, redis, { 'chat:-100123:bans' })
+ assert.are.equal(0, count)
+ end)
+ end)
+
+ -- ================================================================
+ -- Pipeline tests
+ -- ================================================================
+ describe('run()', function()
+ it('succeeds end-to-end with v1 data', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { welcome = 'true' })
+ set_v1_string(redis, 'chat:-100123:welcome', 'Hello!')
+ set_v1_string(redis, 'chat:-100123:rules', 'Be nice')
+ set_v1_hash(redis, 'chat:-100123:filters', { spam = 'delete' })
+ set_v1_hash(redis, 'chat:-100123:triggers', { hi = 'hello' })
+ set_v1_set(redis, 'chat:-100123:bans', { 999 })
+
+ local result = migrate.run(db, redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_true(result.success)
+ assert.is_false(result.already_migrated)
+ assert.is_true(result.records_imported > 0)
+ end)
+
+ it('is idempotent via schema_migrations check', function()
+ -- Simulate already-migrated state
+ db.set_next_result({ { ['?column?'] = 1 } })
+ local result = migrate.run(db, redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_true(result.success)
+ assert.is_true(result.already_migrated)
+ end)
+
+ it('dry_run mode does not write to DB', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { welcome = 'true' })
+ local result = migrate.run(db, redis, {
+ dry_run = true,
+ v1_config_path = '/nonexistent.lua',
+ })
+ assert.is_true(result.success)
+ -- Should not have any BEGIN/COMMIT queries
+ assert.is_false(db.has_query('BEGIN'))
+ end)
+
+ it('skip_cleanup preserves v1 keys', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { welcome = 'true' })
+ local result = migrate.run(db, redis, {
+ skip_cleanup = true,
+ v1_config_path = '/nonexistent.lua',
+ })
+ assert.is_true(result.success)
+ assert.are.equal(0, result.keys_cleaned)
+ -- Key should still exist
+ assert.is_not_nil(redis.store['chat:-100123:settings'])
+ end)
+
+ it('cleans up v1 keys after success', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { welcome = 'true' })
+ set_v1_string(redis, 'chat:-100123:welcome', 'Hello!')
+ local result = migrate.run(db, redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_true(result.success)
+ assert.are.equal(2, result.keys_cleaned)
+ -- Keys should be deleted
+ assert.is_nil(redis.store['chat:-100123:settings'])
+ assert.is_nil(redis.store['chat:-100123:welcome'])
+ end)
+
+ it('rolls back on DB error', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { welcome = 'true' })
+ -- Make db.call throw an error
+ local orig_call = db.call
+ db.call = function(func_name, params)
+ if func_name == 'sp_upsert_chat' then
+ error('simulated DB error')
+ end
+ return orig_call(func_name, params)
+ end
+ local result = migrate.run(db, redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_false(result.success)
+ assert.is_true(#result.errors > 0)
+ assert.matches('simulated DB error', result.errors[1])
+ -- ROLLBACK should have been issued
+ assert.is_true(db.has_query('ROLLBACK'))
+ end)
+
+ it('reports errors without crashing', function()
+ set_v1_hash(redis, 'chat:-100123:settings', { welcome = 'true' })
+ local orig_call = db.call
+ db.call = function(func_name, params)
+ if func_name == 'sp_upsert_chat_setting_if_missing' then
+ error('constraint violation')
+ end
+ return orig_call(func_name, params)
+ end
+ local result = migrate.run(db, redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_false(result.success)
+ assert.is_true(#result.errors > 0)
+ end)
+
+ it('is a no-op when no v1 data detected', function()
+ local result = migrate.run(db, redis, { v1_config_path = '/nonexistent.lua' })
+ assert.is_true(result.success)
+ assert.are.equal(0, result.records_imported)
+ assert.are.equal(0, result.keys_cleaned)
+ end)
+ end)
+
+ -- ================================================================
+ -- Cleanup tests
+ -- ================================================================
+ describe('cleanup_v1_keys()', function()
+ it('deletes all categorized keys', function()
+ local keys = {
+ settings = { 'chat:-100123:settings', 'chat:-100456:settings' },
+ welcome = { 'chat:-100123:welcome' },
+ rules = {},
+ _total = 3,
+ }
+ -- Populate the store so del has something to remove
+ redis.store['chat:-100123:settings'] = 'hash'
+ redis.store['chat:-100456:settings'] = 'hash'
+ redis.store['chat:-100123:welcome'] = 'Hello'
+
+ local count = migrate.cleanup_v1_keys(redis, keys)
+ assert.are.equal(3, count)
+ assert.is_nil(redis.store['chat:-100123:settings'])
+ assert.is_nil(redis.store['chat:-100456:settings'])
+ assert.is_nil(redis.store['chat:-100123:welcome'])
+ end)
+
+ it('returns accurate count', function()
+ local keys = {
+ settings = { 'chat:-100123:settings' },
+ welcome = { 'chat:-100123:welcome' },
+ rules = { 'chat:-100123:rules' },
+ warnings = { 'chat:-100123:warnings:456' },
+ _total = 4,
+ }
+ local count = migrate.cleanup_v1_keys(redis, keys)
+ assert.are.equal(4, count)
+ end)
+ end)
+end)
diff --git a/spec/plugins/utility/github_spec.lua b/spec/plugins/utility/github_spec.lua
new file mode 100644
index 0000000..e842c06
--- /dev/null
+++ b/spec/plugins/utility/github_spec.lua
@@ -0,0 +1,924 @@
+describe('plugins.utility.github', function()
+ local github_plugin
+ local test_helper = require('spec.helpers.test_helper')
+ local json = require('dkjson')
+ local env, ctx, message
+
+ -- Mock state (shared across before_each via upvalues)
+ local http_responses, http_calls
+ local test_config
+
+ -- Sample API responses
+ local SAMPLE_USER = {
+ login = 'octocat', id = 1, name = 'The Octocat', bio = 'GitHub mascot',
+ public_repos = 8, followers = 1000, following = 10,
+ company = '@github', location = 'San Francisco',
+ html_url = 'https://github.com/octocat', avatar_url = 'https://avatars.githubusercontent.com/u/1',
+ }
+
+ local SAMPLE_REPO = {
+ full_name = 'octocat/Hello-World', description = 'My first repo',
+ language = 'Ruby', stargazers_count = 80, forks_count = 9,
+ open_issues_count = 2, license = { spdx_id = 'MIT' },
+ created_at = '2011-01-26T19:01:12Z', private = false,
+ html_url = 'https://github.com/octocat/Hello-World',
+ }
+
+ local SAMPLE_REPOS = {
+ { full_name = 'octocat/Hello-World', description = 'My first repo',
+ stargazers_count = 80, language = 'Ruby', private = false,
+ html_url = 'https://github.com/octocat/Hello-World' },
+ }
+
+ local SAMPLE_ISSUE = {
+ number = 1, title = 'Found a bug', state = 'open',
+ user = { login = 'octocat' },
+ labels = { { name = 'bug', color = 'fc2929' } },
+ assignees = { { login = 'octocat' } },
+ body = 'Description of the bug...',
+ html_url = 'https://github.com/octocat/Hello-World/issues/1',
+ created_at = '2011-04-22T13:33:48Z', comments = 3,
+ }
+
+ local SAMPLE_ISSUES = {
+ { number = 1, title = 'Found a bug', state = 'open',
+ user = { login = 'octocat' },
+ labels = { { name = 'bug' } },
+ html_url = 'https://github.com/octocat/Hello-World/issues/1',
+ created_at = '2011-04-22T13:33:48Z' },
+ }
+
+ local SAMPLE_STARRED = {
+ { full_name = 'octocat/Hello-World', description = 'My first repo',
+ stargazers_count = 80, html_url = 'https://github.com/octocat/Hello-World' },
+ }
+
+ local SAMPLE_NOTIFICATIONS = {
+ { id = '1', reason = 'mention', unread = true,
+ subject = { title = 'Issue title', type = 'Issue' },
+ repository = { full_name = 'octocat/Hello-World' },
+ updated_at = '2014-11-07T22:01:45Z' },
+ }
+
+ local SAMPLE_DEVICE_CODE = {
+ device_code = '3584d83530557fdd1f46af8289938c8ef79f9dc5',
+ user_code = 'WDJB-MJHT', verification_uri = 'https://github.com/login/device',
+ expires_in = 900, interval = 5,
+ }
+
+ local SAMPLE_ACCESS_TOKEN = {
+ access_token = 'gho_16C7e42F292c6912E7710c838347Ae178B4a',
+ token_type = 'bearer', scope = 'repo,notifications,user',
+ }
+
+ before_each(function()
+ http_responses = {}
+ http_calls = {}
+ test_config = {
+ GITHUB_CLIENT_ID = 'test_client_id',
+ GITHUB_CLIENT_SECRET = 'test_client_secret',
+ }
+
+ package.loaded['src.plugins.utility.github'] = nil
+ package.loaded['src.core.http'] = {
+ get = function(url, headers)
+ table.insert(http_calls, { method = 'GET', url = url, headers = headers })
+ local r = http_responses['GET:' .. url]
+ if r then return r.body or '', r.code or 200 end
+ return '', 404
+ end,
+ post = function(url, body, content_type, headers)
+ table.insert(http_calls, { method = 'POST', url = url, body = body, content_type = content_type, headers = headers })
+ local r = http_responses['POST:' .. url]
+ if r then return r.body or '', r.code or 200 end
+ return '', 404
+ end,
+ request = function(opts)
+ local m = opts.method or 'GET'
+ table.insert(http_calls, { method = m, url = opts.url, headers = opts.headers })
+ local r = http_responses[m .. ':' .. opts.url]
+ if r then return r.body or '', r.code or 200 end
+ return '', 404
+ end,
+ }
+ package.loaded['src.core.config'] = {
+ get = function(key, default)
+ if test_config[key] ~= nil then return test_config[key] end
+ return default
+ end,
+ }
+ package.loaded['src.core.logger'] = {
+ debug = function() end, info = function() end,
+ warn = function() end, error = function() end,
+ }
+ package.loaded['telegram-bot-lua.tools'] = {
+ escape_html = function(text)
+ if not text then return '' end
+ return tostring(text):gsub('&', '&'):gsub('<', '<'):gsub('>', '>')
+ end,
+ }
+
+ github_plugin = require('src.plugins.utility.github')
+ env = test_helper.setup()
+ ctx = test_helper.make_ctx(env)
+ end)
+
+ after_each(function()
+ test_helper.teardown(env)
+ end)
+
+ -- Helper to set mock HTTP responses
+ local function mock_get(url, data, code)
+ http_responses['GET:' .. url] = { body = json.encode(data), code = code or 200 }
+ end
+ local function mock_get_raw(url, body, code)
+ http_responses['GET:' .. url] = { body = body, code = code or 200 }
+ end
+ local function mock_post(url, data, code)
+ http_responses['POST:' .. url] = { body = json.encode(data), code = code or 200 }
+ end
+ local function mock_put(url, body, code)
+ http_responses['PUT:' .. url] = { body = body or '', code = code or 204 }
+ end
+ local function mock_delete(url, body, code)
+ http_responses['DELETE:' .. url] = { body = body or '', code = code or 204 }
+ end
+
+ -- Helper: store a token in redis for the test user
+ local function store_token(user_id)
+ user_id = user_id or 111111
+ env.redis.set('github:token:' .. user_id, 'test_token_123')
+ end
+
+ -- Helper: set up pending device flow
+ local function store_pending_device(user_id, overrides)
+ user_id = user_id or 111111
+ local uid = tostring(user_id)
+ local dk = 'github:device:' .. uid
+ local defaults = {
+ device_code = 'test_device_code',
+ user_code = 'TEST-CODE',
+ verification_uri = 'https://github.com/login/device',
+ interval = '5',
+ expires_at = tostring(os.time() + 600),
+ chat_id = tostring(user_id),
+ last_poll = '0',
+ }
+ if overrides then
+ for k, v in pairs(overrides) do defaults[k] = v end
+ end
+ for k, v in pairs(defaults) do
+ env.redis.hset(dk, k, v)
+ end
+ env.redis.sadd('github:pending_devices', uid)
+ end
+
+ -- ================================================================
+ -- 1. Plugin metadata
+ -- ================================================================
+ describe('plugin metadata', function()
+ it('should have correct name', function()
+ assert.are.equal('github', github_plugin.name)
+ end)
+
+ it('should have correct category', function()
+ assert.are.equal('utility', github_plugin.category)
+ end)
+
+ it('should have commands table with github and gh', function()
+ assert.is_table(github_plugin.commands)
+ assert.is_true(#github_plugin.commands >= 2)
+ end)
+
+ it('should have a help string', function()
+ assert.is_string(github_plugin.help)
+ assert.is_true(#github_plugin.help > 0)
+ end)
+
+ it('should have a description', function()
+ assert.is_string(github_plugin.description)
+ end)
+ end)
+
+ -- ================================================================
+ -- 2. Dispatch
+ -- ================================================================
+ describe('dispatch', function()
+ it('should show help with HTML parse mode when no args', function()
+ message = test_helper.make_message({ text = '/gh', command = 'gh', args = '' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_api_called(env.api, 'send_message')
+ local call = env.api.get_call('send_message')
+ assert.are.equal(message.chat.id, call.args[1])
+ assert.is_truthy(call.args[2]:match('login'))
+ assert.are.equal('html', call.args[3].parse_mode)
+ end)
+
+ it('should show help for unknown subcommand', function()
+ message = test_helper.make_message({ text = '/gh foobar', command = 'gh', args = 'foobar' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Unknown command')
+ end)
+
+ it('should route owner/repo to repo handler', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat/Hello%-World')
+ end)
+ end)
+
+ -- ================================================================
+ -- 3. /gh login
+ -- ================================================================
+ describe('/gh login', function()
+ it('should refuse in group chat', function()
+ message = test_helper.make_message({ text = '/gh login', command = 'gh', args = 'login' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'private chat')
+ end)
+
+ it('should refuse if already connected', function()
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ store_token(message.from.id)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'already connected')
+ end)
+
+ it('should refuse if already pending', function()
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ store_pending_device(message.from.id)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'pending login')
+ end)
+
+ it('should refuse if config is missing', function()
+ test_config.GITHUB_CLIENT_ID = nil
+ -- Re-require with new config
+ package.loaded['src.plugins.utility.github'] = nil
+ github_plugin = require('src.plugins.utility.github')
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not configured')
+ end)
+
+ it('should start device flow on success', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ -- Should store device flow in redis
+ local dk = 'github:device:' .. message.from.id
+ assert.are.equal(SAMPLE_DEVICE_CODE.device_code, env.redis.hget(dk, 'device_code'))
+ assert.are.equal(SAMPLE_DEVICE_CODE.user_code, env.redis.hget(dk, 'user_code'))
+ -- Should send verification message
+ test_helper.assert_sent_message_matches(env.api, 'WDJB%-MJHT')
+ end)
+
+ it('should handle GitHub API failure', function()
+ http_responses['POST:https://github.com/login/device/code'] = { body = '', code = 500 }
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to start')
+ end)
+
+ it('should set correct TTL on device key', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ local dk = 'github:device:' .. message.from.id
+ assert.are.equal(900, env.redis.ttls[dk])
+ end)
+
+ it('should add user to pending set', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ assert.are.equal(1, env.redis.sismember('github:pending_devices', tostring(message.from.id)))
+ end)
+
+ it('should send properly formatted verification message', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_truthy(call.args[2]:match('GitHub Login'))
+ assert.is_truthy(call.args[2]:match('github.com/login/device'))
+ assert.is_truthy(call.args[2]:match('15 minutes'))
+ assert.are.equal('html', call.args[3].parse_mode)
+ end)
+ end)
+
+ -- ================================================================
+ -- 4. /gh logout
+ -- ================================================================
+ describe('/gh logout', function()
+ it('should refuse in group chat', function()
+ message = test_helper.make_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'private chat')
+ end)
+
+ it('should refuse if not connected', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not connected')
+ end)
+
+ it('should delete token from redis', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ store_token(message.from.id)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'disconnected')
+ end)
+
+ it('should attempt token revocation', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ store_token(message.from.id)
+ mock_delete('https://api.github.com/applications/test_client_id/token', '', 204)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ -- Should have attempted the DELETE request
+ local found = false
+ for _, c in ipairs(http_calls) do
+ if c.method == 'DELETE' and c.url:match('applications') then
+ found = true
+ break
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should handle revocation failure gracefully', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ store_token(message.from.id)
+ -- Don't set up a mock response — will 404/error
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ -- Should still delete token and send success
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'disconnected')
+ end)
+ end)
+
+ -- ================================================================
+ -- 5. /gh me
+ -- ================================================================
+ describe('/gh me', function()
+ it('should show error when no token', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'connect your GitHub')
+ end)
+
+ it('should format user profile', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user', SAMPLE_USER)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('octocat'))
+ assert.is_truthy(text:match('The Octocat'))
+ assert.is_truthy(text:match('GitHub mascot'))
+ assert.is_truthy(text:match('@github'))
+ assert.is_truthy(text:match('San Francisco'))
+ assert.is_truthy(text:match('1000'))
+ end)
+
+ it('should handle API failure', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user', '', 500)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to reach')
+ end)
+
+ it('should clear token on 401', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user', '', 401)
+ github_plugin.on_message(env.api, message, ctx)
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+
+ it('should send with HTML parse mode', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user', SAMPLE_USER)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.are.equal('html', call.args[3].parse_mode)
+ end)
+ end)
+
+ -- ================================================================
+ -- 6. /gh repos
+ -- ================================================================
+ describe('/gh repos', function()
+ it('should list own repos', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', SAMPLE_REPOS)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Your Repositories')
+ test_helper.assert_sent_message_matches(env.api, 'Hello%-World')
+ end)
+
+ it('should list specified user repos', function()
+ message = test_helper.make_message({ text = '/gh repos octocat', command = 'gh', args = 'repos octocat' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/users/octocat/repos?per_page=5&sort=updated&page=1', SAMPLE_REPOS)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat')
+ end)
+
+ it('should handle empty list', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No repositories')
+ end)
+
+ it('should show pagination keyboard when has more', function()
+ -- Return exactly PER_PAGE items to trigger has_more
+ local repos = {}
+ for i = 1, 5 do
+ table.insert(repos, { full_name = 'user/repo-' .. i, stargazers_count = i })
+ end
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', repos)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+
+ it('should handle API failure', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 500)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to reach')
+ end)
+ end)
+
+ -- ================================================================
+ -- 7. /gh owner/repo
+ -- ================================================================
+ describe('/gh owner/repo', function()
+ it('should format repo info', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('octocat/Hello%-World'))
+ assert.is_truthy(text:match('My first repo'))
+ assert.is_truthy(text:match('Ruby'))
+ assert.is_truthy(text:match('80'))
+ assert.is_truthy(text:match('MIT'))
+ end)
+
+ it('should use token if available', function()
+ store_token(111111)
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ -- Check that auth header was set
+ local found_auth = false
+ for _, c in ipairs(http_calls) do
+ if c.headers and c.headers['Authorization'] then
+ found_auth = true
+ break
+ end
+ end
+ assert.is_true(found_auth)
+ end)
+
+ it('should work without token', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat/Hello%-World')
+ end)
+
+ it('should handle not found', function()
+ mock_get_raw('https://api.github.com/repos/octocat/nope', '', 404)
+ message = test_helper.make_message({ text = '/gh octocat/nope', command = 'gh', args = 'octocat/nope' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Not found')
+ end)
+
+ it('should accept GitHub URL format', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({
+ text = '/gh https://github.com/octocat/Hello-World',
+ command = 'gh', args = 'https://github.com/octocat/Hello-World',
+ })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat/Hello%-World')
+ end)
+ end)
+
+ -- ================================================================
+ -- 8. /gh issues
+ -- ================================================================
+ describe('/gh issues', function()
+ it('should list open issues', function()
+ message = test_helper.make_message({ text = '/gh issues octocat/Hello-World', command = 'gh', args = 'issues octocat/Hello-World' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=1', SAMPLE_ISSUES)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Found a bug')
+ end)
+
+ it('should handle empty issues list', function()
+ message = test_helper.make_message({ text = '/gh issues octocat/Hello-World', command = 'gh', args = 'issues octocat/Hello-World' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No open issues')
+ end)
+
+ it('should show pagination keyboard when has more', function()
+ local issues = {}
+ for i = 1, 5 do
+ table.insert(issues, { number = i, title = 'Issue ' .. i, state = 'open',
+ user = { login = 'test' }, labels = {}, created_at = '2024-01-01T00:00:00Z' })
+ end
+ message = test_helper.make_message({ text = '/gh issues octocat/Hello-World', command = 'gh', args = 'issues octocat/Hello-World' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=1', issues)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+
+ it('should require owner/repo argument', function()
+ message = test_helper.make_message({ text = '/gh issues', command = 'gh', args = 'issues' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Usage')
+ end)
+ end)
+
+ -- ================================================================
+ -- 9. /gh issue
+ -- ================================================================
+ describe('/gh issue', function()
+ it('should show issue details', function()
+ message = test_helper.make_message({ text = '/gh issue octocat/Hello-World#1', command = 'gh', args = 'issue octocat/Hello-World#1' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues/1', SAMPLE_ISSUE)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('Found a bug'))
+ assert.is_truthy(text:match('open'))
+ assert.is_truthy(text:match('octocat'))
+ end)
+
+ it('should truncate long body', function()
+ local long_issue = {
+ number = 1, title = 'Bug', state = 'open',
+ user = { login = 'test' }, labels = {}, assignees = {},
+ body = string.rep('x', 300),
+ html_url = 'https://github.com/test/test/issues/1',
+ created_at = '2024-01-01T00:00:00Z', comments = 0,
+ }
+ message = test_helper.make_message({ text = '/gh issue test/test#1', command = 'gh', args = 'issue test/test#1' })
+ mock_get('https://api.github.com/repos/test/test/issues/1', long_issue)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_truthy(call.args[2]:match('%.%.%.'))
+ end)
+
+ it('should handle not found', function()
+ message = test_helper.make_message({ text = '/gh issue octocat/Hello-World#999', command = 'gh', args = 'issue octocat/Hello-World#999' })
+ mock_get_raw('https://api.github.com/repos/octocat/Hello-World/issues/999', '', 404)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Not found')
+ end)
+
+ it('should show labels and state', function()
+ message = test_helper.make_message({ text = '/gh issue octocat/Hello-World#1', command = 'gh', args = 'issue octocat/Hello-World#1' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues/1', SAMPLE_ISSUE)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('bug'))
+ assert.is_truthy(text:match('open'))
+ end)
+ end)
+
+ -- ================================================================
+ -- 10. /gh starred
+ -- ================================================================
+ describe('/gh starred', function()
+ it('should list starred repos', function()
+ message = test_helper.make_message({ text = '/gh starred', command = 'gh', args = 'starred' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/starred?per_page=5&page=1', SAMPLE_STARRED)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Starred Repositories')
+ test_helper.assert_sent_message_matches(env.api, 'Hello%-World')
+ end)
+
+ it('should handle empty starred list', function()
+ message = test_helper.make_message({ text = '/gh starred', command = 'gh', args = 'starred' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/starred?per_page=5&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No starred')
+ end)
+
+ it('should show pagination when has more', function()
+ local repos = {}
+ for i = 1, 5 do
+ table.insert(repos, { full_name = 'user/repo-' .. i, stargazers_count = i })
+ end
+ message = test_helper.make_message({ text = '/gh starred', command = 'gh', args = 'starred' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/starred?per_page=5&page=1', repos)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+ end)
+
+ -- ================================================================
+ -- 11. /gh star + /gh unstar
+ -- ================================================================
+ describe('/gh star and /gh unstar', function()
+ it('should star a repo with PUT and confirm', function()
+ message = test_helper.make_message({ text = '/gh star octocat/Hello-World', command = 'gh', args = 'star octocat/Hello-World' })
+ store_token(message.from.id)
+ mock_put('https://api.github.com/user/starred/octocat/Hello-World', '', 204)
+ github_plugin.on_message(env.api, message, ctx)
+ -- Check PUT was made
+ local found_put = false
+ for _, c in ipairs(http_calls) do
+ if c.method == 'PUT' and c.url:match('starred/octocat') then found_put = true end
+ end
+ assert.is_true(found_put)
+ test_helper.assert_sent_message_matches(env.api, 'Starred')
+ end)
+
+ it('should unstar a repo with DELETE and confirm', function()
+ message = test_helper.make_message({ text = '/gh unstar octocat/Hello-World', command = 'gh', args = 'unstar octocat/Hello-World' })
+ store_token(message.from.id)
+ mock_delete('https://api.github.com/user/starred/octocat/Hello-World', '', 204)
+ github_plugin.on_message(env.api, message, ctx)
+ local found_delete = false
+ for _, c in ipairs(http_calls) do
+ if c.method == 'DELETE' and c.url:match('starred/octocat') then found_delete = true end
+ end
+ assert.is_true(found_delete)
+ test_helper.assert_sent_message_matches(env.api, 'Unstarred')
+ end)
+
+ it('should require auth for star and unstar', function()
+ message = test_helper.make_message({ text = '/gh star octocat/Hello-World', command = 'gh', args = 'star octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'connect your GitHub')
+ env.api.reset()
+ message = test_helper.make_message({ text = '/gh unstar octocat/Hello-World', command = 'gh', args = 'unstar octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'connect your GitHub')
+ end)
+
+ it('should require owner/repo argument', function()
+ message = test_helper.make_message({ text = '/gh star', command = 'gh', args = 'star' })
+ store_token(message.from.id)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Usage')
+ end)
+ end)
+
+ -- ================================================================
+ -- 12. /gh notifications
+ -- ================================================================
+ describe('/gh notifications', function()
+ it('should list unread notifications', function()
+ message = test_helper.make_message({ text = '/gh notifications', command = 'gh', args = 'notifications' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/notifications?per_page=5&page=1', SAMPLE_NOTIFICATIONS)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Issue title')
+ test_helper.assert_sent_message_matches(env.api, 'mention')
+ end)
+
+ it('should handle empty notifications', function()
+ message = test_helper.make_message({ text = '/gh notifications', command = 'gh', args = 'notifications' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/notifications?per_page=5&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No unread')
+ end)
+
+ it('should show pagination when has more', function()
+ local notifs = {}
+ for i = 1, 5 do
+ table.insert(notifs, {
+ id = tostring(i), reason = 'mention', unread = true,
+ subject = { title = 'Notif ' .. i, type = 'Issue' },
+ repository = { full_name = 'user/repo' },
+ updated_at = '2024-01-01T00:00:00Z',
+ })
+ end
+ message = test_helper.make_message({ text = '/gh notifications', command = 'gh', args = 'notifications' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/notifications?per_page=5&page=1', notifs)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+ end)
+
+ -- ================================================================
+ -- 13. Cron polling
+ -- ================================================================
+ describe('cron', function()
+ it('should skip when no pending devices', function()
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, #http_calls)
+ end)
+
+ it('should skip when config is missing', function()
+ test_config.GITHUB_CLIENT_ID = nil
+ package.loaded['src.plugins.utility.github'] = nil
+ github_plugin = require('src.plugins.utility.github')
+ store_pending_device(111111)
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, #http_calls)
+ end)
+
+ it('should continue on authorization_pending', function()
+ store_pending_device(111111, { last_poll = '0' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'authorization_pending',
+ error_description = 'The authorization request is still pending.',
+ })
+ github_plugin.cron(env.api, ctx)
+ -- Should still be pending
+ assert.are.equal(1, env.redis.sismember('github:pending_devices', '111111'))
+ -- Device key should still exist
+ assert.is_truthy(env.redis.hget('github:device:111111', 'device_code'))
+ end)
+
+ it('should store token and notify on access_token', function()
+ store_pending_device(111111, { last_poll = '0', chat_id = '111111' })
+ mock_post('https://github.com/login/oauth/access_token', SAMPLE_ACCESS_TOKEN)
+ github_plugin.cron(env.api, ctx)
+ -- Token should be stored
+ assert.are.equal(SAMPLE_ACCESS_TOKEN.access_token, env.redis.get('github:token:111111'))
+ -- Device should be cleaned up
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ -- User should be notified
+ test_helper.assert_sent_message_matches(env.api, 'connected successfully')
+ end)
+
+ it('should increase interval on slow_down', function()
+ store_pending_device(111111, { last_poll = '0', interval = '5' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'slow_down',
+ error_description = 'Too many requests.',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal('10', env.redis.hget('github:device:111111', 'interval'))
+ end)
+
+ it('should clean up on expired_token', function()
+ store_pending_device(111111, { last_poll = '0', chat_id = '111111' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'expired_token',
+ error_description = 'The device code has expired.',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+
+ it('should clean up on access_denied', function()
+ store_pending_device(111111, { last_poll = '0', chat_id = '111111' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'access_denied',
+ error_description = 'The user denied the request.',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ test_helper.assert_sent_message_matches(env.api, 'denied')
+ end)
+
+ it('should respect interval between polls', function()
+ local now = os.time()
+ store_pending_device(111111, { last_poll = tostring(now), interval = '60' })
+ github_plugin.cron(env.api, ctx)
+ -- Should not have made any HTTP calls since last_poll is now and interval is 60s
+ assert.are.equal(0, #http_calls)
+ end)
+
+ it('should remove expired flows', function()
+ store_pending_device(111111, {
+ last_poll = '0',
+ expires_at = tostring(os.time() - 100), -- already expired
+ chat_id = '111111',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+ end)
+
+ -- ================================================================
+ -- 14. Callback queries
+ -- ================================================================
+ describe('callback queries', function()
+ it('should handle repos pagination', function()
+ store_token(111111)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=2', SAMPLE_REPOS)
+ local cb = test_helper.make_callback_query({ data = 'r:_:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'edit_message_text')
+ test_helper.assert_api_called(env.api, 'answer_callback_query')
+ end)
+
+ it('should handle issues pagination', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=2', SAMPLE_ISSUES)
+ local cb = test_helper.make_callback_query({ data = 'i:octocat/Hello-World:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'edit_message_text')
+ end)
+
+ it('should handle noop', function()
+ local cb = test_helper.make_callback_query({ data = 'noop' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'answer_callback_query')
+ test_helper.assert_api_not_called(env.api, 'edit_message_text')
+ end)
+
+ it('should always answer callback query', function()
+ mock_get('https://api.github.com/user/starred?per_page=5&page=2', SAMPLE_STARRED)
+ store_token(111111)
+ local cb = test_helper.make_callback_query({ data = 's:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'answer_callback_query')
+ end)
+
+ it('should edit message with updated content', function()
+ mock_get('https://api.github.com/notifications?per_page=5&page=2', SAMPLE_NOTIFICATIONS)
+ store_token(111111)
+ local cb = test_helper.make_callback_query({ data = 'n:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ local call = env.api.get_call('edit_message_text')
+ assert.is_not_nil(call)
+ assert.is_truthy(call.args[3]:match('Issue title'))
+ assert.are.equal('html', call.args[4].parse_mode)
+ end)
+ end)
+
+ -- ================================================================
+ -- 15. Error handling
+ -- ================================================================
+ describe('error handling', function()
+ it('should clear token on 401 response', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 401)
+ github_plugin.on_message(env.api, message, ctx)
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+
+ it('should show rate limit message on 403', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 403)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'rate limit')
+ end)
+
+ it('should show not found on 404', function()
+ message = test_helper.make_message({ text = '/gh octocat/nonexistent', command = 'gh', args = 'octocat/nonexistent' })
+ mock_get_raw('https://api.github.com/repos/octocat/nonexistent', '', 404)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Not found')
+ end)
+
+ it('should show generic error on network failure', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 0)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to reach')
+ end)
+ end)
+end)
diff --git a/src/core/migrate.lua b/src/core/migrate.lua
new file mode 100644
index 0000000..8f10921
--- /dev/null
+++ b/src/core/migrate.lua
@@ -0,0 +1,571 @@
+--[[
+ mattata v2.1 - v1.5 Data Migration
+ Detects v1.5 installations and imports data into the v2 schema.
+ Handles: config conversion, chat settings, welcome messages, rules,
+ warnings, disabled plugins, filters, triggers, and bans.
+]]
+
+local migrate = {}
+
+local logger = require('src.core.logger')
+
+-- v1 setting names -> v2 setting names
+local SETTING_KEY_MAP = {
+ ['antilink'] = 'antilink_enabled',
+ ['welcome'] = 'welcome_enabled',
+ ['captcha'] = 'captcha_enabled',
+ ['antibot'] = 'antibot_enabled',
+ ['antiarabic'] = 'antiarabic_enabled',
+ ['antiflood'] = 'antiflood_enabled',
+ ['antispam'] = 'antispam_enabled',
+ ['antiforward'] = 'antiforward_enabled',
+ ['rtl'] = 'rtl_enabled',
+ ['max warnings'] = 'max_warnings',
+ ['delete commands'] = 'delete_commands',
+ ['force group language'] = 'force_group_language',
+ ['language'] = 'language',
+ ['log admin actions'] = 'log_admin_actions',
+ ['welcome message'] = 'welcome_enabled',
+ ['use administration'] = 'use_administration',
+}
+
+-- v1 plugin names -> v2 plugin names (most are identical)
+local PLUGIN_NAME_MAP = {
+ ['administration'] = 'admin',
+ ['antispam'] = 'antispam',
+ ['captcha'] = 'captcha',
+ ['welcome'] = 'greeting',
+ ['lastfm'] = 'lastfm',
+ ['translate'] = 'translate',
+ ['weather'] = 'weather',
+ ['youtube'] = 'youtube',
+ ['spotify'] = 'spotify',
+ ['wikipedia'] = 'wikipedia',
+ ['currency'] = 'currency',
+ ['help'] = 'help',
+ ['id'] = 'id',
+ ['ban'] = 'ban',
+ ['kick'] = 'kick',
+ ['mute'] = 'mute',
+ ['warn'] = 'warn',
+ ['pin'] = 'pin',
+ ['report'] = 'report',
+ ['rules'] = 'rules',
+ ['setlang'] = 'setlang',
+ ['settings'] = 'settings',
+}
+
+-- v1 config key -> .env key mapping
+local CONFIG_KEY_MAP = {
+ { v1 = 'bot_token', env = 'BOT_TOKEN' },
+ { v1 = 'log_channel', env = 'LOG_CHAT' },
+ { v1 = 'log_chat', env = 'LOG_CHAT' },
+}
+
+local CONFIG_API_KEY_MAP = {
+ { v1 = 'lastfm', env = 'LASTFM_API_KEY' },
+ { v1 = 'youtube', env = 'YOUTUBE_API_KEY' },
+ { v1 = 'weather', env = 'OPENWEATHERMAP_API_KEY' },
+ { v1 = 'spotify_client_id', env = 'SPOTIFY_CLIENT_ID' },
+ { v1 = 'spotify_client_secret', env = 'SPOTIFY_CLIENT_SECRET' },
+ { v1 = 'spamwatch', env = 'SPAMWATCH_TOKEN' },
+}
+
+-- v1 Redis key patterns (distinct from v2 patterns)
+local V1_KEY_PATTERNS = {
+ settings = 'chat:*:settings',
+ welcome = 'chat:*:welcome',
+ rules = 'chat:*:rules',
+ warnings = 'chat:*:warnings:*',
+ disabled_plugins = 'chat:*:disabled_plugins',
+ filters = 'chat:*:filters',
+ triggers = 'chat:*:triggers',
+ bans = 'chat:*:bans',
+}
+
+-- Extract chat_id from a v1 Redis key like "chat:-100123:settings"
+local function extract_chat_id(key)
+ local id = key:match('^chat:(%-?%d+):')
+ return id and tonumber(id) or nil
+end
+
+-- Extract user_id from a warnings key like "chat:-100123:warnings:456"
+local function extract_warning_user_id(key)
+ local uid = key:match(':warnings:(%d+)$')
+ return uid and tonumber(uid) or nil
+end
+
+-- Ensure a chat row exists in PostgreSQL
+local function ensure_chat(db, chat_id)
+ db.call('sp_upsert_chat', { chat_id, 'Imported Chat', 'supergroup', nil })
+end
+
+--- Detect a v1.5 configuration.lua file
+-- @param path string: path to configuration.lua (default: 'configuration.lua')
+-- @return table|nil: parsed config table, or nil if not found/invalid
+function migrate.detect_v1_config(path)
+ path = path or 'configuration.lua'
+ local f = io.open(path, 'r')
+ if not f then return nil end
+ f:close()
+
+ -- Load in a sandboxed environment (no os, io, require)
+ local sandbox = {
+ tonumber = tonumber,
+ tostring = tostring,
+ type = type,
+ pairs = pairs,
+ ipairs = ipairs,
+ table = { insert = table.insert, concat = table.concat },
+ string = { format = string.format, match = string.match },
+ math = { floor = math.floor },
+ }
+ local chunk, err = loadfile(path, 't', sandbox)
+ if not chunk then
+ logger.warn('Failed to parse v1 config %s: %s', path, tostring(err))
+ return nil
+ end
+
+ local ok, result = pcall(chunk)
+ if not ok then
+ logger.warn('Failed to execute v1 config %s: %s', path, tostring(result))
+ return nil
+ end
+
+ -- Validate: must have bot_token and admins
+ if type(result) ~= 'table' then return nil end
+ if not result.bot_token and not result['bot_token'] then return nil end
+ if not result.admins and not result['admins'] then return nil end
+
+ return result
+end
+
+--- Convert a v1 config table to .env format string
+-- @param v1_config table: parsed v1 configuration table
+-- @return string: .env file content
+function migrate.convert_config(v1_config)
+ if not v1_config or type(v1_config) ~= 'table' then
+ return ''
+ end
+
+ local lines = { '# Auto-generated from v1.5 configuration.lua' }
+
+ -- Direct top-level mappings
+ for _, mapping in ipairs(CONFIG_KEY_MAP) do
+ local val = v1_config[mapping.v1]
+ if val and tostring(val) ~= '' then
+ table.insert(lines, mapping.env .. '=' .. tostring(val))
+ end
+ end
+
+ -- Admins (table -> comma-joined)
+ if type(v1_config.admins) == 'table' then
+ local admin_ids = {}
+ for _, id in ipairs(v1_config.admins) do
+ table.insert(admin_ids, tostring(id))
+ end
+ if #admin_ids > 0 then
+ table.insert(lines, 'BOT_ADMINS=' .. table.concat(admin_ids, ','))
+ end
+ end
+
+ -- Redis config
+ if type(v1_config.redis) == 'table' then
+ if v1_config.redis.host and tostring(v1_config.redis.host) ~= '' then
+ table.insert(lines, 'REDIS_HOST=' .. tostring(v1_config.redis.host))
+ end
+ if v1_config.redis.port then
+ table.insert(lines, 'REDIS_PORT=' .. tostring(v1_config.redis.port))
+ end
+ if v1_config.redis.password and tostring(v1_config.redis.password) ~= '' then
+ table.insert(lines, 'REDIS_PASSWORD=' .. tostring(v1_config.redis.password))
+ end
+ end
+
+ -- API keys from keys subtable
+ if type(v1_config.keys) == 'table' then
+ for _, mapping in ipairs(CONFIG_API_KEY_MAP) do
+ local val = v1_config.keys[mapping.v1]
+ if val and tostring(val) ~= '' then
+ table.insert(lines, mapping.env .. '=' .. tostring(val))
+ end
+ end
+ end
+
+ table.insert(lines, '')
+ return table.concat(lines, '\n')
+end
+
+--- Scan Redis for v1-era key patterns
+-- @param redis table: Redis connection
+-- @return table: categorized keys { settings = {...}, welcome = {...}, ... }
+function migrate.scan_v1_keys(redis)
+ local result = {}
+ local total = 0
+ for category, pattern in pairs(V1_KEY_PATTERNS) do
+ local keys = redis.scan(pattern)
+ result[category] = keys or {}
+ total = total + #(keys or {})
+ end
+ result._total = total
+ return result
+end
+
+--- Check if v1.5 data is present
+-- @param redis table: Redis connection
+-- @param opts table: optional { v1_config_path = 'configuration.lua' }
+-- @return table: { detected = bool, config_file = path|nil, key_count = number }
+function migrate.check(redis, opts)
+ opts = opts or {}
+ local config_path = opts.v1_config_path or 'configuration.lua'
+
+ local config_detected = false
+ local f = io.open(config_path, 'r')
+ if f then
+ f:close()
+ config_detected = true
+ end
+
+ local keys = migrate.scan_v1_keys(redis)
+ local key_count = keys._total or 0
+
+ return {
+ detected = config_detected or key_count > 0,
+ config_file = config_detected and config_path or nil,
+ key_count = key_count,
+ }
+end
+
+--- Import chat settings from v1 Redis hashes to PostgreSQL
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 setting keys
+-- @return number: count of imported records
+function migrate.import_chat_settings(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ if chat_id then
+ ensure_chat(db, chat_id)
+ local settings = redis.hgetall(key)
+ for field, value in pairs(settings) do
+ local mapped = SETTING_KEY_MAP[field] or field
+ db.call('sp_upsert_chat_setting_if_missing', { chat_id, mapped, value })
+ count = count + 1
+ end
+ end
+ end
+ return count
+end
+
+--- Import welcome messages from v1 Redis strings to PostgreSQL
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 welcome keys
+-- @return number: count of imported records
+function migrate.import_welcome_messages(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ if chat_id then
+ local message = redis.get(key)
+ if message and message ~= '' then
+ ensure_chat(db, chat_id)
+ db.call('sp_upsert_welcome_message', { chat_id, message })
+ count = count + 1
+ end
+ end
+ end
+ return count
+end
+
+--- Import rules from v1 Redis strings to PostgreSQL
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 rules keys
+-- @return number: count of imported records
+function migrate.import_rules(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ if chat_id then
+ local rules_text = redis.get(key)
+ if rules_text and rules_text ~= '' then
+ ensure_chat(db, chat_id)
+ db.call('sp_upsert_rules', { chat_id, rules_text })
+ count = count + 1
+ end
+ end
+ end
+ return count
+end
+
+--- Import warnings from v1 Redis strings to v2 Redis hashes + PostgreSQL
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 warning keys
+-- @return number: count of imported records
+function migrate.import_warnings(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ local user_id = extract_warning_user_id(key)
+ if chat_id and user_id then
+ local warn_count = tonumber(redis.get(key)) or 0
+ if warn_count > 0 then
+ ensure_chat(db, chat_id)
+ -- Set v2 Redis hash (matches warn plugin pattern)
+ local v2_key = 'chat:' .. chat_id .. ':' .. user_id
+ redis.hset(v2_key, 'warnings', tostring(warn_count))
+ -- Insert warning rows in DB
+ for _ = 1, warn_count do
+ db.call('sp_insert_warning', { chat_id, user_id, 0, 'Imported from v1.5' })
+ end
+ count = count + warn_count
+ end
+ end
+ end
+ return count
+end
+
+--- Import disabled plugins from v1 Redis hashes to PostgreSQL + v2 Redis sets
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 disabled_plugins keys
+-- @return number: count of imported records
+function migrate.import_disabled_plugins(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ if chat_id then
+ ensure_chat(db, chat_id)
+ local plugins = redis.hgetall(key)
+ for name in pairs(plugins) do
+ local mapped = PLUGIN_NAME_MAP[name] or name
+ -- Insert into PostgreSQL disabled_plugins table
+ db.execute(
+ 'INSERT INTO disabled_plugins (chat_id, plugin_name) VALUES ($1, $2) ON CONFLICT DO NOTHING',
+ { chat_id, mapped }
+ )
+ -- Also set v2 Redis set (matches session.lua pattern)
+ redis.sadd('disabled_plugins:' .. chat_id, mapped)
+ count = count + 1
+ end
+ end
+ end
+ return count
+end
+
+--- Import filters from v1 Redis hashes to PostgreSQL
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 filter keys
+-- @return number: count of imported records
+function migrate.import_filters(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ if chat_id then
+ ensure_chat(db, chat_id)
+ local filters = redis.hgetall(key)
+ for pattern, action in pairs(filters) do
+ db.call('sp_insert_filter', { chat_id, pattern, action, nil })
+ count = count + 1
+ end
+ end
+ end
+ return count
+end
+
+--- Import triggers from v1 Redis hashes to PostgreSQL
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 trigger keys
+-- @return number: count of imported records
+function migrate.import_triggers(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ if chat_id then
+ ensure_chat(db, chat_id)
+ local triggers = redis.hgetall(key)
+ for pattern, response in pairs(triggers) do
+ db.call('sp_insert_trigger', { chat_id, pattern, response, nil })
+ count = count + 1
+ end
+ end
+ end
+ return count
+end
+
+--- Import bans from v1 Redis sets to PostgreSQL group_blocklist
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param keys table: array of v1 ban keys
+-- @return number: count of imported records
+function migrate.import_bans(db, redis, keys)
+ local count = 0
+ for _, key in ipairs(keys) do
+ local chat_id = extract_chat_id(key)
+ if chat_id then
+ ensure_chat(db, chat_id)
+ local members = redis.smembers(key)
+ for _, user_id_str in ipairs(members) do
+ local user_id = tonumber(user_id_str)
+ if user_id then
+ db.call('sp_upsert_blocklist_entry', { chat_id, user_id, 'Imported from v1.5' })
+ count = count + 1
+ end
+ end
+ end
+ end
+ return count
+end
+
+--- Clean up all v1 Redis keys after successful migration
+-- @param redis table: Redis connection
+-- @param keys table: categorized key map from scan_v1_keys
+-- @return number: count of deleted keys
+function migrate.cleanup_v1_keys(redis, keys)
+ local count = 0
+ for category, key_list in pairs(keys) do
+ if category ~= '_total' then
+ for _, key in ipairs(key_list) do
+ redis.del(key)
+ count = count + 1
+ end
+ end
+ end
+ return count
+end
+
+--- Run the full v1.5 -> v2.x migration pipeline
+-- @param db table: database connection
+-- @param redis table: Redis connection
+-- @param opts table: { dry_run = false, skip_cleanup = false, v1_config_path = 'configuration.lua' }
+-- @return table: { success, already_migrated, config_migrated, records_imported, keys_cleaned, errors }
+function migrate.run(db, redis, opts)
+ opts = opts or {}
+ local dry_run = opts.dry_run or false
+ local skip_cleanup = opts.skip_cleanup or false
+ local config_path = opts.v1_config_path or 'configuration.lua'
+
+ local result = {
+ success = false,
+ already_migrated = false,
+ config_migrated = false,
+ records_imported = 0,
+ keys_cleaned = 0,
+ errors = {},
+ }
+
+ -- 1. Check if already migrated
+ local applied = db.execute(
+ 'SELECT 1 FROM schema_migrations WHERE name = $1',
+ { 'v1_data_import' }
+ )
+ if applied and #applied > 0 then
+ result.success = true
+ result.already_migrated = true
+ return result
+ end
+
+ -- 2. Detect and convert v1 config
+ local v1_config = migrate.detect_v1_config(config_path)
+ if v1_config and not dry_run then
+ local env_file = io.open('.env', 'r')
+ if not env_file then
+ local env_content = migrate.convert_config(v1_config)
+ local out = io.open('.env', 'w')
+ if out then
+ out:write(env_content)
+ out:close()
+ result.config_migrated = true
+ logger.info('Converted v1 configuration.lua to .env')
+ end
+ else
+ env_file:close()
+ logger.info('Skipping config conversion: .env already exists')
+ end
+ end
+
+ -- 3. Scan v1 Redis keys
+ local keys = migrate.scan_v1_keys(redis)
+ if keys._total == 0 and not v1_config then
+ result.success = true
+ return result
+ end
+
+ if dry_run then
+ result.success = true
+ result.records_imported = keys._total
+ return result
+ end
+
+ -- 4-7. Run imports inside a transaction
+ local import_count = 0
+ local tx_ok, tx_err = pcall(function()
+ db.query('BEGIN')
+
+ -- 5. Import all categories
+ if keys.settings and #keys.settings > 0 then
+ import_count = import_count + migrate.import_chat_settings(db, redis, keys.settings)
+ end
+ if keys.welcome and #keys.welcome > 0 then
+ import_count = import_count + migrate.import_welcome_messages(db, redis, keys.welcome)
+ end
+ if keys.rules and #keys.rules > 0 then
+ import_count = import_count + migrate.import_rules(db, redis, keys.rules)
+ end
+ if keys.warnings and #keys.warnings > 0 then
+ import_count = import_count + migrate.import_warnings(db, redis, keys.warnings)
+ end
+ if keys.disabled_plugins and #keys.disabled_plugins > 0 then
+ import_count = import_count + migrate.import_disabled_plugins(db, redis, keys.disabled_plugins)
+ end
+ if keys.filters and #keys.filters > 0 then
+ import_count = import_count + migrate.import_filters(db, redis, keys.filters)
+ end
+ if keys.triggers and #keys.triggers > 0 then
+ import_count = import_count + migrate.import_triggers(db, redis, keys.triggers)
+ end
+ if keys.bans and #keys.bans > 0 then
+ import_count = import_count + migrate.import_bans(db, redis, keys.bans)
+ end
+
+ -- 6. Record migration
+ db.execute(
+ 'INSERT INTO schema_migrations (name) VALUES ($1)',
+ { 'v1_data_import' }
+ )
+
+ -- 7. Commit
+ db.query('COMMIT')
+ end)
+
+ if not tx_ok then
+ db.query('ROLLBACK')
+ table.insert(result.errors, tostring(tx_err))
+ logger.error('v1.5 migration failed, rolled back: %s', tostring(tx_err))
+ return result
+ end
+
+ result.records_imported = import_count
+
+ -- 8. Cleanup v1 keys
+ if not skip_cleanup then
+ result.keys_cleaned = migrate.cleanup_v1_keys(redis, keys)
+ end
+
+ -- 9. Rename configuration.lua
+ local config_exists = io.open(config_path, 'r')
+ if config_exists then
+ config_exists:close()
+ os.rename(config_path, config_path .. '.v1.bak')
+ end
+
+ result.success = true
+ return result
+end
+
+return migrate
diff --git a/src/db/init.lua b/src/db/init.lua
index 36019d1..be379ee 100644
--- a/src/db/init.lua
+++ b/src/db/init.lua
@@ -1,109 +1,110 @@
--[[
mattata v2.0 - Migration Runner
Runs pending SQL migrations in order, wrapped in transactions.
Supports migrations from src/db/migrations/ AND plugin.migration fields.
]]
local migrations = {}
local logger = require('src.core.logger')
local migration_files = {
{ name = '001_initial_schema', path = 'src.db.migrations.001_initial_schema' },
{ name = '002_federation_tables', path = 'src.db.migrations.002_federation_tables' },
{ name = '003_statistics_tables', path = 'src.db.migrations.003_statistics_tables' },
{ name = '004_performance_indexes', path = 'src.db.migrations.004_performance_indexes' },
{ name = '005_stored_procedures', path = 'src.db.migrations.005_stored_procedures' },
- { name = '006_import_procedures', path = 'src.db.migrations.006_import_procedures' }
+ { name = '006_import_procedures', path = 'src.db.migrations.006_import_procedures' },
+ { name = '007_v1_import_tracking', path = 'src.db.migrations.007_v1_import_tracking' }
}
function migrations.run(db)
-- Create migrations tracking table
db.query([[
CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
]])
-- Run each migration if not already applied
for _, mig in ipairs(migration_files) do
local applied = db.execute(
'SELECT 1 FROM schema_migrations WHERE name = $1',
{ mig.name }
)
if not applied or #applied == 0 then
logger.info('Running migration: %s', mig.name)
local ok, mod = pcall(require, mig.path)
if ok and type(mod) == 'table' and mod.up then
-- Wrap migration in a transaction
local begin_ok, begin_err = db.query('BEGIN')
if not begin_ok and begin_err then
logger.error('Failed to begin transaction for migration %s: %s', mig.name, tostring(begin_err))
os.exit(1)
end
local sql = mod.up()
local migration_ok = true
local migration_err = nil
-- Split on semicolons, respecting $$-delimited blocks
local statements = {}
local current = ''
local in_dollar = false
local i = 1
while i <= #sql do
if sql:sub(i, i + 1) == '$$' then
in_dollar = not in_dollar
current = current .. '$$'
i = i + 2
elseif sql:sub(i, i) == ';' and not in_dollar then
local trimmed = current:match('^%s*(.-)%s*$')
if trimmed ~= '' then
statements[#statements + 1] = trimmed
end
current = ''
i = i + 1
else
current = current .. sql:sub(i, i)
i = i + 1
end
end
local trimmed = current:match('^%s*(.-)%s*$')
if trimmed ~= '' then
statements[#statements + 1] = trimmed
end
for _, statement in ipairs(statements) do
local result, err = db.query(statement)
if not result and err then
migration_ok = false
migration_err = err
break
end
end
if not migration_ok then
logger.error('Migration %s failed: %s — rolling back', mig.name, tostring(migration_err))
db.query('ROLLBACK')
os.exit(1)
end
-- Record migration as applied using parameterized query
db.execute(
'INSERT INTO schema_migrations (name) VALUES ($1)',
{ mig.name }
)
db.query('COMMIT')
logger.info('Migration %s applied successfully', mig.name)
else
logger.error('Failed to load migration %s: %s', mig.name, tostring(mod))
os.exit(1)
end
else
logger.debug('Migration %s already applied', mig.name)
end
end
logger.info('All migrations up to date')
end
return migrations
diff --git a/src/db/migrations/007_v1_import_tracking.lua b/src/db/migrations/007_v1_import_tracking.lua
new file mode 100644
index 0000000..be7b3e7
--- /dev/null
+++ b/src/db/migrations/007_v1_import_tracking.lua
@@ -0,0 +1,24 @@
+--[[
+ Migration 007 - v1 Import Tracking
+ Adds a helper stored procedure for bulk-importing chat settings
+ during v1.5 -> v2.x data migration. Uses ON CONFLICT DO NOTHING
+ to preserve any existing v2 settings.
+]]
+
+local migration = {}
+
+function migration.up()
+ return [[
+
+CREATE OR REPLACE FUNCTION sp_upsert_chat_setting_if_missing(
+ p_chat_id BIGINT, p_key TEXT, p_value TEXT
+) RETURNS void AS $$
+ INSERT INTO chat_settings (chat_id, key, value)
+ VALUES (p_chat_id, p_key, p_value)
+ ON CONFLICT (chat_id, key) DO NOTHING;
+$$ LANGUAGE sql;
+
+ ]]
+end
+
+return migration
diff --git a/src/plugins/utility/github.lua b/src/plugins/utility/github.lua
index 7795938..f4cc0ae 100644
--- a/src/plugins/utility/github.lua
+++ b/src/plugins/utility/github.lua
@@ -1,75 +1,770 @@
--[[
mattata v2.0 - GitHub Plugin
- Fetches information about a GitHub repository.
+ Full GitHub integration with OAuth Device Flow authentication,
+ multiple subcommands, pagination, and cron-based auth polling.
]]
local plugin = {}
plugin.name = 'github'
plugin.category = 'utility'
-plugin.description = 'View information about a GitHub repository'
+plugin.description = 'GitHub integration with OAuth authentication'
plugin.commands = { 'github', 'gh' }
-plugin.help = '/gh <owner/repo> - View information about a GitHub repository.'
+plugin.help = table.concat({
+ '/gh login - Connect your GitHub account (PM only)',
+ '/gh logout - Disconnect your GitHub account (PM only)',
+ '/gh me - View your GitHub profile',
+ '/gh repos [user] - List repositories',
+ '/gh <owner/repo> - View repository info',
+ '/gh issues <owner/repo> - List open issues',
+ '/gh issue <owner/repo#123> - View specific issue',
+ '/gh starred - List your starred repos',
+ '/gh star <owner/repo> - Star a repository',
+ '/gh unstar <owner/repo> - Unstar a repository',
+ '/gh notifications - View unread notifications',
+}, '\n')
-function plugin.on_message(api, message, ctx)
- local http = require('src.core.http')
- local tools = require('telegram-bot-lua.tools')
+local http = require('src.core.http')
+local config = require('src.core.config')
+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%.%-_]+)')
+-- Constants
+local GITHUB_API = 'https://api.github.com'
+local DEVICE_CODE_URL = 'https://github.com/login/device/code'
+local ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
+local PER_PAGE = 5
+local TOKEN_TTL = 31536000 -- 1 year
+local DEVICE_TTL = 900 -- 15 minutes
+local CRON_MAX_POLLS = 10
+
+-- Redis key helpers
+local function token_key(user_id)
+ return 'github:token:' .. tostring(user_id)
+end
+
+local function device_key(user_id)
+ return 'github:device:' .. tostring(user_id)
+end
+
+local PENDING_KEY = 'github:pending_devices'
+
+-- Generic GitHub API caller
+local function gh_api(path, token, method, body)
+ local url = path:match('^https?://') and path or (GITHUB_API .. path)
+ local headers = { ['Accept'] = 'application/vnd.github.v3+json' }
+ if token then
+ headers['Authorization'] = 'Bearer ' .. token
end
- if not owner or not repo then
- return api.send_message(message.chat.id, 'Invalid repository format. Use: /gh owner/repo')
+ method = method or 'GET'
+ if method == 'GET' then
+ local resp, code = http.get(url, headers)
+ if code ~= 200 then return nil, code end
+ if not resp or resp == '' then return nil, code end
+ return json.decode(resp), code
+ elseif method == 'POST' then
+ local req_body = body or ''
+ if type(body) == 'table' then req_body = json.encode(body) end
+ local resp, code = http.post(url, req_body, 'application/json', headers)
+ if code ~= 200 and code ~= 201 then return nil, code end
+ if not resp or resp == '' then return nil, code end
+ return json.decode(resp), code
+ else
+ -- PUT, DELETE (no body needed for our use cases)
+ headers['Content-Length'] = '0'
+ local resp, code = http.request({
+ url = url,
+ method = method,
+ headers = headers,
+ })
+ if code == 204 then return true, code end
+ if code ~= 200 then return nil, code end
+ if not resp or resp == '' then return true, code end
+ return json.decode(resp), code
end
+end
- local api_url = string.format('https://api.github.com/repos/%s/%s', owner, repo)
- local data, _ = http.get_json(api_url, {
- ['Accept'] = 'application/vnd.github.v3+json'
- })
- if not data then
- return api.send_message(message.chat.id, 'Repository not found or GitHub API is unavailable.')
+-- Retrieve stored token
+local function get_token(redis, user_id)
+ return redis.get(token_key(user_id))
+end
+
+-- Get token or send error message
+local function require_token(api, redis, message)
+ local token = get_token(redis, message.from.id)
+ if not token then
+ api.send_message(message.chat.id, 'You need to connect your GitHub account first. Use /gh login in a private chat.')
+ return nil
end
- if not data or data.message then
- return api.send_message(message.chat.id, 'Repository not found: ' .. (data and data.message or 'unknown error'))
+ return token
+end
+
+-- Authed API call with error handling
+local function gh_api_authed(api, redis, message, path, method, body)
+ local token = get_token(redis, message.from.id)
+ local data, code = gh_api(path, token, method, body)
+ if code == 401 then
+ if token then redis.del(token_key(message.from.id)) end
+ api.send_message(message.chat.id, 'Your GitHub token has expired. Please /gh login again.')
+ return nil, code
+ elseif code == 403 then
+ api.send_message(message.chat.id, 'GitHub API rate limit exceeded or insufficient permissions.')
+ return nil, code
+ elseif code == 404 then
+ api.send_message(message.chat.id, 'Not found on GitHub.')
+ return nil, code
+ elseif not data then
+ api.send_message(message.chat.id, 'Failed to reach the GitHub API. Please try again later.')
+ return nil, code or 0
end
+ return data, code
+end
+-- Format full repo info as HTML lines
+local function format_repo(data)
local lines = {
- string.format('<b>%s</b>', tools.escape_html(data.full_name or (owner .. '/' .. repo)))
+ string.format('<b>%s</b>', tools.escape_html(data.full_name or '')),
}
-
if data.description and data.description ~= '' then
- table.insert(lines, tools.escape_html(data.description))
+ table.insert(lines, (tools.escape_html(data.description)))
end
-
table.insert(lines, '')
-
+ if data.private then
+ table.insert(lines, 'Visibility: <code>private</code>')
+ end
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
+ return lines
+end
+-- Build pagination keyboard with prev/next buttons
+local function pagination_keyboard(api, prefix, page, has_more)
+ if page == 1 and not has_more then return nil end
+ local keyboard = api.inline_keyboard()
+ local row = api.row()
+ if page > 1 then
+ row:callback_data_button('< Prev', string.format('github:%s:%d', prefix, page - 1))
+ end
+ row:callback_data_button('Page ' .. page, 'github:noop')
+ if has_more then
+ row:callback_data_button('Next >', string.format('github:%s:%d', prefix, page + 1))
+ end
+ keyboard:row(row)
+ return keyboard
+end
+
+-- Format repos list
+local function format_repos_list(repos, page, user)
+ local title = user and user ~= '_'
+ and string.format('<b>%s\'s Repositories</b>', tools.escape_html(user))
+ or '<b>Your Repositories</b>'
+ local lines = { title .. ' (Page ' .. page .. ')' }
+ if #repos == 0 then
+ table.insert(lines, '\nNo repositories found.')
+ return table.concat(lines, '\n')
+ end
+ for _, r in ipairs(repos) do
+ table.insert(lines, '')
+ local name = '<b>' .. tools.escape_html(r.full_name or '') .. '</b>'
+ if r.private then name = name .. ' [private]' end
+ table.insert(lines, name)
+ if r.description and r.description ~= '' then
+ local desc = r.description
+ if #desc > 80 then desc = desc:sub(1, 77) .. '...' end
+ table.insert(lines, (tools.escape_html(desc)))
+ end
+ local meta = {}
+ if r.language then table.insert(meta, r.language) end
+ table.insert(meta, tostring(r.stargazers_count or 0) .. ' stars')
+ table.insert(lines, table.concat(meta, ' | '))
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Format issues list
+local function format_issues_list(issues, page, owner_repo)
+ local lines = { string.format('<b>Open Issues — %s</b> (Page %d)', tools.escape_html(owner_repo), page) }
+ if #issues == 0 then
+ table.insert(lines, '\nNo open issues found.')
+ return table.concat(lines, '\n')
+ end
+ for _, issue in ipairs(issues) do
+ table.insert(lines, '')
+ local labels_str = ''
+ if issue.labels and #issue.labels > 0 then
+ local names = {}
+ for _, l in ipairs(issue.labels) do table.insert(names, l.name) end
+ labels_str = ' [' .. table.concat(names, ', ') .. ']'
+ end
+ table.insert(lines, string.format(
+ '#%d <b>%s</b>%s',
+ issue.number,
+ tools.escape_html(issue.title or ''),
+ tools.escape_html(labels_str)
+ ))
+ table.insert(lines, string.format('by %s — %s',
+ tools.escape_html(issue.user and issue.user.login or 'unknown'),
+ (issue.created_at or ''):sub(1, 10)
+ ))
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Format starred repos list
+local function format_starred_list(repos, page)
+ local lines = { '<b>Starred Repositories</b> (Page ' .. page .. ')' }
+ if #repos == 0 then
+ table.insert(lines, '\nNo starred repositories.')
+ return table.concat(lines, '\n')
+ end
+ for _, r in ipairs(repos) do
+ table.insert(lines, '')
+ table.insert(lines, '<b>' .. tools.escape_html(r.full_name or '') .. '</b>')
+ if r.description and r.description ~= '' then
+ local desc = r.description
+ if #desc > 80 then desc = desc:sub(1, 77) .. '...' end
+ table.insert(lines, (tools.escape_html(desc)))
+ end
+ table.insert(lines, tostring(r.stargazers_count or 0) .. ' stars')
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Format notifications list
+local function format_notifications_list(notifications, page)
+ local lines = { '<b>Unread Notifications</b> (Page ' .. page .. ')' }
+ if #notifications == 0 then
+ table.insert(lines, '\nNo unread notifications.')
+ return table.concat(lines, '\n')
+ end
+ for _, n in ipairs(notifications) do
+ table.insert(lines, '')
+ table.insert(lines, string.format(
+ '<b>[%s]</b> %s',
+ tools.escape_html(n.subject and n.subject.type or 'Unknown'),
+ tools.escape_html(n.subject and n.subject.title or '')
+ ))
+ table.insert(lines, string.format(
+ '%s — %s',
+ tools.escape_html(n.repository and n.repository.full_name or ''),
+ tools.escape_html(n.reason or '')
+ ))
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Parse owner/repo from argument
+local function parse_owner_repo(arg)
+ if not arg then return nil end
+ local owner, repo = arg:match('^([%w%.%-_]+)/([%w%.%-_]+)$')
+ if not owner then
+ owner, repo = arg:match('github%.com/([%w%.%-_]+)/([%w%.%-_]+)')
+ end
+ if owner and repo then
+ return owner .. '/' .. repo
+ end
+ return nil
+end
+
+-- Truncate slug for callback data (64 byte limit)
+local function cb_slug(slug)
+ if #slug > 48 then return slug:sub(1, 48) end
+ return slug
+end
+
+-- Handler dispatch table
+local handlers = {}
+
+handlers.login = function(api, message, ctx)
+ if message.chat.type ~= 'private' then
+ return api.send_message(message.chat.id, 'Please use /gh login in a private chat for security.')
+ end
+ local redis = ctx.redis
+ local user_id = message.from.id
+ if get_token(redis, user_id) then
+ return api.send_message(message.chat.id, 'You are already connected to GitHub. Use /gh logout first to reconnect.')
+ end
+ if redis.sismember(PENDING_KEY, tostring(user_id)) == 1 then
+ return api.send_message(message.chat.id, 'You already have a pending login. Please complete the current flow or wait for it to expire.')
+ end
+ local client_id = config.get('GITHUB_CLIENT_ID')
+ if not client_id or client_id == '' then
+ return api.send_message(message.chat.id, 'GitHub integration is not configured.')
+ end
+ local body = 'client_id=' .. client_id .. '&scope=repo,notifications,user'
+ local resp_body, code = http.post(DEVICE_CODE_URL, body, 'application/x-www-form-urlencoded', {
+ ['Accept'] = 'application/json',
+ })
+ if code ~= 200 or not resp_body or resp_body == '' then
+ return api.send_message(message.chat.id, 'Failed to start GitHub login. Please try again later.')
+ end
+ local data = json.decode(resp_body)
+ if not data or not data.device_code then
+ return api.send_message(message.chat.id, 'Failed to start GitHub login. Please try again later.')
+ end
+ local now = os.time()
+ local dk = device_key(user_id)
+ redis.hset(dk, 'device_code', data.device_code)
+ redis.hset(dk, 'user_code', data.user_code)
+ redis.hset(dk, 'verification_uri', data.verification_uri)
+ redis.hset(dk, 'interval', tostring(data.interval or 5))
+ redis.hset(dk, 'expires_at', tostring(now + (data.expires_in or 900)))
+ redis.hset(dk, 'chat_id', tostring(message.chat.id))
+ redis.hset(dk, 'last_poll', '0')
+ redis.expire(dk, data.expires_in or DEVICE_TTL)
+ redis.sadd(PENDING_KEY, tostring(user_id))
+ local text = string.format(
+ '<b>GitHub Login</b>\n\n'
+ .. '1. Open: %s\n'
+ .. '2. Enter code: <code>%s</code>\n\n'
+ .. 'The code expires in %d minutes.',
+ tools.escape_html(data.verification_uri),
+ tools.escape_html(data.user_code),
+ math.floor((data.expires_in or 900) / 60)
+ )
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ })
+end
+
+handlers.logout = function(api, message, ctx)
+ if message.chat.type ~= 'private' then
+ return api.send_message(message.chat.id, 'Please use /gh logout in a private chat.')
+ end
+ local redis = ctx.redis
+ local user_id = message.from.id
+ local token = get_token(redis, user_id)
+ if not token then
+ return api.send_message(message.chat.id, 'You are not connected to GitHub.')
+ end
+ local client_id = config.get('GITHUB_CLIENT_ID')
+ local client_secret = config.get('GITHUB_CLIENT_SECRET')
+ if client_id and client_secret and client_id ~= '' and client_secret ~= '' then
+ pcall(function()
+ local revoke_body = json.encode({ access_token = token })
+ http.request({
+ url = GITHUB_API .. '/applications/' .. client_id .. '/token',
+ method = 'DELETE',
+ headers = {
+ ['Accept'] = 'application/vnd.github.v3+json',
+ ['Content-Type'] = 'application/json',
+ ['Content-Length'] = tostring(#revoke_body),
+ },
+ })
+ end)
+ end
+ redis.del(token_key(user_id))
+ return api.send_message(message.chat.id, 'Your GitHub account has been disconnected.')
+end
+
+handlers.me = function(api, message, ctx)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local data = gh_api_authed(api, redis, message, '/user')
+ if not data then return end
+ local lines = {
+ string.format('<b>%s</b>', tools.escape_html(data.login or '')),
+ }
+ if data.name and data.name ~= '' then
+ table.insert(lines, (tools.escape_html(data.name)))
+ end
+ if data.bio and data.bio ~= '' then
+ table.insert(lines, '<i>' .. tools.escape_html(data.bio) .. '</i>')
+ end
+ table.insert(lines, '')
+ if data.company and data.company ~= '' then
+ table.insert(lines, 'Company: ' .. tools.escape_html(data.company))
+ end
+ if data.location and data.location ~= '' then
+ table.insert(lines, 'Location: ' .. tools.escape_html(data.location))
+ end
+ table.insert(lines, string.format('Public repos: <code>%d</code>', data.public_repos or 0))
+ table.insert(lines, string.format('Followers: <code>%d</code>', data.followers or 0))
+ table.insert(lines, string.format('Following: <code>%d</code>', data.following or 0))
+ local keyboard = api.inline_keyboard():row(
+ api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. (data.login or '')))
+ )
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.repos = function(api, message, ctx, arg)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local path
+ if arg and arg ~= '' then
+ path = string.format('/users/%s/repos?per_page=%d&sort=updated&page=1', arg, PER_PAGE)
+ else
+ path = string.format('/user/repos?per_page=%d&sort=updated&page=1', PER_PAGE)
+ end
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local user = arg or '_'
+ local text = format_repos_list(data, 1, user)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'r:' .. cb_slug(user), 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.repo = function(api, message, ctx, arg)
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Invalid repository format. Use: /gh owner/repo')
+ end
+ local redis = ctx.redis
+ local data = gh_api_authed(api, redis, message, '/repos/' .. owner_repo)
+ if not data then return end
+ local lines = format_repo(data)
+ local keyboard = api.inline_keyboard():row(
+ api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. owner_repo))
+ )
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.issues = function(api, message, ctx, arg)
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Usage: /gh issues owner/repo')
+ end
+ local redis = ctx.redis
+ local path = string.format('/repos/%s/issues?per_page=%d&state=open&page=1', owner_repo, PER_PAGE)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local text = format_issues_list(data, 1, owner_repo)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'i:' .. cb_slug(owner_repo), 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.issue = function(api, message, ctx, arg)
+ if not arg then
+ return api.send_message(message.chat.id, 'Usage: /gh issue owner/repo#123')
+ end
+ local owner_repo, number = arg:match('^([%w%.%-_]+/[%w%.%-_]+)#(%d+)$')
+ if not owner_repo or not number then
+ return api.send_message(message.chat.id, 'Invalid format. Use: /gh issue owner/repo#123')
+ end
+ local redis = ctx.redis
+ local path = string.format('/repos/%s/issues/%s', owner_repo, number)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local lines = {
+ string.format('<b>%s#%d</b>', tools.escape_html(owner_repo), data.number),
+ string.format('<b>%s</b>', tools.escape_html(data.title or '')),
+ }
+ table.insert(lines, '')
+ table.insert(lines, 'State: <code>' .. (data.state or 'unknown') .. '</code>')
+ table.insert(lines, 'Author: <code>' .. tools.escape_html(data.user and data.user.login or 'unknown') .. '</code>')
+ if data.labels and #data.labels > 0 then
+ local label_names = {}
+ for _, l in ipairs(data.labels) do table.insert(label_names, l.name) end
+ table.insert(lines, 'Labels: <code>' .. tools.escape_html(table.concat(label_names, ', ')) .. '</code>')
+ end
+ if data.assignees and #data.assignees > 0 then
+ local names = {}
+ for _, a in ipairs(data.assignees) do table.insert(names, a.login) end
+ table.insert(lines, 'Assignees: <code>' .. tools.escape_html(table.concat(names, ', ')) .. '</code>')
+ end
+ if data.comments and data.comments > 0 then
+ table.insert(lines, string.format('Comments: <code>%d</code>', data.comments))
+ end
+ if data.body and data.body ~= '' then
+ local body_text = data.body
+ if #body_text > 200 then body_text = body_text:sub(1, 197) .. '...' end
+ table.insert(lines, '')
+ table.insert(lines, (tools.escape_html(body_text)))
+ end
local keyboard = api.inline_keyboard():row(
- api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. owner .. '/' .. repo))
+ api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. owner_repo .. '/issues/' .. number))
)
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.starred = function(api, message, ctx)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local path = string.format('/user/starred?per_page=%d&page=1', PER_PAGE)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local text = format_starred_list(data, 1)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 's', 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.star = function(api, message, ctx, arg)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Usage: /gh star owner/repo')
+ end
+ local data, _ = gh_api_authed(api, redis, message, '/user/starred/' .. owner_repo, 'PUT')
+ if not data then return end
+ return api.send_message(message.chat.id,
+ string.format('Starred <b>%s</b>.', tools.escape_html(owner_repo)),
+ { parse_mode = 'html' }
+ )
+end
+
+handlers.unstar = function(api, message, ctx, arg)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Usage: /gh unstar owner/repo')
+ end
+ local data, _ = gh_api_authed(api, redis, message, '/user/starred/' .. owner_repo, 'DELETE')
+ if not data then return end
+ return api.send_message(message.chat.id,
+ string.format('Unstarred <b>%s</b>.', tools.escape_html(owner_repo)),
+ { parse_mode = 'html' }
+ )
+end
+
+handlers.notifications = function(api, message, ctx)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local path = string.format('/notifications?per_page=%d&page=1', PER_PAGE)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local text = format_notifications_list(data, 1)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'n', 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
- return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
+-- Command dispatcher
+function plugin.on_message(api, message, ctx)
+ local input = message.args
+ if not input or input == '' then
+ return api.send_message(message.chat.id, plugin.help, { parse_mode = 'html' })
+ end
+ local parts = {}
+ for word in input:gmatch('%S+') do
+ table.insert(parts, word)
+ end
+ local subcommand = parts[1]:lower()
+ local arg = parts[2]
+ if handlers[subcommand] then
+ return handlers[subcommand](api, message, ctx, arg)
+ end
+ -- Try owner/repo format
+ local owner_repo = parse_owner_repo(input)
+ if owner_repo then
+ return handlers.repo(api, message, ctx, input)
+ end
+ return api.send_message(message.chat.id, 'Unknown command. Use /gh for help.')
+end
+
+-- Callback query handler for pagination
+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 redis = ctx.redis
+ local token = get_token(redis, callback_query.from.id)
+ -- Parse callback data: type:params:page or type:page
+ local cb_parts = {}
+ for part in data:gmatch('[^:]+') do
+ table.insert(cb_parts, part)
+ end
+ local cb_type = cb_parts[1]
+ if cb_type == 'r' then
+ -- Repos: r:user:page
+ local user = cb_parts[2]
+ local page = tonumber(cb_parts[3]) or 1
+ local path
+ if user == '_' then
+ path = string.format('/user/repos?per_page=%d&sort=updated&page=%d', PER_PAGE, page)
+ else
+ path = string.format('/users/%s/repos?per_page=%d&sort=updated&page=%d', user, PER_PAGE, page)
+ end
+ local repos, _ = gh_api(path, token)
+ if not repos then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch repositories.' })
+ end
+ local text = format_repos_list(repos, page, user)
+ local has_more = #repos == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'r:' .. cb_slug(user), page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ elseif cb_type == 'i' then
+ -- Issues: i:owner/repo:page (owner/repo may contain /)
+ local page = tonumber(cb_parts[#cb_parts]) or 1
+ -- Reconstruct owner/repo from middle parts
+ local owner_repo = table.concat(cb_parts, ':', 2, #cb_parts - 1)
+ local path = string.format('/repos/%s/issues?per_page=%d&state=open&page=%d', owner_repo, PER_PAGE, page)
+ local issues, _ = gh_api(path, token)
+ if not issues then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch issues.' })
+ end
+ local text = format_issues_list(issues, page, owner_repo)
+ local has_more = #issues == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'i:' .. cb_slug(owner_repo), page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ elseif cb_type == 's' then
+ -- Starred: s:page
+ local page = tonumber(cb_parts[2]) or 1
+ local path = string.format('/user/starred?per_page=%d&page=%d', PER_PAGE, page)
+ local repos, _ = gh_api(path, token)
+ if not repos then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch starred repos.' })
+ end
+ local text = format_starred_list(repos, page)
+ local has_more = #repos == PER_PAGE
+ local keyboard = pagination_keyboard(api, 's', page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ elseif cb_type == 'n' then
+ -- Notifications: n:page
+ local page = tonumber(cb_parts[2]) or 1
+ local path = string.format('/notifications?per_page=%d&page=%d', PER_PAGE, page)
+ local notifications, _ = gh_api(path, token)
+ if not notifications then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch notifications.' })
+ end
+ local text = format_notifications_list(notifications, page)
+ local has_more = #notifications == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'n', page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ end
+ return api.answer_callback_query(callback_query.id)
+end
+
+-- Cron: poll GitHub for pending device flows
+function plugin.cron(api, ctx)
+ local redis = ctx.redis
+ local client_id = config.get('GITHUB_CLIENT_ID')
+ if not client_id or client_id == '' then return end
+ local pending = redis.smembers(PENDING_KEY)
+ if not pending or #pending == 0 then return end
+ local now = os.time()
+ local polls = 0
+ for _, uid_str in ipairs(pending) do
+ if polls >= CRON_MAX_POLLS then break end
+ local device = redis.hgetall(device_key(uid_str))
+ if not device or not device.device_code then
+ redis.srem(PENDING_KEY, uid_str)
+ else
+ local expires_at = tonumber(device.expires_at) or 0
+ if now > expires_at then
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'Your GitHub login has expired. Please try /gh login again.')
+ end)
+ end
+ else
+ local interval = tonumber(device.interval) or 5
+ local last_poll = tonumber(device.last_poll) or 0
+ if now - last_poll >= interval then
+ polls = polls + 1
+ redis.hset(device_key(uid_str), 'last_poll', tostring(now))
+ local body = string.format(
+ 'client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code',
+ client_id, device.device_code
+ )
+ local resp_body, _ = http.post(ACCESS_TOKEN_URL, body, 'application/x-www-form-urlencoded', {
+ ['Accept'] = 'application/json',
+ })
+ if resp_body and resp_body ~= '' then
+ local resp_data = json.decode(resp_body)
+ if resp_data then
+ if resp_data.access_token then
+ redis.setex(token_key(uid_str), TOKEN_TTL, resp_data.access_token)
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'GitHub account connected successfully! Use /gh me to see your profile.')
+ end)
+ end
+ elseif resp_data.error == 'slow_down' then
+ local new_interval = interval + 5
+ redis.hset(device_key(uid_str), 'interval', tostring(new_interval))
+ elseif resp_data.error == 'access_denied' then
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'GitHub login was denied.')
+ end)
+ end
+ elseif resp_data.error == 'expired_token' then
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'Your GitHub login code has expired. Please try /gh login again.')
+ end)
+ end
+ end
+ -- authorization_pending: do nothing, wait for next poll
+ end
+ end
+ end
+ end
+ end
+ end
end
return plugin
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, May 17, 2:41 AM (1 d, 21 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
62980
Default Alt Text
(141 KB)
Attached To
Mode
R69 mattata
Attached
Detach File
Event Timeline