Skip to content

Commit

Permalink
feat(github): better command hints
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Mar 24, 2021
1 parent 5378134 commit 53f4e8f
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 98 deletions.
162 changes: 120 additions & 42 deletions packages/plugin-github/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@

import { createHmac } from 'crypto'
import { encode } from 'querystring'
import { Context, camelize, Time, Random, sanitize } from 'koishi-core'
import { CommonPayload, addListeners, defaultEvents } from './events'
import { Context, camelize, Time, Random, sanitize, Logger } from 'koishi-core'
import { CommonPayload, addListeners, defaultEvents, EventConfig } from './events'
import { Config, GitHub, ReplyHandler, ReplySession, EventData } from './server'
import axios from 'axios'

export * from './server'

const logger = new Logger('github')

const defaultOptions: Config = {
secret: '',
path: '/github',
messagePrefix: '[GitHub] ',
replyTimeout: Time.hour,
repos: [],
events: {},
}

function authorize(ctx: Context, config: Config) {
const { appId, redirect } = config
interface RuntimeConfig extends Config {
subscriptions: Record<string, Record<string, EventConfig>>
}

function authorize(ctx: Context, config: RuntimeConfig) {
const { appId, redirect, subscriptions } = config
const { app, database } = ctx

const tokens: Record<string, string> = {}
Expand Down Expand Up @@ -59,6 +65,7 @@ function authorize(ctx: Context, config: Config) {
.userFields(['ghAccessToken', 'ghRefreshToken'])
.option('add', '-a 监听一个新的仓库')
.option('delete', '-d 移除已监听的仓库')
.option('subscribe', '-s 添加完成后更新到订阅', { hidden: true })
.action(async ({ session, options }, name) => {
if (options.add || options.delete) {
if (!name) return '请输入仓库名。'
Expand All @@ -72,19 +79,43 @@ function authorize(ctx: Context, config: Config) {
if (options.add) {
if (repo) return `已经添加过仓库 ${name}。`
const secret = Random.uuid()
const { id } = await ctx.app.github.request(url, 'POST', session, {
events: ['*'],
config: {
secret,
url: app.options.selfUrl + config.path + '/webhook',
},
})
await ctx.database.create('github', { name, id, secret })
return '添加仓库成功!'
let data: any
try {
data = await ctx.app.github.request('POST', url, session, {
events: ['*'],
config: {
secret,
url: app.options.selfUrl + config.path + '/webhook',
},
})
} catch (err) {
if (!axios.isAxiosError(err)) throw err
if (err.response?.status === 404) {
return '仓库不存在或您无权访问。'
} else if (err.response?.status === 403) {
return '第三方访问受限,请尝试授权此应用。\nhttps://docs.github.com/articles/restricting-access-to-your-organization-s-data/'
} else {
logger.warn(err)
return '由于未知原因添加仓库失败。'
}
}
await ctx.database.create('github', { name, id: data.id, secret })
if (!options.subscribe) return '添加仓库成功!'
return session.execute({
name: 'github',
args: [name],
options: { add: true },
}, true)
} else {
const [repo] = await ctx.database.get('github', [name])
if (!repo) return `尚未添加过仓库 ${name}。`
await ctx.app.github.request(`${url}/${repo.id}`, 'DELETE', session)
try {
await ctx.app.github.request('DELETE', `${url}/${repo.id}`, session)
} catch (err) {
if (!axios.isAxiosError(err)) throw err
logger.warn(err)
return '移除仓库失败。'
}
await ctx.database.remove('github', [name])
return '移除仓库成功!'
}
}
Expand All @@ -94,6 +125,17 @@ function authorize(ctx: Context, config: Config) {
return repos.map(repo => repo.name).join('\n')
})

function subscribe(repo: string, id: string, meta: EventConfig) {
(subscriptions[repo] ||= {})[id] = meta
}

function unsubscribe(repo: string, id: string) {
delete subscriptions[repo][id]
if (!Object.keys(subscriptions[repo]).length) {
delete subscriptions[repo]
}
}

ctx.command('github [name]')
.channelFields(['githubWebhooks'])
.option('list', '-l 查看当前频道订阅的仓库列表')
Expand All @@ -111,34 +153,63 @@ function authorize(ctx: Context, config: Config) {
if (!session.channel) return '当前不是群聊上下文。'
if (!name) return '请输入仓库名。'
if (!/^[\w-]+\/[\w-]+$/.test(name)) return '请输入正确的仓库名。'
const [repo] = await ctx.database.get('github', [name])
if (!repo) return `尚未添加过仓库 ${name}。`

const webhooks = session.channel.githubWebhooks
if (options.add) {
if (webhooks[name]) return `已经在当前频道订阅过仓库 ${name}。`
const [repo] = await ctx.database.get('github', [name])
if (!repo) {
const dispose = session.middleware(({ content }, next) => {
dispose()
content = content.trim()
if (content && content !== '.' && content !== '。') return next()
return session.execute({
name: 'github.repos',
args: [name],
options: { add: true, subscribe: true },
})
})
return `尚未添加过仓库 ${name}。发送空行或句号以立即添加并订阅该仓库。`
}
webhooks[name] = {}
await session.channel._update()
subscribe(name, session.cid, {})
return '添加订阅成功!'
} else if (options.delete) {
if (!webhooks[name]) return `尚未在当前频道订阅过仓库 ${name}。`
delete webhooks[name]
await session.channel._update()
unsubscribe(name, session.cid)
return '移除订阅成功!'
}
}
})

ctx.on('connect', async () => {
const channels = await ctx.database.getAssignedChannels(['id', 'githubWebhooks'])
for (const channel of channels) {
for (const repo in channel.githubWebhooks) {
subscribe(repo, channel.id, channel.githubWebhooks[repo])
}
}
})
}

export const name = 'github'

export function apply(ctx: Context, config: Config = {}) {
config = { ...defaultOptions, ...config }
config.path = sanitize(config.path || '/github')
const { app } = ctx
export function apply(ctx: Context, rawConfig: Config = {}) {
const config: RuntimeConfig = {
...defaultOptions,
...rawConfig,
subscriptions: {},
}
config.path = sanitize(config.path)

const { app } = ctx
const github = app.github = new GitHub(app, config)

ctx.command('github', 'GitHub 相关功能').alias('gh')
.action(({ session }) => session.execute('help github'))
.action(({ session }) => session.execute('help github', true))

ctx.command('github.recent', '查看最近的通知')
.action(async () => {
Expand All @@ -152,6 +223,12 @@ export function apply(ctx: Context, config: Config = {}) {

if (ctx.database) {
ctx.plugin(authorize, config)
} else {
config.repos.forEach(({ name, channels }) => {
config.subscriptions[name] = Array.isArray(channels)
? Object.fromEntries(channels.map(id => [id, {}]))
: channels
})
}

const reactions = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']
Expand Down Expand Up @@ -223,31 +300,32 @@ export function apply(ctx: Context, config: Config = {}) {

addListeners((event, handler) => {
const base = camelize(event.split('/', 1)[0])
app.on(`github/${event}` as any, async (payload: CommonPayload) => {
// step 1: filter repository
const groupIds = config.repos[payload.repository.full_name]
if (!groupIds) return

// step 2: filter event
const baseConfig = config.events[base] || {}
if (baseConfig === false) return
const action = camelize(payload.action)
if (action && baseConfig !== true) {
const actionConfig = baseConfig[action]
if (actionConfig === false) return
if (actionConfig !== true && !(defaultEvents[base] || {})[action]) return
}
ctx.on(`github/${event}` as any, async (payload: CommonPayload) => {
// step 1: filter event
const repoConfig = config.subscriptions[payload.repository.full_name] || {}
const targets = Object.keys(repoConfig).filter((id) => {
const baseConfig = repoConfig[id][base] || {}
if (baseConfig === false) return
const action = camelize(payload.action)
if (action && baseConfig !== true) {
const actionConfig = baseConfig[action]
if (actionConfig === false) return
if (actionConfig !== true && !(defaultEvents[base] || {})[action]) return
}
return true
})
if (!targets.length) return

// step 3: handle event
// step 2: handle event
const result = handler(payload as any)
if (!result) return

// step 4: broadcast message
// step 3: broadcast message
app.logger('github').debug('broadcast', result[0].split('\n', 1)[0])
const messageIds = await ctx.broadcast(groupIds, config.messagePrefix + result[0])
const messageIds = await ctx.broadcast(targets, config.messagePrefix + result[0])
const hexIds = messageIds.map(id => id.slice(0, 6))

// step 5: save message ids for interactions
// step 4: save message ids for interactions
for (const id of hexIds) {
history[id] = result
}
Expand Down
52 changes: 27 additions & 25 deletions packages/plugin-github/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ declare module 'koishi-core' {
}

interface Channel {
githubWebhooks: Record<string, {}>
githubWebhooks: Record<string, EventConfig>
}

interface Tables {
Expand Down Expand Up @@ -52,25 +52,25 @@ Database.extend('koishi-plugin-mysql', ({ tables, Domain }) => {
interface Repository {
name: string
secret: string
id?: number
id: number
}

interface RepoConfig extends Repository {
targets: string[]
interface RepoConfig {
name: string
secret: string
channels: string[] | Record<string, EventConfig>
}

export interface Config {
path?: string
secret?: string
messagePrefix?: string
appId?: string
appSecret?: string
messagePrefix?: string
redirect?: string
promptTimeout?: number
replyTimeout?: number
requestTimeout?: number
repos?: RepoConfig[]
events?: EventConfig
}

export interface OAuth {
Expand Down Expand Up @@ -101,7 +101,7 @@ export class GitHub {
return data
}

private async _request(url: string, method: Method, session: ReplySession, data?: any, headers?: Record<string, any>) {
private async _request(method: Method, url: string, session: ReplySession, data?: any, headers?: Record<string, any>) {
logger.debug(method, url, data)
const response = await axios(url, {
...this.app.options.axiosConfig,
Expand All @@ -124,19 +124,16 @@ export class GitHub {
await session.execute({ name: 'github.authorize', args: [name] })
}

async request(url: string, method: Method, session: ReplySession, body?: any, headers?: Record<string, any>) {
async request(method: Method, url: string, session: ReplySession, body?: any, headers?: Record<string, any>) {
if (!session.user.ghAccessToken) {
return this.authorize(session, '要使用此功能,请对机器人进行授权。输入你的 GitHub 用户名。')
}

try {
return await this._request(url, method, session, body, headers)
return await this._request(method, url, session, body, headers)
} catch (error) {
const { response } = error as AxiosError
if (response?.status !== 401) {
logger.warn(error)
return session.send('发送失败。')
}
if (response?.status !== 401) throw error
}

try {
Expand All @@ -150,12 +147,7 @@ export class GitHub {
return this.authorize(session, '令牌已失效,需要重新授权。输入你的 GitHub 用户名。')
}

try {
return await this._request(url, method, session, body, headers)
} catch (error) {
logger.warn(error)
return session.send('发送失败。')
}
return await this._request(method, url, session, body, headers)
}
}

Expand All @@ -176,35 +168,45 @@ export type EventData<T = {}> = [string, (ReplyPayloads & T)?]
export class ReplyHandler {
constructor(public github: GitHub, public session: ReplySession, public content?: string) {}

async request(method: Method, url: string, message: string, body?: any, headers?: Record<string, any>) {
try {
await this.github.request(method, url, this.session, body, headers)
} catch (err) {
if (!axios.isAxiosError(err)) throw err
logger.warn(err)
return this.session.send(message)
}
}

link(url: string) {
return this.session.send(url)
}

react(url: string) {
return this.github.request(url, 'POST', this.session, {
return this.request('POST', url, '发送失败。', {
content: this.content,
}, {
accept: 'application/vnd.github.squirrel-girl-preview',
})
}

reply(url: string, params?: Record<string, any>) {
return this.github.request(url, 'POST', this.session, {
return this.request('POST', url, '发送失败。', {
body: formatReply(this.content),
...params,
})
}

base(url: string) {
return this.github.request(url, 'PATCH', this.session, {
return this.request('PATCH', url, '修改失败。', {
base: this.content,
})
}

merge(url: string, method?: 'merge' | 'squash' | 'rebase') {
const [title] = this.content.split('\n', 1)
const message = this.content.slice(title.length)
return this.github.request(url, 'PUT', this.session, {
return this.request('PUT', url, '操作失败。', {
merge_method: method,
commit_title: title.trim(),
commit_message: message.trim(),
Expand All @@ -221,7 +223,7 @@ export class ReplyHandler {

async close(url: string, commentUrl: string) {
if (this.content) await this.reply(commentUrl)
await this.github.request(url, 'PATCH', this.session, {
await this.request('PATCH', url, '操作失败。', {
state: 'closed',
})
}
Expand Down

0 comments on commit 53f4e8f

Please sign in to comment.