Page Menu
Home
Phabricator (Chris)
Search
Configure Global Search
Log In
Files
F108485
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
33 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/plugins/utility/github.lua b/src/plugins/utility/github.lua
index a67d25e..f4cc0ae 100644
--- a/src/plugins/utility/github.lua
+++ b/src/plugins/utility/github.lua
@@ -1,770 +1,770 @@
--[[
mattata v2.0 - GitHub Plugin
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 = 'GitHub integration with OAuth authentication'
plugin.commands = { 'github', 'gh' }
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')
local http = require('src.core.http')
local config = require('src.core.config')
local json = require('dkjson')
local tools = require('telegram-bot-lua.tools')
-local logger = require('src.core.logger')
+
-- 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
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
-- 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
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 '')),
}
if data.description and data.description ~= '' then
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 .. '/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, code = gh_api_authed(api, redis, message, '/user/starred/' .. owner_repo, 'PUT')
+ 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, code = gh_api_authed(api, redis, message, '/user/starred/' .. owner_repo, 'DELETE')
+ 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
-- 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, code = gh_api(path, token)
+ 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, code = gh_api(path, token)
+ 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, code = gh_api(path, token)
+ 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, code = gh_api(path, token)
+ 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, code = http.post(ACCESS_TOKEN_URL, body, 'application/x-www-form-urlencoded', {
+ 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
Wed, Apr 1, 11:15 AM (1 d, 20 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
57148
Default Alt Text
(33 KB)
Attached To
Mode
R69 mattata
Attached
Detach File
Event Timeline