Page Menu
Home
Phabricator (Chris)
Search
Configure Global Search
Log In
Files
F117605
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
81 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/.env.example b/.env.example
index 89fbdb1..8c09d47 100644
--- a/.env.example
+++ b/.env.example
@@ -1,53 +1,55 @@
# mattata v2.2 Configuration
# Copy to .env and fill in required values
# Required
BOT_TOKEN=
BOT_ADMINS=221714512
BOT_NAME=mattata
# PostgreSQL (primary database)
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=mattata
DATABASE_USER=mattata
DATABASE_PASSWORD=changeme
# Redis (cache/sessions only)
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Mode: polling (default) or webhook
WEBHOOK_ENABLED=false
WEBHOOK_URL=
WEBHOOK_PORT=8443
WEBHOOK_SECRET=
POLLING_TIMEOUT=60
POLLING_LIMIT=100
# AI (disabled by default)
AI_ENABLED=false
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
# Optional API keys (core features work without any of these)
LASTFM_API_KEY=
YOUTUBE_API_KEY=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPAMWATCH_TOKEN=
TENOR_API_KEY=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
# Bot links (optional, defaults shown)
CHANNEL_URL=https://t.me/mattata
SUPPORT_URL=https://t.me/mattataSupport
GITHUB_URL=https://github.com/wrxck/mattata
DEV_URL=https://t.me/mattataDev
# Logging
LOG_CHAT=
DEBUG=false
diff --git a/spec/plugins/utility/github_spec.lua b/spec/plugins/utility/github_spec.lua
new file mode 100644
index 0000000..e842c06
--- /dev/null
+++ b/spec/plugins/utility/github_spec.lua
@@ -0,0 +1,924 @@
+describe('plugins.utility.github', function()
+ local github_plugin
+ local test_helper = require('spec.helpers.test_helper')
+ local json = require('dkjson')
+ local env, ctx, message
+
+ -- Mock state (shared across before_each via upvalues)
+ local http_responses, http_calls
+ local test_config
+
+ -- Sample API responses
+ local SAMPLE_USER = {
+ login = 'octocat', id = 1, name = 'The Octocat', bio = 'GitHub mascot',
+ public_repos = 8, followers = 1000, following = 10,
+ company = '@github', location = 'San Francisco',
+ html_url = 'https://github.com/octocat', avatar_url = 'https://avatars.githubusercontent.com/u/1',
+ }
+
+ local SAMPLE_REPO = {
+ full_name = 'octocat/Hello-World', description = 'My first repo',
+ language = 'Ruby', stargazers_count = 80, forks_count = 9,
+ open_issues_count = 2, license = { spdx_id = 'MIT' },
+ created_at = '2011-01-26T19:01:12Z', private = false,
+ html_url = 'https://github.com/octocat/Hello-World',
+ }
+
+ local SAMPLE_REPOS = {
+ { full_name = 'octocat/Hello-World', description = 'My first repo',
+ stargazers_count = 80, language = 'Ruby', private = false,
+ html_url = 'https://github.com/octocat/Hello-World' },
+ }
+
+ local SAMPLE_ISSUE = {
+ number = 1, title = 'Found a bug', state = 'open',
+ user = { login = 'octocat' },
+ labels = { { name = 'bug', color = 'fc2929' } },
+ assignees = { { login = 'octocat' } },
+ body = 'Description of the bug...',
+ html_url = 'https://github.com/octocat/Hello-World/issues/1',
+ created_at = '2011-04-22T13:33:48Z', comments = 3,
+ }
+
+ local SAMPLE_ISSUES = {
+ { number = 1, title = 'Found a bug', state = 'open',
+ user = { login = 'octocat' },
+ labels = { { name = 'bug' } },
+ html_url = 'https://github.com/octocat/Hello-World/issues/1',
+ created_at = '2011-04-22T13:33:48Z' },
+ }
+
+ local SAMPLE_STARRED = {
+ { full_name = 'octocat/Hello-World', description = 'My first repo',
+ stargazers_count = 80, html_url = 'https://github.com/octocat/Hello-World' },
+ }
+
+ local SAMPLE_NOTIFICATIONS = {
+ { id = '1', reason = 'mention', unread = true,
+ subject = { title = 'Issue title', type = 'Issue' },
+ repository = { full_name = 'octocat/Hello-World' },
+ updated_at = '2014-11-07T22:01:45Z' },
+ }
+
+ local SAMPLE_DEVICE_CODE = {
+ device_code = '3584d83530557fdd1f46af8289938c8ef79f9dc5',
+ user_code = 'WDJB-MJHT', verification_uri = 'https://github.com/login/device',
+ expires_in = 900, interval = 5,
+ }
+
+ local SAMPLE_ACCESS_TOKEN = {
+ access_token = 'gho_16C7e42F292c6912E7710c838347Ae178B4a',
+ token_type = 'bearer', scope = 'repo,notifications,user',
+ }
+
+ before_each(function()
+ http_responses = {}
+ http_calls = {}
+ test_config = {
+ GITHUB_CLIENT_ID = 'test_client_id',
+ GITHUB_CLIENT_SECRET = 'test_client_secret',
+ }
+
+ package.loaded['src.plugins.utility.github'] = nil
+ package.loaded['src.core.http'] = {
+ get = function(url, headers)
+ table.insert(http_calls, { method = 'GET', url = url, headers = headers })
+ local r = http_responses['GET:' .. url]
+ if r then return r.body or '', r.code or 200 end
+ return '', 404
+ end,
+ post = function(url, body, content_type, headers)
+ table.insert(http_calls, { method = 'POST', url = url, body = body, content_type = content_type, headers = headers })
+ local r = http_responses['POST:' .. url]
+ if r then return r.body or '', r.code or 200 end
+ return '', 404
+ end,
+ request = function(opts)
+ local m = opts.method or 'GET'
+ table.insert(http_calls, { method = m, url = opts.url, headers = opts.headers })
+ local r = http_responses[m .. ':' .. opts.url]
+ if r then return r.body or '', r.code or 200 end
+ return '', 404
+ end,
+ }
+ package.loaded['src.core.config'] = {
+ get = function(key, default)
+ if test_config[key] ~= nil then return test_config[key] end
+ return default
+ end,
+ }
+ package.loaded['src.core.logger'] = {
+ debug = function() end, info = function() end,
+ warn = function() end, error = function() end,
+ }
+ package.loaded['telegram-bot-lua.tools'] = {
+ escape_html = function(text)
+ if not text then return '' end
+ return tostring(text):gsub('&', '&'):gsub('<', '<'):gsub('>', '>')
+ end,
+ }
+
+ github_plugin = require('src.plugins.utility.github')
+ env = test_helper.setup()
+ ctx = test_helper.make_ctx(env)
+ end)
+
+ after_each(function()
+ test_helper.teardown(env)
+ end)
+
+ -- Helper to set mock HTTP responses
+ local function mock_get(url, data, code)
+ http_responses['GET:' .. url] = { body = json.encode(data), code = code or 200 }
+ end
+ local function mock_get_raw(url, body, code)
+ http_responses['GET:' .. url] = { body = body, code = code or 200 }
+ end
+ local function mock_post(url, data, code)
+ http_responses['POST:' .. url] = { body = json.encode(data), code = code or 200 }
+ end
+ local function mock_put(url, body, code)
+ http_responses['PUT:' .. url] = { body = body or '', code = code or 204 }
+ end
+ local function mock_delete(url, body, code)
+ http_responses['DELETE:' .. url] = { body = body or '', code = code or 204 }
+ end
+
+ -- Helper: store a token in redis for the test user
+ local function store_token(user_id)
+ user_id = user_id or 111111
+ env.redis.set('github:token:' .. user_id, 'test_token_123')
+ end
+
+ -- Helper: set up pending device flow
+ local function store_pending_device(user_id, overrides)
+ user_id = user_id or 111111
+ local uid = tostring(user_id)
+ local dk = 'github:device:' .. uid
+ local defaults = {
+ device_code = 'test_device_code',
+ user_code = 'TEST-CODE',
+ verification_uri = 'https://github.com/login/device',
+ interval = '5',
+ expires_at = tostring(os.time() + 600),
+ chat_id = tostring(user_id),
+ last_poll = '0',
+ }
+ if overrides then
+ for k, v in pairs(overrides) do defaults[k] = v end
+ end
+ for k, v in pairs(defaults) do
+ env.redis.hset(dk, k, v)
+ end
+ env.redis.sadd('github:pending_devices', uid)
+ end
+
+ -- ================================================================
+ -- 1. Plugin metadata
+ -- ================================================================
+ describe('plugin metadata', function()
+ it('should have correct name', function()
+ assert.are.equal('github', github_plugin.name)
+ end)
+
+ it('should have correct category', function()
+ assert.are.equal('utility', github_plugin.category)
+ end)
+
+ it('should have commands table with github and gh', function()
+ assert.is_table(github_plugin.commands)
+ assert.is_true(#github_plugin.commands >= 2)
+ end)
+
+ it('should have a help string', function()
+ assert.is_string(github_plugin.help)
+ assert.is_true(#github_plugin.help > 0)
+ end)
+
+ it('should have a description', function()
+ assert.is_string(github_plugin.description)
+ end)
+ end)
+
+ -- ================================================================
+ -- 2. Dispatch
+ -- ================================================================
+ describe('dispatch', function()
+ it('should show help with HTML parse mode when no args', function()
+ message = test_helper.make_message({ text = '/gh', command = 'gh', args = '' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_api_called(env.api, 'send_message')
+ local call = env.api.get_call('send_message')
+ assert.are.equal(message.chat.id, call.args[1])
+ assert.is_truthy(call.args[2]:match('login'))
+ assert.are.equal('html', call.args[3].parse_mode)
+ end)
+
+ it('should show help for unknown subcommand', function()
+ message = test_helper.make_message({ text = '/gh foobar', command = 'gh', args = 'foobar' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Unknown command')
+ end)
+
+ it('should route owner/repo to repo handler', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat/Hello%-World')
+ end)
+ end)
+
+ -- ================================================================
+ -- 3. /gh login
+ -- ================================================================
+ describe('/gh login', function()
+ it('should refuse in group chat', function()
+ message = test_helper.make_message({ text = '/gh login', command = 'gh', args = 'login' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'private chat')
+ end)
+
+ it('should refuse if already connected', function()
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ store_token(message.from.id)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'already connected')
+ end)
+
+ it('should refuse if already pending', function()
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ store_pending_device(message.from.id)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'pending login')
+ end)
+
+ it('should refuse if config is missing', function()
+ test_config.GITHUB_CLIENT_ID = nil
+ -- Re-require with new config
+ package.loaded['src.plugins.utility.github'] = nil
+ github_plugin = require('src.plugins.utility.github')
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not configured')
+ end)
+
+ it('should start device flow on success', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ -- Should store device flow in redis
+ local dk = 'github:device:' .. message.from.id
+ assert.are.equal(SAMPLE_DEVICE_CODE.device_code, env.redis.hget(dk, 'device_code'))
+ assert.are.equal(SAMPLE_DEVICE_CODE.user_code, env.redis.hget(dk, 'user_code'))
+ -- Should send verification message
+ test_helper.assert_sent_message_matches(env.api, 'WDJB%-MJHT')
+ end)
+
+ it('should handle GitHub API failure', function()
+ http_responses['POST:https://github.com/login/device/code'] = { body = '', code = 500 }
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to start')
+ end)
+
+ it('should set correct TTL on device key', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ local dk = 'github:device:' .. message.from.id
+ assert.are.equal(900, env.redis.ttls[dk])
+ end)
+
+ it('should add user to pending set', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ assert.are.equal(1, env.redis.sismember('github:pending_devices', tostring(message.from.id)))
+ end)
+
+ it('should send properly formatted verification message', function()
+ mock_post('https://github.com/login/device/code', SAMPLE_DEVICE_CODE)
+ message = test_helper.make_private_message({ text = '/gh login', command = 'gh', args = 'login' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_truthy(call.args[2]:match('GitHub Login'))
+ assert.is_truthy(call.args[2]:match('github.com/login/device'))
+ assert.is_truthy(call.args[2]:match('15 minutes'))
+ assert.are.equal('html', call.args[3].parse_mode)
+ end)
+ end)
+
+ -- ================================================================
+ -- 4. /gh logout
+ -- ================================================================
+ describe('/gh logout', function()
+ it('should refuse in group chat', function()
+ message = test_helper.make_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'private chat')
+ end)
+
+ it('should refuse if not connected', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'not connected')
+ end)
+
+ it('should delete token from redis', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ store_token(message.from.id)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'disconnected')
+ end)
+
+ it('should attempt token revocation', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ store_token(message.from.id)
+ mock_delete('https://api.github.com/applications/test_client_id/token', '', 204)
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ -- Should have attempted the DELETE request
+ local found = false
+ for _, c in ipairs(http_calls) do
+ if c.method == 'DELETE' and c.url:match('applications') then
+ found = true
+ break
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it('should handle revocation failure gracefully', function()
+ message = test_helper.make_private_message({ text = '/gh logout', command = 'gh', args = 'logout' })
+ store_token(message.from.id)
+ -- Don't set up a mock response — will 404/error
+ ctx.is_private = true
+ github_plugin.on_message(env.api, message, ctx)
+ -- Should still delete token and send success
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'disconnected')
+ end)
+ end)
+
+ -- ================================================================
+ -- 5. /gh me
+ -- ================================================================
+ describe('/gh me', function()
+ it('should show error when no token', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'connect your GitHub')
+ end)
+
+ it('should format user profile', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user', SAMPLE_USER)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('octocat'))
+ assert.is_truthy(text:match('The Octocat'))
+ assert.is_truthy(text:match('GitHub mascot'))
+ assert.is_truthy(text:match('@github'))
+ assert.is_truthy(text:match('San Francisco'))
+ assert.is_truthy(text:match('1000'))
+ end)
+
+ it('should handle API failure', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user', '', 500)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to reach')
+ end)
+
+ it('should clear token on 401', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user', '', 401)
+ github_plugin.on_message(env.api, message, ctx)
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+
+ it('should send with HTML parse mode', function()
+ message = test_helper.make_message({ text = '/gh me', command = 'gh', args = 'me' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user', SAMPLE_USER)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.are.equal('html', call.args[3].parse_mode)
+ end)
+ end)
+
+ -- ================================================================
+ -- 6. /gh repos
+ -- ================================================================
+ describe('/gh repos', function()
+ it('should list own repos', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', SAMPLE_REPOS)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Your Repositories')
+ test_helper.assert_sent_message_matches(env.api, 'Hello%-World')
+ end)
+
+ it('should list specified user repos', function()
+ message = test_helper.make_message({ text = '/gh repos octocat', command = 'gh', args = 'repos octocat' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/users/octocat/repos?per_page=5&sort=updated&page=1', SAMPLE_REPOS)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat')
+ end)
+
+ it('should handle empty list', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No repositories')
+ end)
+
+ it('should show pagination keyboard when has more', function()
+ -- Return exactly PER_PAGE items to trigger has_more
+ local repos = {}
+ for i = 1, 5 do
+ table.insert(repos, { full_name = 'user/repo-' .. i, stargazers_count = i })
+ end
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', repos)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+
+ it('should handle API failure', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 500)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to reach')
+ end)
+ end)
+
+ -- ================================================================
+ -- 7. /gh owner/repo
+ -- ================================================================
+ describe('/gh owner/repo', function()
+ it('should format repo info', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('octocat/Hello%-World'))
+ assert.is_truthy(text:match('My first repo'))
+ assert.is_truthy(text:match('Ruby'))
+ assert.is_truthy(text:match('80'))
+ assert.is_truthy(text:match('MIT'))
+ end)
+
+ it('should use token if available', function()
+ store_token(111111)
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ -- Check that auth header was set
+ local found_auth = false
+ for _, c in ipairs(http_calls) do
+ if c.headers and c.headers['Authorization'] then
+ found_auth = true
+ break
+ end
+ end
+ assert.is_true(found_auth)
+ end)
+
+ it('should work without token', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({ text = '/gh octocat/Hello-World', command = 'gh', args = 'octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat/Hello%-World')
+ end)
+
+ it('should handle not found', function()
+ mock_get_raw('https://api.github.com/repos/octocat/nope', '', 404)
+ message = test_helper.make_message({ text = '/gh octocat/nope', command = 'gh', args = 'octocat/nope' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Not found')
+ end)
+
+ it('should accept GitHub URL format', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World', SAMPLE_REPO)
+ message = test_helper.make_message({
+ text = '/gh https://github.com/octocat/Hello-World',
+ command = 'gh', args = 'https://github.com/octocat/Hello-World',
+ })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'octocat/Hello%-World')
+ end)
+ end)
+
+ -- ================================================================
+ -- 8. /gh issues
+ -- ================================================================
+ describe('/gh issues', function()
+ it('should list open issues', function()
+ message = test_helper.make_message({ text = '/gh issues octocat/Hello-World', command = 'gh', args = 'issues octocat/Hello-World' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=1', SAMPLE_ISSUES)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Found a bug')
+ end)
+
+ it('should handle empty issues list', function()
+ message = test_helper.make_message({ text = '/gh issues octocat/Hello-World', command = 'gh', args = 'issues octocat/Hello-World' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No open issues')
+ end)
+
+ it('should show pagination keyboard when has more', function()
+ local issues = {}
+ for i = 1, 5 do
+ table.insert(issues, { number = i, title = 'Issue ' .. i, state = 'open',
+ user = { login = 'test' }, labels = {}, created_at = '2024-01-01T00:00:00Z' })
+ end
+ message = test_helper.make_message({ text = '/gh issues octocat/Hello-World', command = 'gh', args = 'issues octocat/Hello-World' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=1', issues)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+
+ it('should require owner/repo argument', function()
+ message = test_helper.make_message({ text = '/gh issues', command = 'gh', args = 'issues' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Usage')
+ end)
+ end)
+
+ -- ================================================================
+ -- 9. /gh issue
+ -- ================================================================
+ describe('/gh issue', function()
+ it('should show issue details', function()
+ message = test_helper.make_message({ text = '/gh issue octocat/Hello-World#1', command = 'gh', args = 'issue octocat/Hello-World#1' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues/1', SAMPLE_ISSUE)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('Found a bug'))
+ assert.is_truthy(text:match('open'))
+ assert.is_truthy(text:match('octocat'))
+ end)
+
+ it('should truncate long body', function()
+ local long_issue = {
+ number = 1, title = 'Bug', state = 'open',
+ user = { login = 'test' }, labels = {}, assignees = {},
+ body = string.rep('x', 300),
+ html_url = 'https://github.com/test/test/issues/1',
+ created_at = '2024-01-01T00:00:00Z', comments = 0,
+ }
+ message = test_helper.make_message({ text = '/gh issue test/test#1', command = 'gh', args = 'issue test/test#1' })
+ mock_get('https://api.github.com/repos/test/test/issues/1', long_issue)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_truthy(call.args[2]:match('%.%.%.'))
+ end)
+
+ it('should handle not found', function()
+ message = test_helper.make_message({ text = '/gh issue octocat/Hello-World#999', command = 'gh', args = 'issue octocat/Hello-World#999' })
+ mock_get_raw('https://api.github.com/repos/octocat/Hello-World/issues/999', '', 404)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Not found')
+ end)
+
+ it('should show labels and state', function()
+ message = test_helper.make_message({ text = '/gh issue octocat/Hello-World#1', command = 'gh', args = 'issue octocat/Hello-World#1' })
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues/1', SAMPLE_ISSUE)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ local text = call.args[2]
+ assert.is_truthy(text:match('bug'))
+ assert.is_truthy(text:match('open'))
+ end)
+ end)
+
+ -- ================================================================
+ -- 10. /gh starred
+ -- ================================================================
+ describe('/gh starred', function()
+ it('should list starred repos', function()
+ message = test_helper.make_message({ text = '/gh starred', command = 'gh', args = 'starred' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/starred?per_page=5&page=1', SAMPLE_STARRED)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Starred Repositories')
+ test_helper.assert_sent_message_matches(env.api, 'Hello%-World')
+ end)
+
+ it('should handle empty starred list', function()
+ message = test_helper.make_message({ text = '/gh starred', command = 'gh', args = 'starred' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/starred?per_page=5&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No starred')
+ end)
+
+ it('should show pagination when has more', function()
+ local repos = {}
+ for i = 1, 5 do
+ table.insert(repos, { full_name = 'user/repo-' .. i, stargazers_count = i })
+ end
+ message = test_helper.make_message({ text = '/gh starred', command = 'gh', args = 'starred' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/user/starred?per_page=5&page=1', repos)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+ end)
+
+ -- ================================================================
+ -- 11. /gh star + /gh unstar
+ -- ================================================================
+ describe('/gh star and /gh unstar', function()
+ it('should star a repo with PUT and confirm', function()
+ message = test_helper.make_message({ text = '/gh star octocat/Hello-World', command = 'gh', args = 'star octocat/Hello-World' })
+ store_token(message.from.id)
+ mock_put('https://api.github.com/user/starred/octocat/Hello-World', '', 204)
+ github_plugin.on_message(env.api, message, ctx)
+ -- Check PUT was made
+ local found_put = false
+ for _, c in ipairs(http_calls) do
+ if c.method == 'PUT' and c.url:match('starred/octocat') then found_put = true end
+ end
+ assert.is_true(found_put)
+ test_helper.assert_sent_message_matches(env.api, 'Starred')
+ end)
+
+ it('should unstar a repo with DELETE and confirm', function()
+ message = test_helper.make_message({ text = '/gh unstar octocat/Hello-World', command = 'gh', args = 'unstar octocat/Hello-World' })
+ store_token(message.from.id)
+ mock_delete('https://api.github.com/user/starred/octocat/Hello-World', '', 204)
+ github_plugin.on_message(env.api, message, ctx)
+ local found_delete = false
+ for _, c in ipairs(http_calls) do
+ if c.method == 'DELETE' and c.url:match('starred/octocat') then found_delete = true end
+ end
+ assert.is_true(found_delete)
+ test_helper.assert_sent_message_matches(env.api, 'Unstarred')
+ end)
+
+ it('should require auth for star and unstar', function()
+ message = test_helper.make_message({ text = '/gh star octocat/Hello-World', command = 'gh', args = 'star octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'connect your GitHub')
+ env.api.reset()
+ message = test_helper.make_message({ text = '/gh unstar octocat/Hello-World', command = 'gh', args = 'unstar octocat/Hello-World' })
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'connect your GitHub')
+ end)
+
+ it('should require owner/repo argument', function()
+ message = test_helper.make_message({ text = '/gh star', command = 'gh', args = 'star' })
+ store_token(message.from.id)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Usage')
+ end)
+ end)
+
+ -- ================================================================
+ -- 12. /gh notifications
+ -- ================================================================
+ describe('/gh notifications', function()
+ it('should list unread notifications', function()
+ message = test_helper.make_message({ text = '/gh notifications', command = 'gh', args = 'notifications' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/notifications?per_page=5&page=1', SAMPLE_NOTIFICATIONS)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Issue title')
+ test_helper.assert_sent_message_matches(env.api, 'mention')
+ end)
+
+ it('should handle empty notifications', function()
+ message = test_helper.make_message({ text = '/gh notifications', command = 'gh', args = 'notifications' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/notifications?per_page=5&page=1', {})
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'No unread')
+ end)
+
+ it('should show pagination when has more', function()
+ local notifs = {}
+ for i = 1, 5 do
+ table.insert(notifs, {
+ id = tostring(i), reason = 'mention', unread = true,
+ subject = { title = 'Notif ' .. i, type = 'Issue' },
+ repository = { full_name = 'user/repo' },
+ updated_at = '2024-01-01T00:00:00Z',
+ })
+ end
+ message = test_helper.make_message({ text = '/gh notifications', command = 'gh', args = 'notifications' })
+ store_token(message.from.id)
+ mock_get('https://api.github.com/notifications?per_page=5&page=1', notifs)
+ github_plugin.on_message(env.api, message, ctx)
+ local call = env.api.get_call('send_message')
+ assert.is_not_nil(call.args[3].reply_markup)
+ end)
+ end)
+
+ -- ================================================================
+ -- 13. Cron polling
+ -- ================================================================
+ describe('cron', function()
+ it('should skip when no pending devices', function()
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, #http_calls)
+ end)
+
+ it('should skip when config is missing', function()
+ test_config.GITHUB_CLIENT_ID = nil
+ package.loaded['src.plugins.utility.github'] = nil
+ github_plugin = require('src.plugins.utility.github')
+ store_pending_device(111111)
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, #http_calls)
+ end)
+
+ it('should continue on authorization_pending', function()
+ store_pending_device(111111, { last_poll = '0' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'authorization_pending',
+ error_description = 'The authorization request is still pending.',
+ })
+ github_plugin.cron(env.api, ctx)
+ -- Should still be pending
+ assert.are.equal(1, env.redis.sismember('github:pending_devices', '111111'))
+ -- Device key should still exist
+ assert.is_truthy(env.redis.hget('github:device:111111', 'device_code'))
+ end)
+
+ it('should store token and notify on access_token', function()
+ store_pending_device(111111, { last_poll = '0', chat_id = '111111' })
+ mock_post('https://github.com/login/oauth/access_token', SAMPLE_ACCESS_TOKEN)
+ github_plugin.cron(env.api, ctx)
+ -- Token should be stored
+ assert.are.equal(SAMPLE_ACCESS_TOKEN.access_token, env.redis.get('github:token:111111'))
+ -- Device should be cleaned up
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ -- User should be notified
+ test_helper.assert_sent_message_matches(env.api, 'connected successfully')
+ end)
+
+ it('should increase interval on slow_down', function()
+ store_pending_device(111111, { last_poll = '0', interval = '5' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'slow_down',
+ error_description = 'Too many requests.',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal('10', env.redis.hget('github:device:111111', 'interval'))
+ end)
+
+ it('should clean up on expired_token', function()
+ store_pending_device(111111, { last_poll = '0', chat_id = '111111' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'expired_token',
+ error_description = 'The device code has expired.',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+
+ it('should clean up on access_denied', function()
+ store_pending_device(111111, { last_poll = '0', chat_id = '111111' })
+ mock_post('https://github.com/login/oauth/access_token', {
+ error = 'access_denied',
+ error_description = 'The user denied the request.',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ test_helper.assert_sent_message_matches(env.api, 'denied')
+ end)
+
+ it('should respect interval between polls', function()
+ local now = os.time()
+ store_pending_device(111111, { last_poll = tostring(now), interval = '60' })
+ github_plugin.cron(env.api, ctx)
+ -- Should not have made any HTTP calls since last_poll is now and interval is 60s
+ assert.are.equal(0, #http_calls)
+ end)
+
+ it('should remove expired flows', function()
+ store_pending_device(111111, {
+ last_poll = '0',
+ expires_at = tostring(os.time() - 100), -- already expired
+ chat_id = '111111',
+ })
+ github_plugin.cron(env.api, ctx)
+ assert.are.equal(0, env.redis.sismember('github:pending_devices', '111111'))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+ end)
+
+ -- ================================================================
+ -- 14. Callback queries
+ -- ================================================================
+ describe('callback queries', function()
+ it('should handle repos pagination', function()
+ store_token(111111)
+ mock_get('https://api.github.com/user/repos?per_page=5&sort=updated&page=2', SAMPLE_REPOS)
+ local cb = test_helper.make_callback_query({ data = 'r:_:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'edit_message_text')
+ test_helper.assert_api_called(env.api, 'answer_callback_query')
+ end)
+
+ it('should handle issues pagination', function()
+ mock_get('https://api.github.com/repos/octocat/Hello-World/issues?per_page=5&state=open&page=2', SAMPLE_ISSUES)
+ local cb = test_helper.make_callback_query({ data = 'i:octocat/Hello-World:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'edit_message_text')
+ end)
+
+ it('should handle noop', function()
+ local cb = test_helper.make_callback_query({ data = 'noop' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'answer_callback_query')
+ test_helper.assert_api_not_called(env.api, 'edit_message_text')
+ end)
+
+ it('should always answer callback query', function()
+ mock_get('https://api.github.com/user/starred?per_page=5&page=2', SAMPLE_STARRED)
+ store_token(111111)
+ local cb = test_helper.make_callback_query({ data = 's:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ test_helper.assert_api_called(env.api, 'answer_callback_query')
+ end)
+
+ it('should edit message with updated content', function()
+ mock_get('https://api.github.com/notifications?per_page=5&page=2', SAMPLE_NOTIFICATIONS)
+ store_token(111111)
+ local cb = test_helper.make_callback_query({ data = 'n:2' })
+ github_plugin.on_callback_query(env.api, cb, cb.message, ctx)
+ local call = env.api.get_call('edit_message_text')
+ assert.is_not_nil(call)
+ assert.is_truthy(call.args[3]:match('Issue title'))
+ assert.are.equal('html', call.args[4].parse_mode)
+ end)
+ end)
+
+ -- ================================================================
+ -- 15. Error handling
+ -- ================================================================
+ describe('error handling', function()
+ it('should clear token on 401 response', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 401)
+ github_plugin.on_message(env.api, message, ctx)
+ assert.is_nil(env.redis.get('github:token:' .. message.from.id))
+ test_helper.assert_sent_message_matches(env.api, 'expired')
+ end)
+
+ it('should show rate limit message on 403', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 403)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'rate limit')
+ end)
+
+ it('should show not found on 404', function()
+ message = test_helper.make_message({ text = '/gh octocat/nonexistent', command = 'gh', args = 'octocat/nonexistent' })
+ mock_get_raw('https://api.github.com/repos/octocat/nonexistent', '', 404)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Not found')
+ end)
+
+ it('should show generic error on network failure', function()
+ message = test_helper.make_message({ text = '/gh repos', command = 'gh', args = 'repos' })
+ store_token(message.from.id)
+ mock_get_raw('https://api.github.com/user/repos?per_page=5&sort=updated&page=1', '', 0)
+ github_plugin.on_message(env.api, message, ctx)
+ test_helper.assert_sent_message_matches(env.api, 'Failed to reach')
+ end)
+ end)
+end)
diff --git a/src/plugins/utility/github.lua b/src/plugins/utility/github.lua
index 7795938..f4cc0ae 100644
--- a/src/plugins/utility/github.lua
+++ b/src/plugins/utility/github.lua
@@ -1,75 +1,770 @@
--[[
mattata v2.0 - GitHub Plugin
- Fetches information about a GitHub repository.
+ Full GitHub integration with OAuth Device Flow authentication,
+ multiple subcommands, pagination, and cron-based auth polling.
]]
local plugin = {}
plugin.name = 'github'
plugin.category = 'utility'
-plugin.description = 'View information about a GitHub repository'
+plugin.description = 'GitHub integration with OAuth authentication'
plugin.commands = { 'github', 'gh' }
-plugin.help = '/gh <owner/repo> - View information about a GitHub repository.'
+plugin.help = table.concat({
+ '/gh login - Connect your GitHub account (PM only)',
+ '/gh logout - Disconnect your GitHub account (PM only)',
+ '/gh me - View your GitHub profile',
+ '/gh repos [user] - List repositories',
+ '/gh <owner/repo> - View repository info',
+ '/gh issues <owner/repo> - List open issues',
+ '/gh issue <owner/repo#123> - View specific issue',
+ '/gh starred - List your starred repos',
+ '/gh star <owner/repo> - Star a repository',
+ '/gh unstar <owner/repo> - Unstar a repository',
+ '/gh notifications - View unread notifications',
+}, '\n')
-function plugin.on_message(api, message, ctx)
- local http = require('src.core.http')
- local tools = require('telegram-bot-lua.tools')
+local http = require('src.core.http')
+local config = require('src.core.config')
+local json = require('dkjson')
+local tools = require('telegram-bot-lua.tools')
- local input = message.args
- if not input or input == '' then
- return api.send_message(message.chat.id, 'Please specify a repository. Usage: /gh <owner/repo>')
- end
- -- Extract owner/repo from various input formats
- local owner, repo = input:match('^([%w%.%-_]+)/([%w%.%-_]+)$')
- if not owner then
- -- Try extracting from a full GitHub URL
- owner, repo = input:match('github%.com/([%w%.%-_]+)/([%w%.%-_]+)')
+-- Constants
+local GITHUB_API = 'https://api.github.com'
+local DEVICE_CODE_URL = 'https://github.com/login/device/code'
+local ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
+local PER_PAGE = 5
+local TOKEN_TTL = 31536000 -- 1 year
+local DEVICE_TTL = 900 -- 15 minutes
+local CRON_MAX_POLLS = 10
+
+-- Redis key helpers
+local function token_key(user_id)
+ return 'github:token:' .. tostring(user_id)
+end
+
+local function device_key(user_id)
+ return 'github:device:' .. tostring(user_id)
+end
+
+local PENDING_KEY = 'github:pending_devices'
+
+-- Generic GitHub API caller
+local function gh_api(path, token, method, body)
+ local url = path:match('^https?://') and path or (GITHUB_API .. path)
+ local headers = { ['Accept'] = 'application/vnd.github.v3+json' }
+ if token then
+ headers['Authorization'] = 'Bearer ' .. token
end
- if not owner or not repo then
- return api.send_message(message.chat.id, 'Invalid repository format. Use: /gh owner/repo')
+ method = method or 'GET'
+ if method == 'GET' then
+ local resp, code = http.get(url, headers)
+ if code ~= 200 then return nil, code end
+ if not resp or resp == '' then return nil, code end
+ return json.decode(resp), code
+ elseif method == 'POST' then
+ local req_body = body or ''
+ if type(body) == 'table' then req_body = json.encode(body) end
+ local resp, code = http.post(url, req_body, 'application/json', headers)
+ if code ~= 200 and code ~= 201 then return nil, code end
+ if not resp or resp == '' then return nil, code end
+ return json.decode(resp), code
+ else
+ -- PUT, DELETE (no body needed for our use cases)
+ headers['Content-Length'] = '0'
+ local resp, code = http.request({
+ url = url,
+ method = method,
+ headers = headers,
+ })
+ if code == 204 then return true, code end
+ if code ~= 200 then return nil, code end
+ if not resp or resp == '' then return true, code end
+ return json.decode(resp), code
end
+end
- local api_url = string.format('https://api.github.com/repos/%s/%s', owner, repo)
- local data, _ = http.get_json(api_url, {
- ['Accept'] = 'application/vnd.github.v3+json'
- })
- if not data then
- return api.send_message(message.chat.id, 'Repository not found or GitHub API is unavailable.')
+-- Retrieve stored token
+local function get_token(redis, user_id)
+ return redis.get(token_key(user_id))
+end
+
+-- Get token or send error message
+local function require_token(api, redis, message)
+ local token = get_token(redis, message.from.id)
+ if not token then
+ api.send_message(message.chat.id, 'You need to connect your GitHub account first. Use /gh login in a private chat.')
+ return nil
end
- if not data or data.message then
- return api.send_message(message.chat.id, 'Repository not found: ' .. (data and data.message or 'unknown error'))
+ return token
+end
+
+-- Authed API call with error handling
+local function gh_api_authed(api, redis, message, path, method, body)
+ local token = get_token(redis, message.from.id)
+ local data, code = gh_api(path, token, method, body)
+ if code == 401 then
+ if token then redis.del(token_key(message.from.id)) end
+ api.send_message(message.chat.id, 'Your GitHub token has expired. Please /gh login again.')
+ return nil, code
+ elseif code == 403 then
+ api.send_message(message.chat.id, 'GitHub API rate limit exceeded or insufficient permissions.')
+ return nil, code
+ elseif code == 404 then
+ api.send_message(message.chat.id, 'Not found on GitHub.')
+ return nil, code
+ elseif not data then
+ api.send_message(message.chat.id, 'Failed to reach the GitHub API. Please try again later.')
+ return nil, code or 0
end
+ return data, code
+end
+-- Format full repo info as HTML lines
+local function format_repo(data)
local lines = {
- string.format('<b>%s</b>', tools.escape_html(data.full_name or (owner .. '/' .. repo)))
+ string.format('<b>%s</b>', tools.escape_html(data.full_name or '')),
}
-
if data.description and data.description ~= '' then
- table.insert(lines, tools.escape_html(data.description))
+ table.insert(lines, (tools.escape_html(data.description)))
end
-
table.insert(lines, '')
-
+ if data.private then
+ table.insert(lines, 'Visibility: <code>private</code>')
+ end
if data.language then
table.insert(lines, 'Language: <code>' .. tools.escape_html(data.language) .. '</code>')
end
table.insert(lines, string.format('Stars: <code>%s</code>', data.stargazers_count or 0))
table.insert(lines, string.format('Forks: <code>%s</code>', data.forks_count or 0))
table.insert(lines, string.format('Open issues: <code>%s</code>', data.open_issues_count or 0))
-
if data.license and data.license.spdx_id then
table.insert(lines, 'License: <code>' .. tools.escape_html(data.license.spdx_id) .. '</code>')
end
-
if data.created_at then
table.insert(lines, 'Created: <code>' .. data.created_at:sub(1, 10) .. '</code>')
end
+ return lines
+end
+-- Build pagination keyboard with prev/next buttons
+local function pagination_keyboard(api, prefix, page, has_more)
+ if page == 1 and not has_more then return nil end
+ local keyboard = api.inline_keyboard()
+ local row = api.row()
+ if page > 1 then
+ row:callback_data_button('< Prev', string.format('github:%s:%d', prefix, page - 1))
+ end
+ row:callback_data_button('Page ' .. page, 'github:noop')
+ if has_more then
+ row:callback_data_button('Next >', string.format('github:%s:%d', prefix, page + 1))
+ end
+ keyboard:row(row)
+ return keyboard
+end
+
+-- Format repos list
+local function format_repos_list(repos, page, user)
+ local title = user and user ~= '_'
+ and string.format('<b>%s\'s Repositories</b>', tools.escape_html(user))
+ or '<b>Your Repositories</b>'
+ local lines = { title .. ' (Page ' .. page .. ')' }
+ if #repos == 0 then
+ table.insert(lines, '\nNo repositories found.')
+ return table.concat(lines, '\n')
+ end
+ for _, r in ipairs(repos) do
+ table.insert(lines, '')
+ local name = '<b>' .. tools.escape_html(r.full_name or '') .. '</b>'
+ if r.private then name = name .. ' [private]' end
+ table.insert(lines, name)
+ if r.description and r.description ~= '' then
+ local desc = r.description
+ if #desc > 80 then desc = desc:sub(1, 77) .. '...' end
+ table.insert(lines, (tools.escape_html(desc)))
+ end
+ local meta = {}
+ if r.language then table.insert(meta, r.language) end
+ table.insert(meta, tostring(r.stargazers_count or 0) .. ' stars')
+ table.insert(lines, table.concat(meta, ' | '))
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Format issues list
+local function format_issues_list(issues, page, owner_repo)
+ local lines = { string.format('<b>Open Issues — %s</b> (Page %d)', tools.escape_html(owner_repo), page) }
+ if #issues == 0 then
+ table.insert(lines, '\nNo open issues found.')
+ return table.concat(lines, '\n')
+ end
+ for _, issue in ipairs(issues) do
+ table.insert(lines, '')
+ local labels_str = ''
+ if issue.labels and #issue.labels > 0 then
+ local names = {}
+ for _, l in ipairs(issue.labels) do table.insert(names, l.name) end
+ labels_str = ' [' .. table.concat(names, ', ') .. ']'
+ end
+ table.insert(lines, string.format(
+ '#%d <b>%s</b>%s',
+ issue.number,
+ tools.escape_html(issue.title or ''),
+ tools.escape_html(labels_str)
+ ))
+ table.insert(lines, string.format('by %s — %s',
+ tools.escape_html(issue.user and issue.user.login or 'unknown'),
+ (issue.created_at or ''):sub(1, 10)
+ ))
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Format starred repos list
+local function format_starred_list(repos, page)
+ local lines = { '<b>Starred Repositories</b> (Page ' .. page .. ')' }
+ if #repos == 0 then
+ table.insert(lines, '\nNo starred repositories.')
+ return table.concat(lines, '\n')
+ end
+ for _, r in ipairs(repos) do
+ table.insert(lines, '')
+ table.insert(lines, '<b>' .. tools.escape_html(r.full_name or '') .. '</b>')
+ if r.description and r.description ~= '' then
+ local desc = r.description
+ if #desc > 80 then desc = desc:sub(1, 77) .. '...' end
+ table.insert(lines, (tools.escape_html(desc)))
+ end
+ table.insert(lines, tostring(r.stargazers_count or 0) .. ' stars')
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Format notifications list
+local function format_notifications_list(notifications, page)
+ local lines = { '<b>Unread Notifications</b> (Page ' .. page .. ')' }
+ if #notifications == 0 then
+ table.insert(lines, '\nNo unread notifications.')
+ return table.concat(lines, '\n')
+ end
+ for _, n in ipairs(notifications) do
+ table.insert(lines, '')
+ table.insert(lines, string.format(
+ '<b>[%s]</b> %s',
+ tools.escape_html(n.subject and n.subject.type or 'Unknown'),
+ tools.escape_html(n.subject and n.subject.title or '')
+ ))
+ table.insert(lines, string.format(
+ '%s — %s',
+ tools.escape_html(n.repository and n.repository.full_name or ''),
+ tools.escape_html(n.reason or '')
+ ))
+ end
+ return table.concat(lines, '\n')
+end
+
+-- Parse owner/repo from argument
+local function parse_owner_repo(arg)
+ if not arg then return nil end
+ local owner, repo = arg:match('^([%w%.%-_]+)/([%w%.%-_]+)$')
+ if not owner then
+ owner, repo = arg:match('github%.com/([%w%.%-_]+)/([%w%.%-_]+)')
+ end
+ if owner and repo then
+ return owner .. '/' .. repo
+ end
+ return nil
+end
+
+-- Truncate slug for callback data (64 byte limit)
+local function cb_slug(slug)
+ if #slug > 48 then return slug:sub(1, 48) end
+ return slug
+end
+
+-- Handler dispatch table
+local handlers = {}
+
+handlers.login = function(api, message, ctx)
+ if message.chat.type ~= 'private' then
+ return api.send_message(message.chat.id, 'Please use /gh login in a private chat for security.')
+ end
+ local redis = ctx.redis
+ local user_id = message.from.id
+ if get_token(redis, user_id) then
+ return api.send_message(message.chat.id, 'You are already connected to GitHub. Use /gh logout first to reconnect.')
+ end
+ if redis.sismember(PENDING_KEY, tostring(user_id)) == 1 then
+ return api.send_message(message.chat.id, 'You already have a pending login. Please complete the current flow or wait for it to expire.')
+ end
+ local client_id = config.get('GITHUB_CLIENT_ID')
+ if not client_id or client_id == '' then
+ return api.send_message(message.chat.id, 'GitHub integration is not configured.')
+ end
+ local body = 'client_id=' .. client_id .. '&scope=repo,notifications,user'
+ local resp_body, code = http.post(DEVICE_CODE_URL, body, 'application/x-www-form-urlencoded', {
+ ['Accept'] = 'application/json',
+ })
+ if code ~= 200 or not resp_body or resp_body == '' then
+ return api.send_message(message.chat.id, 'Failed to start GitHub login. Please try again later.')
+ end
+ local data = json.decode(resp_body)
+ if not data or not data.device_code then
+ return api.send_message(message.chat.id, 'Failed to start GitHub login. Please try again later.')
+ end
+ local now = os.time()
+ local dk = device_key(user_id)
+ redis.hset(dk, 'device_code', data.device_code)
+ redis.hset(dk, 'user_code', data.user_code)
+ redis.hset(dk, 'verification_uri', data.verification_uri)
+ redis.hset(dk, 'interval', tostring(data.interval or 5))
+ redis.hset(dk, 'expires_at', tostring(now + (data.expires_in or 900)))
+ redis.hset(dk, 'chat_id', tostring(message.chat.id))
+ redis.hset(dk, 'last_poll', '0')
+ redis.expire(dk, data.expires_in or DEVICE_TTL)
+ redis.sadd(PENDING_KEY, tostring(user_id))
+ local text = string.format(
+ '<b>GitHub Login</b>\n\n'
+ .. '1. Open: %s\n'
+ .. '2. Enter code: <code>%s</code>\n\n'
+ .. 'The code expires in %d minutes.',
+ tools.escape_html(data.verification_uri),
+ tools.escape_html(data.user_code),
+ math.floor((data.expires_in or 900) / 60)
+ )
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ })
+end
+
+handlers.logout = function(api, message, ctx)
+ if message.chat.type ~= 'private' then
+ return api.send_message(message.chat.id, 'Please use /gh logout in a private chat.')
+ end
+ local redis = ctx.redis
+ local user_id = message.from.id
+ local token = get_token(redis, user_id)
+ if not token then
+ return api.send_message(message.chat.id, 'You are not connected to GitHub.')
+ end
+ local client_id = config.get('GITHUB_CLIENT_ID')
+ local client_secret = config.get('GITHUB_CLIENT_SECRET')
+ if client_id and client_secret and client_id ~= '' and client_secret ~= '' then
+ pcall(function()
+ local revoke_body = json.encode({ access_token = token })
+ http.request({
+ url = GITHUB_API .. '/applications/' .. client_id .. '/token',
+ method = 'DELETE',
+ headers = {
+ ['Accept'] = 'application/vnd.github.v3+json',
+ ['Content-Type'] = 'application/json',
+ ['Content-Length'] = tostring(#revoke_body),
+ },
+ })
+ end)
+ end
+ redis.del(token_key(user_id))
+ return api.send_message(message.chat.id, 'Your GitHub account has been disconnected.')
+end
+
+handlers.me = function(api, message, ctx)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local data = gh_api_authed(api, redis, message, '/user')
+ if not data then return end
+ local lines = {
+ string.format('<b>%s</b>', tools.escape_html(data.login or '')),
+ }
+ if data.name and data.name ~= '' then
+ table.insert(lines, (tools.escape_html(data.name)))
+ end
+ if data.bio and data.bio ~= '' then
+ table.insert(lines, '<i>' .. tools.escape_html(data.bio) .. '</i>')
+ end
+ table.insert(lines, '')
+ if data.company and data.company ~= '' then
+ table.insert(lines, 'Company: ' .. tools.escape_html(data.company))
+ end
+ if data.location and data.location ~= '' then
+ table.insert(lines, 'Location: ' .. tools.escape_html(data.location))
+ end
+ table.insert(lines, string.format('Public repos: <code>%d</code>', data.public_repos or 0))
+ table.insert(lines, string.format('Followers: <code>%d</code>', data.followers or 0))
+ table.insert(lines, string.format('Following: <code>%d</code>', data.following or 0))
+ local keyboard = api.inline_keyboard():row(
+ api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. (data.login or '')))
+ )
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.repos = function(api, message, ctx, arg)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local path
+ if arg and arg ~= '' then
+ path = string.format('/users/%s/repos?per_page=%d&sort=updated&page=1', arg, PER_PAGE)
+ else
+ path = string.format('/user/repos?per_page=%d&sort=updated&page=1', PER_PAGE)
+ end
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local user = arg or '_'
+ local text = format_repos_list(data, 1, user)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'r:' .. cb_slug(user), 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.repo = function(api, message, ctx, arg)
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Invalid repository format. Use: /gh owner/repo')
+ end
+ local redis = ctx.redis
+ local data = gh_api_authed(api, redis, message, '/repos/' .. owner_repo)
+ if not data then return end
+ local lines = format_repo(data)
+ local keyboard = api.inline_keyboard():row(
+ api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. owner_repo))
+ )
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.issues = function(api, message, ctx, arg)
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Usage: /gh issues owner/repo')
+ end
+ local redis = ctx.redis
+ local path = string.format('/repos/%s/issues?per_page=%d&state=open&page=1', owner_repo, PER_PAGE)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local text = format_issues_list(data, 1, owner_repo)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'i:' .. cb_slug(owner_repo), 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.issue = function(api, message, ctx, arg)
+ if not arg then
+ return api.send_message(message.chat.id, 'Usage: /gh issue owner/repo#123')
+ end
+ local owner_repo, number = arg:match('^([%w%.%-_]+/[%w%.%-_]+)#(%d+)$')
+ if not owner_repo or not number then
+ return api.send_message(message.chat.id, 'Invalid format. Use: /gh issue owner/repo#123')
+ end
+ local redis = ctx.redis
+ local path = string.format('/repos/%s/issues/%s', owner_repo, number)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local lines = {
+ string.format('<b>%s#%d</b>', tools.escape_html(owner_repo), data.number),
+ string.format('<b>%s</b>', tools.escape_html(data.title or '')),
+ }
+ table.insert(lines, '')
+ table.insert(lines, 'State: <code>' .. (data.state or 'unknown') .. '</code>')
+ table.insert(lines, 'Author: <code>' .. tools.escape_html(data.user and data.user.login or 'unknown') .. '</code>')
+ if data.labels and #data.labels > 0 then
+ local label_names = {}
+ for _, l in ipairs(data.labels) do table.insert(label_names, l.name) end
+ table.insert(lines, 'Labels: <code>' .. tools.escape_html(table.concat(label_names, ', ')) .. '</code>')
+ end
+ if data.assignees and #data.assignees > 0 then
+ local names = {}
+ for _, a in ipairs(data.assignees) do table.insert(names, a.login) end
+ table.insert(lines, 'Assignees: <code>' .. tools.escape_html(table.concat(names, ', ')) .. '</code>')
+ end
+ if data.comments and data.comments > 0 then
+ table.insert(lines, string.format('Comments: <code>%d</code>', data.comments))
+ end
+ if data.body and data.body ~= '' then
+ local body_text = data.body
+ if #body_text > 200 then body_text = body_text:sub(1, 197) .. '...' end
+ table.insert(lines, '')
+ table.insert(lines, (tools.escape_html(body_text)))
+ end
local keyboard = api.inline_keyboard():row(
- api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. owner .. '/' .. repo))
+ api.row():url_button('View on GitHub', data.html_url or ('https://github.com/' .. owner_repo .. '/issues/' .. number))
)
+ return api.send_message(message.chat.id, table.concat(lines, '\n'), {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.starred = function(api, message, ctx)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local path = string.format('/user/starred?per_page=%d&page=1', PER_PAGE)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local text = format_starred_list(data, 1)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 's', 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
+
+handlers.star = function(api, message, ctx, arg)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Usage: /gh star owner/repo')
+ end
+ local data, _ = gh_api_authed(api, redis, message, '/user/starred/' .. owner_repo, 'PUT')
+ if not data then return end
+ return api.send_message(message.chat.id,
+ string.format('Starred <b>%s</b>.', tools.escape_html(owner_repo)),
+ { parse_mode = 'html' }
+ )
+end
+
+handlers.unstar = function(api, message, ctx, arg)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local owner_repo = parse_owner_repo(arg)
+ if not owner_repo then
+ return api.send_message(message.chat.id, 'Usage: /gh unstar owner/repo')
+ end
+ local data, _ = gh_api_authed(api, redis, message, '/user/starred/' .. owner_repo, 'DELETE')
+ if not data then return end
+ return api.send_message(message.chat.id,
+ string.format('Unstarred <b>%s</b>.', tools.escape_html(owner_repo)),
+ { parse_mode = 'html' }
+ )
+end
+
+handlers.notifications = function(api, message, ctx)
+ local redis = ctx.redis
+ if not require_token(api, redis, message) then return end
+ local path = string.format('/notifications?per_page=%d&page=1', PER_PAGE)
+ local data = gh_api_authed(api, redis, message, path)
+ if not data then return end
+ local text = format_notifications_list(data, 1)
+ local has_more = #data == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'n', 1, has_more)
+ return api.send_message(message.chat.id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+end
- return api.send_message(message.chat.id, table.concat(lines, '\n'), { parse_mode = 'html', link_preview_options = { is_disabled = true }, reply_markup = keyboard })
+-- Command dispatcher
+function plugin.on_message(api, message, ctx)
+ local input = message.args
+ if not input or input == '' then
+ return api.send_message(message.chat.id, plugin.help, { parse_mode = 'html' })
+ end
+ local parts = {}
+ for word in input:gmatch('%S+') do
+ table.insert(parts, word)
+ end
+ local subcommand = parts[1]:lower()
+ local arg = parts[2]
+ if handlers[subcommand] then
+ return handlers[subcommand](api, message, ctx, arg)
+ end
+ -- Try owner/repo format
+ local owner_repo = parse_owner_repo(input)
+ if owner_repo then
+ return handlers.repo(api, message, ctx, input)
+ end
+ return api.send_message(message.chat.id, 'Unknown command. Use /gh for help.')
+end
+
+-- Callback query handler for pagination
+function plugin.on_callback_query(api, callback_query, message, ctx)
+ local data = callback_query.data
+ if data == 'noop' then
+ return api.answer_callback_query(callback_query.id)
+ end
+ local redis = ctx.redis
+ local token = get_token(redis, callback_query.from.id)
+ -- Parse callback data: type:params:page or type:page
+ local cb_parts = {}
+ for part in data:gmatch('[^:]+') do
+ table.insert(cb_parts, part)
+ end
+ local cb_type = cb_parts[1]
+ if cb_type == 'r' then
+ -- Repos: r:user:page
+ local user = cb_parts[2]
+ local page = tonumber(cb_parts[3]) or 1
+ local path
+ if user == '_' then
+ path = string.format('/user/repos?per_page=%d&sort=updated&page=%d', PER_PAGE, page)
+ else
+ path = string.format('/users/%s/repos?per_page=%d&sort=updated&page=%d', user, PER_PAGE, page)
+ end
+ local repos, _ = gh_api(path, token)
+ if not repos then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch repositories.' })
+ end
+ local text = format_repos_list(repos, page, user)
+ local has_more = #repos == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'r:' .. cb_slug(user), page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ elseif cb_type == 'i' then
+ -- Issues: i:owner/repo:page (owner/repo may contain /)
+ local page = tonumber(cb_parts[#cb_parts]) or 1
+ -- Reconstruct owner/repo from middle parts
+ local owner_repo = table.concat(cb_parts, ':', 2, #cb_parts - 1)
+ local path = string.format('/repos/%s/issues?per_page=%d&state=open&page=%d', owner_repo, PER_PAGE, page)
+ local issues, _ = gh_api(path, token)
+ if not issues then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch issues.' })
+ end
+ local text = format_issues_list(issues, page, owner_repo)
+ local has_more = #issues == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'i:' .. cb_slug(owner_repo), page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ elseif cb_type == 's' then
+ -- Starred: s:page
+ local page = tonumber(cb_parts[2]) or 1
+ local path = string.format('/user/starred?per_page=%d&page=%d', PER_PAGE, page)
+ local repos, _ = gh_api(path, token)
+ if not repos then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch starred repos.' })
+ end
+ local text = format_starred_list(repos, page)
+ local has_more = #repos == PER_PAGE
+ local keyboard = pagination_keyboard(api, 's', page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ elseif cb_type == 'n' then
+ -- Notifications: n:page
+ local page = tonumber(cb_parts[2]) or 1
+ local path = string.format('/notifications?per_page=%d&page=%d', PER_PAGE, page)
+ local notifications, _ = gh_api(path, token)
+ if not notifications then
+ return api.answer_callback_query(callback_query.id, { text = 'Failed to fetch notifications.' })
+ end
+ local text = format_notifications_list(notifications, page)
+ local has_more = #notifications == PER_PAGE
+ local keyboard = pagination_keyboard(api, 'n', page, has_more)
+ api.answer_callback_query(callback_query.id)
+ return api.edit_message_text(message.chat.id, message.message_id, text, {
+ parse_mode = 'html',
+ link_preview_options = { is_disabled = true },
+ reply_markup = keyboard,
+ })
+ end
+ return api.answer_callback_query(callback_query.id)
+end
+
+-- Cron: poll GitHub for pending device flows
+function plugin.cron(api, ctx)
+ local redis = ctx.redis
+ local client_id = config.get('GITHUB_CLIENT_ID')
+ if not client_id or client_id == '' then return end
+ local pending = redis.smembers(PENDING_KEY)
+ if not pending or #pending == 0 then return end
+ local now = os.time()
+ local polls = 0
+ for _, uid_str in ipairs(pending) do
+ if polls >= CRON_MAX_POLLS then break end
+ local device = redis.hgetall(device_key(uid_str))
+ if not device or not device.device_code then
+ redis.srem(PENDING_KEY, uid_str)
+ else
+ local expires_at = tonumber(device.expires_at) or 0
+ if now > expires_at then
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'Your GitHub login has expired. Please try /gh login again.')
+ end)
+ end
+ else
+ local interval = tonumber(device.interval) or 5
+ local last_poll = tonumber(device.last_poll) or 0
+ if now - last_poll >= interval then
+ polls = polls + 1
+ redis.hset(device_key(uid_str), 'last_poll', tostring(now))
+ local body = string.format(
+ 'client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code',
+ client_id, device.device_code
+ )
+ local resp_body, _ = http.post(ACCESS_TOKEN_URL, body, 'application/x-www-form-urlencoded', {
+ ['Accept'] = 'application/json',
+ })
+ if resp_body and resp_body ~= '' then
+ local resp_data = json.decode(resp_body)
+ if resp_data then
+ if resp_data.access_token then
+ redis.setex(token_key(uid_str), TOKEN_TTL, resp_data.access_token)
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'GitHub account connected successfully! Use /gh me to see your profile.')
+ end)
+ end
+ elseif resp_data.error == 'slow_down' then
+ local new_interval = interval + 5
+ redis.hset(device_key(uid_str), 'interval', tostring(new_interval))
+ elseif resp_data.error == 'access_denied' then
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'GitHub login was denied.')
+ end)
+ end
+ elseif resp_data.error == 'expired_token' then
+ redis.del(device_key(uid_str))
+ redis.srem(PENDING_KEY, uid_str)
+ if device.chat_id then
+ pcall(function()
+ api.send_message(tonumber(device.chat_id), 'Your GitHub login code has expired. Please try /gh login again.')
+ end)
+ end
+ end
+ -- authorization_pending: do nothing, wait for next poll
+ end
+ end
+ end
+ end
+ end
+ end
end
return plugin
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, May 14, 9:44 AM (1 d, 21 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
63460
Default Alt Text
(81 KB)
Attached To
Mode
R69 mattata
Attached
Detach File
Event Timeline