-
Notifications
You must be signed in to change notification settings - Fork 47
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge remote-tracking branch 'origin/slack'
- Loading branch information
Showing
39 changed files
with
5,483 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
Oops, something went wrong.