Skip to content

Commit

Permalink
feat(slack): add adapter-slack
Browse files Browse the repository at this point in the history
  • Loading branch information
XxLittleCxX committed Jul 5, 2023
1 parent 3d6c6b4 commit 1dfde7d
Show file tree
Hide file tree
Showing 12 changed files with 1,282 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"
}
}
157 changes: 157 additions & 0 deletions adapters/slack/src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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'
import { SlackMessageEncoder } from './message'
import { GenericMessageEvent, SlackChannel, SlackTeam, SlackUser } from './types'
import FormData from 'form-data'

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

constructor(ctx: Context, config: T) {
super(ctx, config)
this.http = ctx.http.extend({
headers: {
// 'Authorization': `Bearer ${config.token}`,
},
}).extend(config)

if (config.protocol === 'ws') {
ctx.plugin(WsClient, 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 {
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.request<AuthTestResponse>('POST', '/auth.test')
return {
userId: data.user_id,
avatar: null,
username: data.user,
isBot: !!data.bot_id,
}
}

async deleteMessage(channelId: string, messageId: string): Promise<void> {
return this.request('POST', '/chat.delete', { channel: channelId, ts: messageId })
}

async getMessage(channelId: string, messageId: string): Promise<Universal.Message> {
const msg = await this.request<{
messages: GenericMessageEvent[]
}>('POST', '/conversations.history', {
channel: channelId,
latest: messageId,
limit: 1,
})
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 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.request<{
channel: {
id: string
}
}>('POST', '/conversations.open', {
users: channelId,
})
return this.sendMessage(channel.id, content, undefined, options)
}
}

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'
16 changes: 16 additions & 0 deletions adapters/slack/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Adapter, Schema } from '@satorijs/satori'
import { SlackBot } from './bot'

export class HttpServer extends Adapter.Server<SlackBot<SlackBot.BaseConfig & HttpServer.Config>> {

}

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

export const Config: Schema<Config> = Schema.object({
protocol: Schema.const('http').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 }
115 changes: 115 additions & 0 deletions adapters/slack/src/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { h, MessageEncoder, Session } from '@satorijs/satori'
import { SlackBot } from './bot'
import FormData from 'form-data'
import { adaptMessage, adaptSentAsset } from './utils'
import { File } from './types'

// https://api.slack.com/reference/surfaces/formatting#basics
export const sanitize = (val: string) =>
val
.replace(/(?<!\u200b)[\*_~`]/g, '\u200B$&')
.replace(/@everyone/g, () => '@\u200Beveryone')
.replace(/@here/g, () => '@\u200Bhere')
.replace(/(?<!\u200b)^>/g, '\u200A&gt;')
// .replace(/<((?:#C|@U|!subteam\^)[0-9A-Z]{1,12})>/g, '&lt;$1&gt;')
// .replace(/<(\!(?:here|channel|everyone)(?:\|[0-9a-zA-Z?]*)?)>/g, '&lt;$1&gt;')
.replace(/<(.*?)>/g, '&lt;$1&gt;')

export class SlackMessageEncoder extends MessageEncoder<SlackBot> {
buffer = ''
thread_ts = null
elements: any[] = []
addition: Record<string, any> = {}
results: Session[] = []
async flush() {
if (!this.buffer.length) return
const r = await this.bot.request('POST', '/chat.postMessage', {
channel: this.channelId,
...this.addition,
thread_ts: this.thread_ts,
'blocks': [
{
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': this.buffer,
},
},
],
})
const session = this.bot.session()
adaptMessage(this.bot, r.message, session)
session.app.emit(session, 'send', session)
this.results.push(session)
this.buffer = ''
}

async sendAsset(element: h) {
if (this.buffer.length) await this.flush()
const { attrs } = element
const { filename, data, mime } = await this.bot.ctx.http.file(attrs.url, attrs)
const form = new FormData()
// https://github.com/form-data/form-data/issues/468
const value = process.env.KOISHI_ENV === 'browser'
? new Blob([data], { type: mime })
: Buffer.from(data)
form.append('file', value, attrs.file || filename)
form.append('channels', this.channelId)
if (this.thread_ts) form.append('thread_ts', this.thread_ts)
const sent = await this.bot.request<{
ok: boolean
file: File
}>('POST', '/files.upload', form, form.getHeaders())
if (sent.ok) {
const session = this.bot.session()
adaptSentAsset(sent.file, session)
session.app.emit(session, 'send', session)
this.results.push(session)
}
}

async visit(element: h) {
const { type, attrs, children } = element
if (type === 'text') {
this.buffer += sanitize(attrs.content)
} else if (type === 'image' && attrs.url) {
await this.sendAsset(element)
} else if (type === 'sharp' && attrs.id) {
this.buffer += `<#${attrs.id}>`
} else if (type === 'at') {
if (attrs.id) this.buffer += `<@${attrs.id}>`
} else if (type === 'b' || type === 'strong') {
this.buffer += '*'
await this.render(children)
this.buffer += '*'
} else if (type === 'i' || type === 'em') {
this.buffer += '_'
await this.render(children)
this.buffer += '_'
} else if (type === 's' || type === 'del') {
this.buffer += '~'
await this.render(children)
this.buffer += '~'
} else if (type === 'code') {
this.buffer += '`'
await this.render(children)
this.buffer += '`'
} else if (type === 'a') {
this.buffer += `<${attrs.href}|`
await this.render(children)
this.buffer += `>`
} else if (type === 'quote') {
this.thread_ts = attrs.id
} else if (type === 'p') {
this.buffer += `\n`
await this.render(children)
} else if (type === 'author') {
this.addition = {
username: attrs.nickname,
icon_url: attrs.avatar,
}
} else if (type === 'message') {
await this.render(children)
}
}
}
Loading

0 comments on commit 1dfde7d

Please sign in to comment.