Skip to content

Commit

Permalink
feat(github): support app.github server api
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Sep 5, 2020
1 parent e8c4e70 commit 52134c5
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 122 deletions.
134 changes: 13 additions & 121 deletions packages/plugin-github/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

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<string, number[]>
events?: EventConfig
}

const defaultOptions: Config = {
secret: '',
prefix: '.',
Expand All @@ -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<OAuth>('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,
Expand All @@ -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) {
Expand All @@ -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<number, ReplyPayloads> = {}

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) => {
Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions packages/plugin-github/src/server.ts
Original file line number Diff line number Diff line change
@@ -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<string, number[]>
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<OAuth>('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('发送失败。')
}
}
}
2 changes: 1 addition & 1 deletion packages/plugin-github/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down

0 comments on commit 52134c5

Please sign in to comment.