Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
141 KB
Referenced Files
None
Subscribers
None
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('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;')
+ 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 &lt;owner/repo&gt; - View repository info',
+ '/gh issues &lt;owner/repo&gt; - List open issues',
+ '/gh issue &lt;owner/repo#123&gt; - View specific issue',
+ '/gh starred - List your starred repos',
+ '/gh star &lt;owner/repo&gt; - Star a repository',
+ '/gh unstar &lt;owner/repo&gt; - 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

Mime Type
text/x-diff
Expires
Sun, May 17, 2:41 AM (1 d, 22 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
62980
Default Alt Text
(141 KB)

Event Timeline