Skip to content

Commit

Permalink
feat(slack): http mode, session.quote, reactions
Browse files Browse the repository at this point in the history
  • Loading branch information
XxLittleCxX committed Jul 8, 2023
1 parent 12253bd commit 0935b98
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 35 deletions.
3 changes: 2 additions & 1 deletion adapters/slack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"@slack/types": "^2.8.0",
"form-data": "^4.0.0"
"form-data": "^4.0.0",
"seratch-slack-types": "^0.8.0"
}
}
46 changes: 42 additions & 4 deletions adapters/slack/src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { adaptChannel, adaptGuild, adaptMessage, adaptUser, AuthTestResponse } f
import { SlackMessageEncoder } from './message'
import { GenericMessageEvent, SlackChannel, SlackTeam, SlackUser } from './types'
import FormData from 'form-data'
import * as WebApi from 'seratch-slack-types/web-api'

export class SlackBot<T extends SlackBot.Config = SlackBot.Config> extends Bot<T> {
static MessageEncoder = SlackMessageEncoder
Expand All @@ -20,6 +21,8 @@ export class SlackBot<T extends SlackBot.Config = SlackBot.Config> extends Bot<T

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

Expand All @@ -28,9 +31,11 @@ export class SlackBot<T extends SlackBot.Config = SlackBot.Config> extends Bot<T
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
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 }))
}
}
Expand All @@ -56,7 +61,7 @@ export class SlackBot<T extends SlackBot.Config = SlackBot.Config> extends Bot<T
channel: channelId,
oldest: messageId,
limit: 1,
inclusive: true
inclusive: true,
})
return adaptMessage(this, msg.messages[0])
}
Expand Down Expand Up @@ -132,6 +137,39 @@ export class SlackBot<T extends SlackBot.Config = SlackBot.Config> extends Bot<T
})
return this.sendMessage(channel.id, content, undefined, options)
}

async getReactions(channelId: string, messageId: string, emoji: string): Promise<Universal.User[]> {
const { message } = await this.request<WebApi.ReactionsGetResponse>('POST', '/reactions.get', `channel=${channelId}&timestamp=${messageId}&emoji=${emoji}`, {

Check warning on line 142 in adapters/slack/src/bot.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 161. Maximum allowed is 160
'content-type': 'application/x-www-form-urlencoded',
})
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
return this.request('POST', '/reactions.add', {
channel: channelId,
timestamp: messageId,
name: emoji,
})
}

async clearReaction(channelId: string, messageId: string, emoji?: string): Promise<void> {
const { message } = await this.request<WebApi.ReactionsGetResponse>('POST', '/reactions.get', `channel=${channelId}&timestamp=${messageId}&full=true`, {
'content-type': 'application/x-www-form-urlencoded',
})
for (const reaction of message.reactions) {
if (!emoji || reaction.name === emoji) {
await this.request('POST', '/reactions.remove', {
channel: channelId,
timestamp: messageId,
name: reaction.name,
})
}
}
}
}

