diff --git a/adapters/slack/package.json b/adapters/slack/package.json index 578f1650..129f433f 100644 --- a/adapters/slack/package.json +++ b/adapters/slack/package.json @@ -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" } } diff --git a/adapters/slack/src/bot.ts b/adapters/slack/src/bot.ts index 6b206eb7..79809eca 100644 --- a/adapters/slack/src/bot.ts +++ b/adapters/slack/src/bot.ts @@ -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 extends Bot { static MessageEncoder = SlackMessageEncoder @@ -20,6 +21,8 @@ export class SlackBot extends Bot extends Bot extends Bot extends Bot { + const { message } = await this.request('POST', '/reactions.get', `channel=${channelId}×tamp=${messageId}&emoji=${emoji}`, { + '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 { + // reactions.write + return this.request('POST', '/reactions.add', { + channel: channelId, + timestamp: messageId, + name: emoji, + }) + } + + async clearReaction(channelId: string, messageId: string, emoji?: string): Promise { + const { message } = await this.request('POST', '/reactions.get', `channel=${channelId}×tamp=${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 { diff --git a/adapters/slack/src/http.ts b/adapters/slack/src/http.ts index 2a296549..2d9ccc67 100644 --- a/adapters/slack/src/http.ts +++ b/adapters/slack/src/http.ts @@ -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> { +export class HttpServer extends Adapter.Server { + 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 = 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 = Schema.object({ protocol: Schema.const('http').required(), + signing: Schema.string().required(), }) } diff --git a/adapters/slack/src/message.ts b/adapters/slack/src/message.ts index 75c02503..a0b2eec4 100644 --- a/adapters/slack/src/message.ts +++ b/adapters/slack/src/message.ts @@ -32,10 +32,10 @@ export class SlackMessageEncoder extends MessageEncoder { 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 = '' @@ -75,8 +75,8 @@ export class SlackMessageEncoder extends MessageEncoder { this.buffer += `<#${attrs.id}>` } else if (type === 'at') { if (attrs.id) this.buffer += `<@${attrs.id}>` - if (attrs.type === "all") this.buffer += `` - if (attrs.type === "here") this.buffer += `` + if (attrs.type === 'all') this.buffer += `` + if (attrs.type === 'here') this.buffer += `` } else if (type === 'b' || type === 'strong') { this.buffer += '*' await this.render(children) diff --git a/adapters/slack/src/types/events/index.ts b/adapters/slack/src/types/events/index.ts index 852bfd30..e1f1976b 100644 --- a/adapters/slack/src/types/events/index.ts +++ b/adapters/slack/src/types/events/index.ts @@ -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' @@ -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. * diff --git a/adapters/slack/src/utils.ts b/adapters/slack/src/utils.ts index c5b51845..e889c9f6 100644 --- a/adapters/slack/src/utils.ts +++ b/adapters/slack/src/utils.ts @@ -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' import { KnownBlock } from '@slack/types' import { File, SlackChannel, SlackTeam } from './types' import { unescape } from './message' @@ -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)) @@ -81,20 +81,24 @@ const adaptBotProfile = (evt: GenericMessageEvent): Universal.Author => ({ avatar: evt.bot_profile.icons.image_72, }) -export function prepareMessage(session: Partial, evt: MessageEvent) { - session.subtype = evt.channel_type === 'channel' ? 'group' : 'private' +export async function adaptMessage(bot: SlackBot, evt: GenericMessageEvent, session: Partial = {}) { + session.isDirect = evt.channel_type === 'im' session.channelId = evt.channel -} - -export function adaptMessage(bot: SlackBot, evt: GenericMessageEvent, session: Partial = {}) { 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/')) { @@ -120,12 +124,12 @@ export function adaptMessage(bot: SlackBot, evt: GenericMessageEvent, session: P } export function adaptMessageDeleted(bot: SlackBot, evt: MessageDeletedEvent, session: Partial = {}) { - 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) } @@ -145,16 +149,25 @@ export function adaptSentAsset(file: File, session: Partial = {}) { return session as Universal.Message } -export async function adaptSession(bot: SlackBot, payload: EnvelopedEvent) { +function setupReaction(session: Partial, data: EnvelopedEvent | EnvelopedEvent) { + 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) { 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') { @@ -162,11 +175,23 @@ export async function adaptSession(bot: SlackBot, payload: EnvelopedEvent { 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) @@ -20,11 +22,11 @@ export class WsClient extends Adapter.WsClient { 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 = parsed.payload bot.socket.send(JSON.stringify({ envelope_id })) const session = await adaptSession(bot, payload)