Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/slack'
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Aug 7, 2023
2 parents d91885e + 96488ef commit 9aada8a
Show file tree
Hide file tree
Showing 39 changed files with 5,483 additions and 0 deletions.
35 changes: 35 additions & 0 deletions adapters/slack/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@satorijs/adapter-slack",
"description": "Slack Adapter for Satorijs",
"version": "1.0.0",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"files": [
"lib"
],
"author": "LittleC <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/satorijs/satori.git",
"directory": "adapters/slack"
},
"bugs": {
"url": "https://github.com/satorijs/satori/issues"
},
"homepage": "https://koishi.chat/plugins/adapter/slack.html",
"keywords": [
"bot",
"slack",
"adapter",
"chatbot",
"satori"
],
"peerDependencies": {
"@satorijs/satori": "^2.4.0"
},
"dependencies": {
"@slack/types": "^2.8.0",
"form-data": "^4.0.0"
}
}
198 changes: 198 additions & 0 deletions adapters/slack/src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { Bot, Context, Fragment, Quester, Schema, SendOptions, Universal } from '@satorijs/satori'
import { WsClient } from './ws'
import { HttpServer } from './http'
import { adaptChannel, adaptGuild, adaptMessage, adaptUser, AuthTestResponse } from './utils'

Check failure on line 4 in adapters/slack/src/bot.ts

View workflow job for this annotation

GitHub Actions / lint

'AuthTestResponse' is defined but never used
import { SlackMessageEncoder } from './message'
import { GenericMessageEvent, SlackChannel, SlackTeam, SlackUser } from './types'
import FormData from 'form-data'
import { Internal, Token } from './types/internal'

export class SlackBot<T extends SlackBot.Config = SlackBot.Config> extends Bot<T> {
static MessageEncoder = SlackMessageEncoder
public http: Quester
public internal: Internal

constructor(ctx: Context, config: T) {
super(ctx, config)
this.http = ctx.http.extend(config)

this.internal = new Internal(this, this.http)

if (config.protocol === 'ws') {
ctx.plugin(WsClient, this)
} else {
ctx.plugin(HttpServer, this)
}
}

async request<T = any>(method: Quester.Method, path: string, data = {}, headers: any = {}, zap: boolean = false): Promise<T> {
headers['Authorization'] = `Bearer ${zap ? this.config.token : this.config.botToken}`
if (method === 'GET') {
return (await this.http.get(path, { params: data, headers })).data
} else {
if (!headers['content-type']) {
data = data instanceof FormData ? data : JSON.stringify(data)
const type = data instanceof FormData ? 'multipart/form-data' : 'application/json; charset=utf-8'
headers['content-type'] = type
}
return (await this.http(method, path, { data, headers }))
}
}

async getSelf() {
const data = await this.internal.authTest(Token.BOT)
return {
userId: data.user_id,
avatar: null,
username: data.user,
isBot: !!data.bot_id,
}
}

async deleteMessage(channelId: string, messageId: string): Promise<void> {
await this.internal.chatDelete(Token.BOT, {
channel: channelId,
ts: Number(messageId),
})
}

async getMessage(channelId: string, messageId: string): Promise<Universal.Message> {
const msg = await this.internal.conversationsHistory(Token.BOT, {
channel: channelId,
oldest: Number(messageId),
limit: 1,
inclusive: true,
})
// @ts-ignore
return adaptMessage(this, msg.messages[0])
}

async getMessageList(channelId: string, before?: string): Promise<Universal.Message[]> {
const msg = await this.request<{
messages: GenericMessageEvent[]
}>('POST', '/conversations.history', {
channel: channelId,
latest: before,
})
return Promise.all(msg.messages.map(v => adaptMessage(this, v)))
}

async getUser(userId: string, guildId?: string): Promise<Universal.User> {
// users:read
// @TODO guildId
const { user } = await this.request<{ user: SlackUser }>('POST', '/users.info', {
user: userId,
})
return adaptUser(user)
}

async getGuildMemberList(guildId: string): Promise<Universal.GuildMember[]> {
// users:read
const { members } = await this.request<{ members: SlackUser[] }>('POST', '/users.list')
return members.map(adaptUser)
}

async getChannel(channelId: string, guildId?: string): Promise<Universal.Channel> {
const { channel } = await this.request<{
channel: SlackChannel
}>('POST', '/conversations.info', {
channel: channelId,
})
return adaptChannel(channel)
}

async getChannelList(guildId: string): Promise<Universal.Channel[]> {
const { channels } = await this.request<{
channels: SlackChannel[]
}>('POST', '/conversations.list', {
team_id: guildId,
})
return channels.map(adaptChannel)
}

async getGuild(guildId: string): Promise<Universal.Guild> {
const { team } = await this.request<{ team: SlackTeam }>('POST', '/team.info', {
team_id: guildId,
})
return adaptGuild(team)
}

async getGuildMember(guildId: string, userId: string): Promise<Universal.GuildMember> {
const { user } = await this.request<{ user: SlackUser }>('POST', '/users.info', {
user: userId,
})
return {
...adaptUser(user),
nickname: user.profile.display_name,
}
}

async sendPrivateMessage(channelId: string, content: Fragment, options?: SendOptions): Promise<string[]> {
// "channels:write,groups:write,mpim:write,im:write",
const { channel } = await this.internal.conversationsOpen(Token.BOT, {
users: channelId,
})
// @ts-ignore
return this.sendMessage(channel.id, content, undefined, options)
}

async getReactions(channelId: string, messageId: string, emoji: string): Promise<Universal.User[]> {
const { message } = await this.internal.reactionsGet(Token.BOT, {
channel: channelId,
timestamp: messageId,
full: true,
})
return message.reactions.find(v => v.name === emoji)?.users.map(v => ({
userId: v,
})) ?? []
}

async createReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
// reactions.write
await this.internal.reactionsAdd(Token.BOT, {
channel: channelId,
timestamp: messageId,
name: emoji,
})
}