export namespace SlackBot {
Expand Down
51 changes: 49 additions & 2 deletions adapters/slack/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,63 @@
import { Adapter, Schema } from '@satorijs/satori'
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<SlackBot.BaseConfig & HttpServer.Config>> {
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(),
})
}
8 changes: 4 additions & 4 deletions adapters/slack/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ export class SlackMessageEncoder extends MessageEncoder<SlackBot> {
channel: this.channelId,
...this.addition,
thread_ts: this.thread_ts,
text: this.buffer
text: this.buffer,
})
const session = this.bot.session()
adaptMessage(this.bot, r.message, session)
await adaptMessage(this.bot, r.message, session)
session.app.emit(session, 'send', session)
this.results.push(session)
this.buffer = ''
Expand Down Expand Up @@ -75,8 +75,8 @@ export class SlackMessageEncoder extends MessageEncoder<SlackBot> {
this.buffer += `<#${attrs.id}>`
} else if (type === 'at') {
if (attrs.id) this.buffer += `<@${attrs.id}>`
if (attrs.type === "all") this.buffer += `<!everyone>`
if (attrs.type === "here") this.buffer += `<!here>`
if (attrs.type === 'all') this.buffer += `<!everyone>`
if (attrs.type === 'here') this.buffer += `<!here>`
} else if (type === 'b' || type === 'strong') {
this.buffer += '*'
await this.render(children)
Expand Down
8 changes: 7 additions & 1 deletion adapters/slack/src/types/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export {
File,
} from './message-events'

export type SocketEvent = HelloEvent | EventsApiEvent
export type SocketEvent = HelloEvent | EventsApiEvent | UrlVerificationEvent | EnvelopedEvent

export interface HelloEvent {
type: 'hello'
Expand All @@ -31,6 +31,12 @@ export interface EventsApiEvent {
payload: EnvelopedEvent
}

export interface UrlVerificationEvent {
type: 'url_verification'
token: string
challenge: string
}

/**
* A Slack Events API event wrapped in the standard envelope.
*
Expand Down
67 changes: 46 additions & 21 deletions adapters/slack/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Element, h, Session, Universal } from '@satorijs/satori'
import { SlackBot } from './bot'
import { BasicSlackEvent, EnvelopedEvent, GenericMessageEvent, MessageChangedEvent, MessageDeletedEvent, MessageEvent, RichText, RichTextBlock, SlackUser } from './types/events'
import { BasicSlackEvent, EnvelopedEvent, GenericMessageEvent, MessageChangedEvent, MessageDeletedEvent, MessageEvent, ReactionAddedEvent, ReactionRemovedEvent, RichText, RichTextBlock, SlackEvent, SlackUser } from './types/events'

Check warning on line 3 in adapters/slack/src/utils.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 231. Maximum allowed is 160

Check failure on line 3 in adapters/slack/src/utils.ts

View workflow job for this annotation

GitHub Actions / lint

'BasicSlackEvent' is defined but never used

Check failure on line 3 in adapters/slack/src/utils.ts

View workflow job for this annotation

GitHub Actions / lint

'MessageEvent' is defined but never used
import { KnownBlock } from '@slack/types'
import { File, SlackChannel, SlackTeam } from './types'
import { unescape } from './message'
Expand Down Expand Up @@ -33,14 +33,14 @@ function adaptRichText(elements: RichText[]) {
function adaptMarkdown(markdown: string) {
let list = markdown.split(/(<(?:.*?)>)/g)
list = list.map(v => v.split(/(:(?:[a-zA-Z0-9_]+):)/g)).flat() // face
let result: Element[] = []
const result: Element[] = []
for (const item of list) {
if (!item) continue
const match = item.match(/<(.*?)>/)
if (match) {
if (match[0].startsWith("@U")) result.push(h.at(match[0].slice(2)))
if (match[0].startsWith("#C")) result.push(h.sharp(match[0].slice(2)))
} else if (item.startsWith(":") && item.endsWith(":")) {
if (match[0].startsWith('@U')) result.push(h.at(match[0].slice(2)))
if (match[0].startsWith('#C')) result.push(h.sharp(match[0].slice(2)))
} else if (item.startsWith(':') && item.endsWith(':')) {
result.push(h('face', { id: item.slice(1, -1) }))
} else {
result.push(h.text(item))
Expand Down Expand Up @@ -81,20 +81,24 @@ const adaptBotProfile = (evt: GenericMessageEvent): Universal.Author => ({
avatar: evt.bot_profile.icons.image_72,
})

export function prepareMessage(session: Partial<Session>, evt: MessageEvent) {
session.subtype = evt.channel_type === 'channel' ? 'group' : 'private'
export async function adaptMessage(bot: SlackBot, evt: GenericMessageEvent, session: Partial<Session> = {}) {
session.isDirect = evt.channel_type === 'im'
session.channelId = evt.channel
}

export function adaptMessage(bot: SlackBot, evt: GenericMessageEvent, session: Partial<Session> = {}) {
session.messageId = evt.ts
session.timestamp = ~~(Number(evt.ts) * 1000)
session.timestamp = Math.floor(Number(evt.ts) * 1000)
session.author = evt.bot_profile ? adaptBotProfile(evt) : adaptAuthor(evt)
session.userId = session.author.userId
if (evt.team) session.guildId = evt.team

let elements = []
if (evt.thread_ts) elements.push(h.quote(evt.thread_ts))
// if a message(parent message) was a thread, it has thread_ts property too
if (evt.thread_ts && evt.thread_ts !== evt.ts) {
const quoted = await bot.getMessage(session.channelId, evt.thread_ts)
session.quote = quoted
session.quote.channelId = session.channelId
}

// if (evt.thread_ts) elements.push(h.quote(evt.thread_ts))
elements = [...elements, ...adaptMessageBlocks(evt.blocks as unknown as NewKnownBlock[])]
for (const file of evt.files ?? []) {
if (file.mimetype.startsWith('video/')) {
Expand All @@ -120,12 +124,12 @@ export function adaptMessage(bot: SlackBot, evt: GenericMessageEvent, session: P
}

export function adaptMessageDeleted(bot: SlackBot, evt: MessageDeletedEvent, session: Partial<Session> = {}) {
session.subtype = evt.channel_type === 'channel' ? 'group' : 'private'
session.isDirect = evt.channel_type === 'im'
session.channelId = evt.channel
session.guildId = evt.previous_message.team
session.type = 'message-deleted'
session.messageId = evt.previous_message.ts
session.timestamp = ~~(Number(evt.previous_message.ts) * 1000)
session.timestamp = Math.floor(Number(evt.previous_message.ts) * 1000)

adaptMessage(bot, evt.previous_message, session)
}
Expand All @@ -145,28 +149,49 @@ export function adaptSentAsset(file: File, session: Partial<Session> = {}) {
return session as Universal.Message
}

export async function adaptSession(bot: SlackBot, payload: EnvelopedEvent<BasicSlackEvent>) {
function setupReaction(session: Partial<Session>, data: EnvelopedEvent<ReactionAddedEvent> | EnvelopedEvent<ReactionRemovedEvent>) {
session.guildId = data.team_id
session.channelId = data.event.item.channel
session.messageId = data.event.item.ts
session.timestamp = Math.floor(Number(data.event.item.ts) * 1000)
session.userId = data.event.user
session.content = data.event.reaction
}

export async function adaptSession(bot: SlackBot, payload: EnvelopedEvent<SlackEvent>) {
const session = bot.session()
// https://api.slack.com/events
if (payload.event.type === 'message') {
const input = payload.event as GenericMessageEvent
// @ts-ignore
if (input.app_id === bot.selfId) return
if (input.user === bot.selfId) return
if (!input.subtype) {
session.type = 'message'
prepareMessage(session, input)
adaptMessage(bot, input as unknown as GenericMessageEvent, session)
await adaptMessage(bot, input as unknown as GenericMessageEvent, session)
}
if (input.subtype === 'message_deleted') adaptMessageDeleted(bot, input as unknown as MessageDeletedEvent, session)
if (input.subtype === 'message_changed') {
const evt = input as unknown as MessageChangedEvent
session.type = 'message-updated'
// @ts-ignore
session.guildId = payload.team_id
prepareMessage(session, input)
adaptMessage(bot, evt.message, session)
await adaptMessage(bot, evt.message, session)
}
return session
} else if (payload.event.type === 'channel_left') {
session.type = 'channel-removed'
session.channelId = payload.event.channel
session.timestamp = Math.floor(Number(payload.event.event_ts) * 1000)
session.guildId = payload.team_id
} else if (payload.event.type === 'reaction_added') {
session.type = 'reaction-added'
setupReaction(session, payload as any)
} else if (payload.event.type === 'reaction_removed') {
session.type = 'reaction-deleted'
setupReaction(session, payload as any)
} else {
return
}
return session
}

export interface AuthTestResponse {
Expand Down
6 changes: 4 additions & 2 deletions adapters/slack/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const logger = new Logger('slack')

export class WsClient extends Adapter.WsClient<SlackBot> {
async prepare(bot: SlackBot) {
const { userId } = await bot.getSelf()
bot.selfId = userId
const data = await bot.request('POST', '/apps.connections.open', {}, {}, true)
const { url } = data
logger.debug('ws url: %s', url)
Expand All @@ -20,11 +22,11 @@ export class WsClient extends Adapter.WsClient<SlackBot> {
const { type } = parsed
if (type === 'hello') {
// @ts-ignore
this.bot.selfId = parsed.connection_info.app_id
// this.bot.selfId = parsed.connection_info.app_id
return this.bot.online()
}
if (type === 'events_api') {
const { envelope_id} = parsed
const { envelope_id } = parsed
const payload: EnvelopedEvent<BasicSlackEvent> = parsed.payload
bot.socket.send(JSON.stringify({ envelope_id }))
const session = await adaptSession(bot, payload)

Check failure on line 32 in adapters/slack/src/ws.ts

View workflow job for this annotation

GitHub Actions / build

Argument of type 'EnvelopedEvent<BasicSlackEvent<string>>' is not assignable to parameter of type 'EnvelopedEvent<SlackEvent>'.
Expand Down

0 comments on commit 0935b98

Please sign in to comment.