From 52134c5af4483ded37997a5e573fa4507e5659d2 Mon Sep 17 00:00:00 2001 From: Shigma <1700011071@pku.edu.cn> Date: Sat, 5 Sep 2020 12:23:26 +0800 Subject: [PATCH] feat(github): support app.github server api --- packages/plugin-github/src/index.ts | 134 ++------------------- packages/plugin-github/src/server.ts | 116 ++++++++++++++++++ packages/plugin-github/tests/index.spec.ts | 2 +- 3 files changed, 130 insertions(+), 122 deletions(-) create mode 100644 packages/plugin-github/src/server.ts diff --git a/packages/plugin-github/src/index.ts b/packages/plugin-github/src/index.ts index d5b40664a8..a58f3191e1 100644 --- a/packages/plugin-github/src/index.ts +++ b/packages/plugin-github/src/index.ts @@ -1,61 +1,22 @@ /* eslint-disable camelcase */ /* eslint-disable quote-props */ -import { Context, Session, User } from 'koishi-core' -import { camelize, CQCode, defineProperty, Logger, Time } from 'koishi-utils' -import { Webhooks } from '@octokit/webhooks' -import { Agent } from 'https' +import { Context, Session } from 'koishi-core' +import { camelize, CQCode, defineProperty, Time } from 'koishi-utils' import { encode } from 'querystring' -import axios, { AxiosError } from 'axios' import { addListeners, defaultEvents, EventConfig, ReplyPayloads } from './events' +import { Config, GitHub } from './server' declare module 'koishi-core/dist/app' { interface App { - githubWebhooks?: Webhooks + github?: GitHub } } -declare module 'koishi-core/dist/database' { - interface User { - ghAccessToken?: string - ghRefreshToken?: string - } -} - -User.extend(() => ({ - ghAccessToken: '', - ghRefreshToken: '', -})) - -export interface OAuth { - access_token: string - expires_in: string - refresh_token: string - refresh_token_expires_in: string - token_type: string - scope: string -} - type ReplyHandlers = { [K in keyof ReplyPayloads]: (payload: ReplyPayloads[K], session: Session, message: string) => Promise } -export interface Config { - agent?: Agent - secret?: string - webhook?: string - authorize?: string - prefix?: string - appId?: string - appSecret?: string - redirect?: string - promptTimeout?: number - replyTimeout?: number - requestTimeout?: number - repos?: Record - events?: EventConfig -} - const defaultOptions: Config = { secret: '', prefix: '.', @@ -66,35 +27,21 @@ const defaultOptions: Config = { events: {}, } -const logger = new Logger('github') - export const name = 'github' export function apply(ctx: Context, config: Config = {}) { config = { ...defaultOptions, ...config } const { app, database, router } = ctx - const { appId, appSecret, prefix, redirect, webhook: path } = config - - const webhooks = new Webhooks({ ...config, path }) - defineProperty(app, 'githubWebhooks', webhooks) + const { appId, prefix, redirect, webhook } = config - async function getTokens(params: any) { - const { data } = await axios.post('https://github.com/login/oauth/access_token', { - client_id: appId, - client_secret: appSecret, - ...params, - }, { - httpsAgent: config.agent, - headers: { Accept: 'application/json' }, - }) - return data - } + const github = new GitHub(config) + defineProperty(app, 'github', github) router.get(config.authorize, async (ctx) => { const targetId = parseInt(ctx.query.state) if (Number.isNaN(targetId)) throw new Error('Invalid targetId') const { code, state } = ctx.query - const data = await getTokens({ code, state, redirect_uri: redirect }) + const data = await github.getTokens({ code, state, redirect_uri: redirect }) await database.setUser(targetId, { ghAccessToken: data.access_token, ghRefreshToken: data.refresh_token, @@ -115,61 +62,6 @@ export function apply(ctx: Context, config: Config = {}) { return '请点击下面的链接继续操作:\n' + url }) - type ReplySession = Session<'ghAccessToken' | 'ghRefreshToken'> - - async function plainRequest(url: string, session: ReplySession, params: any, accept: string) { - logger.debug('POST', url, params) - await axios.post(url, params, { - httpsAgent: config.agent, - timeout: config.requestTimeout, - headers: { - accept, - authorization: `token ${session.$user.ghAccessToken}`, - }, - }) - } - - async function authorize(session: Session, message: string) { - await session.$send(message) - const name = await session.$prompt(config.promptTimeout) - if (!name) return session.$send('输入超时。') - return session.$execute({ command: 'github', args: [name] }) - } - - async function request(url: string, session: ReplySession, params: any, accept = 'application/vnd.github.v3+json') { - if (!session.$user.ghAccessToken) { - return authorize(session, '如果想使用此功能,请对机器人进行授权。输入你的 GitHub 用户名。') - } - - try { - return await plainRequest(url, session, params, accept) - } catch (error) { - const { response } = error as AxiosError - if (response?.status !== 401) { - logger.warn(error) - return session.$send('发送失败。') - } - } - - try { - const data = await getTokens({ - refresh_token: session.$user.ghRefreshToken, - grant_type: 'refresh_token', - }) - session.$user.ghAccessToken = data.access_token - session.$user.ghRefreshToken = data.refresh_token - } catch (error) { - return authorize(session, '令牌已失效,需要重新授权。输入你的 GitHub 用户名。') - } - - try { - await plainRequest(url, session, params, accept) - } catch (error) { - logger.warn(error) - return session.$send('发送失败。') - } - } - const reactions = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes'] function formatReply(source: string) { @@ -182,17 +74,17 @@ export function apply(ctx: Context, config: Config = {}) { const replyHandlers: ReplyHandlers = { link: (url, session) => session.$send(url), - react: (url, session, content) => request(url, session, { content }, 'application/vnd.github.squirrel-girl-preview'), - reply: ([url, params], session, content) => request(url, session, { ...params, body: formatReply(content) }), + react: (url, session, content) => github.request(url, session, { content }, 'application/vnd.github.squirrel-girl-preview'), + reply: ([url, params], session, content) => github.request(url, session, { ...params, body: formatReply(content) }), } const interactions: Record = {} - router.post(path, (ctx, next) => { + router.post(webhook, (ctx, next) => { // workaround @octokit/webhooks for koa ctx.req['body'] = ctx.request.body ctx.status = 200 - return webhooks.middleware(ctx.req, ctx.res, next) + return github.middleware(ctx.req, ctx.res, next) }) ctx.on('before-attach-user', (session, fields) => { @@ -223,7 +115,7 @@ export function apply(ctx: Context, config: Config = {}) { addListeners((event, handler) => { const base = camelize(event.split('.', 1)[0]) as keyof EventConfig - webhooks.on(event, async (callback) => { + github.on(event, async (callback) => { const { repository } = callback.payload // step 1: filter repository diff --git a/packages/plugin-github/src/server.ts b/packages/plugin-github/src/server.ts new file mode 100644 index 0000000000..78e81f7527 --- /dev/null +++ b/packages/plugin-github/src/server.ts @@ -0,0 +1,116 @@ +/* eslint-disable camelcase */ + +import { Webhooks } from '@octokit/webhooks' +import { EventConfig } from './events' +import axios, { AxiosError } from 'axios' +import { Session, User } from 'koishi-core' +import { Logger } from 'koishi-utils' + +declare module 'koishi-core/dist/database' { + interface User { + ghAccessToken?: string + ghRefreshToken?: string + } +} + +User.extend(() => ({ + ghAccessToken: '', + ghRefreshToken: '', +})) + +export interface Config { + secret?: string + webhook?: string + authorize?: string + prefix?: string + appId?: string + appSecret?: string + redirect?: string + promptTimeout?: number + replyTimeout?: number + requestTimeout?: number + repos?: Record + events?: EventConfig +} + +export interface OAuth { + access_token: string + expires_in: string + refresh_token: string + refresh_token_expires_in: string + token_type: string + scope: string +} + +type ReplySession = Session<'ghAccessToken' | 'ghRefreshToken'> + +const logger = new Logger('github') + +export class GitHub extends Webhooks { + constructor(public config: Config) { + super({ ...config, path: config.webhook }) + } + + async getTokens(params: any) { + const { data } = await axios.post('https://github.com/login/oauth/access_token', { + client_id: this.config.appId, + client_secret: this.config.appSecret, + ...params, + }, { + headers: { Accept: 'application/json' }, + }) + return data + } + + async _request(url: string, session: ReplySession, params: any, accept: string) { + logger.debug('POST', url, params) + await axios.post(url, params, { + timeout: this.config.requestTimeout, + headers: { + accept, + authorization: `token ${session.$user.ghAccessToken}`, + }, + }) + } + + async authorize(session: Session, message: string) { + await session.$send(message) + const name = await session.$prompt(this.config.promptTimeout) + if (!name) return session.$send('输入超时。') + return session.$execute({ command: 'github', args: [name] }) + } + + async request(url: string, session: ReplySession, params: any, accept = 'application/vnd.github.v3+json') { + if (!session.$user.ghAccessToken) { + return this.authorize(session, '如果想使用此功能,请对机器人进行授权。输入你的 GitHub 用户名。') + } + + try { + return await this._request(url, session, params, accept) + } catch (error) { + const { response } = error as AxiosError + if (response?.status !== 401) { + logger.warn(error) + return session.$send('发送失败。') + } + } + + try { + const data = await this.getTokens({ + refresh_token: session.$user.ghRefreshToken, + grant_type: 'refresh_token', + }) + session.$user.ghAccessToken = data.access_token + session.$user.ghRefreshToken = data.refresh_token + } catch (error) { + return this.authorize(session, '令牌已失效,需要重新授权。输入你的 GitHub 用户名。') + } + + try { + await this._request(url, session, params, accept) + } catch (error) { + logger.warn(error) + return session.$send('发送失败。') + } + } +} diff --git a/packages/plugin-github/tests/index.spec.ts b/packages/plugin-github/tests/index.spec.ts index 4bf2c3107d..a0f47548ac 100644 --- a/packages/plugin-github/tests/index.spec.ts +++ b/packages/plugin-github/tests/index.spec.ts @@ -35,7 +35,7 @@ function check(file: string) { sendGroupMsg.mockClear() const payload = require(`./fixtures/${file}`) const [name] = file.split('.', 1) - await app.githubWebhooks.receive({ id: Random.uuid(), name, payload }) + await app.github.receive({ id: Random.uuid(), name, payload }) if (snapshot[file]) { expect(sendGroupMsg.mock.calls).to.have.length(1) expect(sendGroupMsg.mock.calls[0][1]).to.equal(snapshot[file].trim())