async clearReaction(channelId: string, messageId: string, emoji?: string): Promise<void> {
const { message } = await this.internal.reactionsGet(Token.BOT, {
channel: channelId,
timestamp: messageId,
full: true,
})
for (const reaction of message.reactions) {
if (!emoji || reaction.name === emoji) {
await this.internal.reactionsRemove(Token.BOT, {
channel: channelId,
timestamp: messageId,
name: reaction.name,
})
}
}
}
}

export namespace SlackBot {
export interface BaseConfig extends Bot.Config, Quester.Config {
token: string
botToken: string
}
export type Config = BaseConfig & (HttpServer.Config | WsClient.Config)

export const Config: Schema<Config> = Schema.intersect([
Schema.object({
protocol: Schema.union(['http', 'ws']).description('选择要使用的协议。').required(),
token: Schema.string().description('App-Level Tokens').role('secret').required(),
botToken: Schema.string().description('OAuth Tokens(Bot Tokens)').role('secret').required(),
}),
Schema.union([
WsClient.Config,
HttpServer.Config,
]),
Quester.createConfig('https://slack.com/api/'),
] as const)
}

SlackBot.prototype.platform = 'slack'
63 changes: 63 additions & 0 deletions adapters/slack/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Adapter, Logger, Schema } from '@satorijs/satori'
import { SlackBot } from './bot'
import crypto from 'node:crypto'
import { EnvelopedEvent, SlackEvent, SocketEvent } from './types'
import { adaptSession } from './utils'

export class HttpServer extends Adapter.Server<SlackBot> {
logger = new Logger('slack')
async start(bot: SlackBot) {
// @ts-ignore
const { signing } = bot.config
const { userId } = await bot.getSelf()
bot.selfId = userId
bot.ctx.router.post('/slack', async (ctx) => {
const timestamp = ctx.request.header['x-slack-request-timestamp'].toString()
const signature = ctx.request.header['x-slack-signature'].toString()
const requestBody = ctx.request.rawBody

const hmac = crypto.createHmac('sha256', signing)
const [version, hash] = signature.split('=')

const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5
if (Number(timestamp) < fiveMinutesAgo) {
return ctx.status = 403
}

hmac.update(`${version}:${timestamp}:${requestBody}`)

if (hash !== hmac.digest('hex')) {
return ctx.status = 403
}
const { type } = ctx.request.body as SocketEvent
if (type === 'url_verification') {
ctx.status = 200
return ctx.body = {
challenge: ctx.request.body.challenge,
}
}
// https://api.slack.com/apis/connections/events-api#receiving-events
if (type === 'event_callback') {
ctx.status = 200
ctx.body = 'ok'
const payload: EnvelopedEvent<SlackEvent> = ctx.request.body
this.logger.debug(require('util').inspect(payload, false, null, true))
const session = await adaptSession(bot, payload)
this.logger.debug(require('util').inspect(session, false, null, true))
if (session) bot.dispatch(session)
}
})
}
}

export namespace HttpServer {
export interface Config {
protocol: 'http'
signing: string
}

export const Config: Schema<Config> = Schema.object({
protocol: Schema.const('http').required(),
signing: Schema.string().required(),
})
}
9 changes: 9 additions & 0 deletions adapters/slack/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { SlackBot } from './bot'

export * from './ws'
export * from './message'
export * from './utils'
export * from './http'
export * from './types'

export { SlackBot }
Loading

0 comments on commit 9aada8a

Please sign in to comment.