diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.ts b/apps/meteor/app/slackbridge/server/RocketAdapter.ts index 87f549a07eb08..95d7fb3d5c95b 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.ts +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -1,31 +1,54 @@ -// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS -// TODO: Remove the following lint/ts instructions when the file gets properly converted -/* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ -// @ts-nocheck import util from 'util'; +import type { DeepWritable, IMessage, IRegisterUser, IRoom, IUser, MessageAttachment, RequiredField } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; +import { promiseTimeout } from '@rocket.chat/tools'; +import type { BotMessageEvent, MessageEvent } from '@slack/types'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; +import type { MatchKeysAndValues } from 'mongodb'; import _ from 'underscore'; +import type { IMessageSyncedWithSlack, SlackTS } from './definition/IMessageSyncedWithSlack'; +import type { IRocketChatAdapter, SlackConversationSyncedWithRocketChat } from './definition/IRocketChatAdapter'; +import type { ISlackAdapter } from './definition/ISlackAdapter'; +import type { ISlackbridge } from './definition/ISlackbridge'; +import type { RocketChatMessageData } from './definition/RocketChatMessageData'; +import type { SlackMessageEvent } from './definition/SlackMessageEvent'; import { rocketLogger } from './logger'; import { sleep } from '../../../lib/utils/sleep'; +import { replace } from '../../../lib/utils/stringUtils'; import { callbacks } from '../../../server/lib/callbacks'; import { createRoom } from '../../lib/server/functions/createRoom'; import { sendMessage } from '../../lib/server/functions/sendMessage'; import { setUserAvatar } from '../../lib/server/functions/setUserAvatar'; import { settings } from '../../settings/server'; -export default class RocketAdapter { - constructor(slackBridge) { +export default class RocketAdapter implements IRocketChatAdapter { + private _slackAdapters: ISlackAdapter[] = []; + + private util: typeof util; + + private userTags: Record< + string, + { + slack: `<@${string}>`; + rocket: `@${string}`; + } + >; + + public get slackAdapters(): ISlackAdapter[] { + return this._slackAdapters; + } + + constructor(private slackBridge: ISlackbridge) { rocketLogger.debug('constructor'); this.slackBridge = slackBridge; this.util = util; this.userTags = {}; - this.slackAdapters = []; + this._slackAdapters = []; } connect() { @@ -36,14 +59,14 @@ export default class RocketAdapter { this.unregisterForEvents(); } - addSlack(slack) { - if (this.slackAdapters.indexOf(slack) < 0) { - this.slackAdapters.push(slack); + addSlack(slack: ISlackAdapter) { + if (this._slackAdapters.indexOf(slack) < 0) { + this._slackAdapters.push(slack); } } clearSlackAdapters() { - this.slackAdapters = []; + this._slackAdapters = []; } registerForEvents() { @@ -62,8 +85,8 @@ export default class RocketAdapter { callbacks.remove('afterUnsetReaction', 'SlackBridge_UnSetReaction'); } - async onMessageDelete(rocketMessageDeleted) { - for await (const slack of this.slackAdapters) { + async onMessageDelete(rocketMessageDeleted: IMessage) { + for await (const slack of this._slackAdapters) { try { if (!slack.getSlackChannel(rocketMessageDeleted.rid)) { // This is on a channel that the rocket bot is not subscribed on this slack server @@ -78,7 +101,7 @@ export default class RocketAdapter { } } - async onSetReaction(rocketMsg, { reaction }) { + async onSetReaction(rocketMsg: IMessage, { reaction }: { user: IUser; reaction: string; shouldReact: boolean }) { try { if (!this.slackBridge.isReactionsEnabled) { return; @@ -92,7 +115,7 @@ export default class RocketAdapter { return; } if (rocketMsg) { - for await (const slack of this.slackAdapters) { + for await (const slack of this._slackAdapters) { const slackChannel = slack.getSlackChannel(rocketMsg.rid); if (slackChannel != null) { const slackTS = slack.getTimeStamp(rocketMsg); @@ -106,7 +129,7 @@ export default class RocketAdapter { } } - async onUnSetReaction(rocketMsg, { reaction }) { + async onUnSetReaction(rocketMsg: IMessage, { reaction }: { user: IUser; reaction: string; shouldReact: boolean; oldMessage: IMessage }) { try { if (!this.slackBridge.isReactionsEnabled) { return; @@ -121,7 +144,7 @@ export default class RocketAdapter { } if (rocketMsg) { - for await (const slack of this.slackAdapters) { + for await (const slack of this._slackAdapters) { const slackChannel = slack.getSlackChannel(rocketMsg.rid); if (slackChannel != null) { const slackTS = slack.getTimeStamp(rocketMsg); @@ -135,8 +158,8 @@ export default class RocketAdapter { } } - async onMessage(rocketMessage) { - for await (const slack of this.slackAdapters) { + async onMessage(rocketMessage: IMessageSyncedWithSlack, _params: unknown) { + for await (const slack of this._slackAdapters) { try { if (!slack.getSlackChannel(rocketMessage.rid)) { // This is on a channel that the rocket bot is not subscribed @@ -144,7 +167,7 @@ export default class RocketAdapter { } rocketLogger.debug({ msg: 'onRocketMessage', rocketMessage }); - if (rocketMessage.editedAt) { + if (isEditedMessage(rocketMessage)) { // This is an Edit Event await this.processMessageChanged(rocketMessage, slack); continue; @@ -155,7 +178,7 @@ export default class RocketAdapter { } if (rocketMessage.file) { - await this.processFileShare(rocketMessage, slack); + await this.processFileShare(rocketMessage as RequiredField, slack); continue; } @@ -169,13 +192,13 @@ export default class RocketAdapter { return rocketMessage; } - async processSendMessage(rocketMessage, slack) { + async processSendMessage(rocketMessage: IMessage, slack: ISlackAdapter) { // Since we got this message, SlackBridge_Out_Enabled is true if (settings.get('SlackBridge_Out_All') === true) { await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), rocketMessage); } else { // They want to limit to certain groups - const outSlackChannels = _.pluck(settings.get('SlackBridge_Out_Channels'), '_id') || []; + const outSlackChannels = _.pluck(settings.get('SlackBridge_Out_Channels'), '_id') || []; // rocketLogger.debug('Out SlackChannels: ', outSlackChannels); if (outSlackChannels.indexOf(rocketMessage.rid) !== -1) { await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), rocketMessage); @@ -183,12 +206,12 @@ export default class RocketAdapter { } } - getMessageAttachment(rocketMessage) { + getMessageAttachment(rocketMessage: IMessage): MessageAttachment | undefined { if (!rocketMessage.file) { return; } - if (!rocketMessage.attachments || !rocketMessage.attachments.length) { + if (!rocketMessage.attachments?.length) { return; } @@ -196,14 +219,14 @@ export default class RocketAdapter { return rocketMessage.attachments.find((attachment) => attachment.title_link && attachment.title_link.indexOf(`/${fileId}/`) >= 0); } - async processFileShare(rocketMessage, slack) { + async processFileShare(rocketMessage: RequiredField, slack: ISlackAdapter) { if (!settings.get('SlackBridge_FileUpload_Enabled')) { return; } if (rocketMessage.file.name) { let fileName = rocketMessage.file.name; - let text = rocketMessage.msg; + let text: string | undefined = rocketMessage.msg; const attachment = this.getMessageAttachment(rocketMessage); if (attachment) { @@ -217,42 +240,62 @@ export default class RocketAdapter { } } - async processMessageChanged(rocketMessage, slack) { - if (rocketMessage) { - if (rocketMessage.updatedBySlack) { - // We have already processed this - delete rocketMessage.updatedBySlack; - return; - } + async processMessageChanged(rocketMessage: IMessageSyncedWithSlack, slack: ISlackAdapter) { + if (!rocketMessage) { + return; + } - // This was a change from Rocket.Chat - const slackChannel = slack.getSlackChannel(rocketMessage.rid); - await slack.postMessageUpdate(slackChannel, rocketMessage); + if (rocketMessage.updatedBySlack) { + // We have already processed this + delete rocketMessage.updatedBySlack; + return; } + + // This was a change from Rocket.Chat + const slackChannel = slack.getSlackChannel(rocketMessage.rid); + await slack.postMessageUpdate(slackChannel, rocketMessage); } - async getChannel(slackMessage) { - return slackMessage.channel ? (await this.findChannel(slackMessage.channel)) || this.addChannel(slackMessage.channel) : null; + async getChannel(slackMessage: { channel?: string }): Promise { + if (!slackMessage.channel) { + return null; + } + + const channel = await this.findChannel(slackMessage.channel); + if (channel) { + return channel; + } + + return this.addChannel(slackMessage.channel); } - async getUser(slackUser) { - return slackUser ? (await this.findUser(slackUser)) || this.addUser(slackUser) : null; + async getUser(slackUser: string): Promise { + if (!slackUser) { + return null; + } + + const user = await this.findUser(slackUser); + if (user) { + return user; + } + + return this.addUser(slackUser); } - createRocketID(slackChannel, ts) { + createRocketID(slackChannel: string, ts: SlackTS): string { return `slack-${slackChannel}-${ts.replace(/\./g, '-')}`; } - async findChannel(slackChannelId) { + async findChannel(slackChannelId: string): Promise { return Rooms.findOneByImportId(slackChannelId); } - async getRocketUsers(members, slackChannel) { + async getRocketUsers(members: string[], slackChannel: SlackConversationSyncedWithRocketChat) { const rocketUsers = []; for await (const member of members) { if (member !== slackChannel.creator) { const rocketUser = (await this.findUser(member)) || (await this.addUser(member)); - if (rocketUser && rocketUser.username) { + if (rocketUser?.username) { rocketUsers.push(rocketUser.username); } } @@ -260,20 +303,20 @@ export default class RocketAdapter { return rocketUsers; } - async getRocketUserCreator(slackChannel) { + async getRocketUserCreator(slackChannel: SlackConversationSyncedWithRocketChat) { return slackChannel.creator ? (await this.findUser(slackChannel.creator)) || this.addUser(slackChannel.creator) : null; } - async addChannel(slackChannelID, hasRetried = false) { + async addChannel(slackChannelID: string, hasRetried = false): Promise { rocketLogger.debug({ msg: 'Adding Rocket.Chat channel from Slack', slackChannelID }); - let addedRoom; + let addedRoom = null; - for await (const slack of this.slackAdapters) { + for await (const slack of this._slackAdapters) { if (addedRoom) { continue; } - const slackChannel = await slack.slackAPI.getRoomInfo(slackChannelID); + const slackChannel: SlackConversationSyncedWithRocketChat | undefined = await slack.slackAPI.getRoomInfo(slackChannelID); if (slackChannel) { const members = await slack.slackAPI.getMembers(slackChannelID); if (!members) { @@ -281,11 +324,18 @@ export default class RocketAdapter { continue; } - const rocketRoom = await Rooms.findOneByName(slackChannel.name); + const { name: slackChannelName } = slackChannel; + + if (!slackChannelName) { + rocketLogger.debug('Unable to addChannel: slack data is missing channel name.'); + continue; + } + + const rocketRoom = await Rooms.findOneByName(slackChannelName); if (rocketRoom || slackChannel.is_general) { - slackChannel.rocketId = slackChannel.is_general ? 'GENERAL' : rocketRoom._id; - await Rooms.addImportIds(slackChannel.rocketId, slackChannel.id); + slackChannel.rocketId = slackChannel.is_general ? 'GENERAL' : (rocketRoom as IRoom)._id; + await Rooms.addImportIds(slackChannel.rocketId, [slackChannel.id || slackChannelID]); } else { const rocketUsers = await this.getRocketUsers(members, slackChannel); const rocketUserCreator = await this.getRocketUserCreator(slackChannel); @@ -297,7 +347,7 @@ export default class RocketAdapter { try { const isPrivate = slackChannel.is_private; - const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator, rocketUsers); + const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannelName, rocketUserCreator, rocketUsers); slackChannel.rocketId = rocketChannel.rid; } catch (e) { if (!hasRetried) { @@ -309,24 +359,28 @@ export default class RocketAdapter { rocketLogger.error({ msg: 'Error adding channel from Slack', err: e }); } - const roomUpdate = { - ts: new Date(slackChannel.created * 1000), + const roomUpdate: Record = { + ...(slackChannel.created && { + ts: new Date(slackChannel.created * 1000), + }), }; let lastSetTopic = 0; - if (slackChannel.topic && slackChannel.topic.value) { + if (slackChannel.topic?.value) { roomUpdate.topic = slackChannel.topic.value; - lastSetTopic = slackChannel.topic.last_set; + if (slackChannel.topic.last_set) { + lastSetTopic = slackChannel.topic.last_set; + } } - if (slackChannel.purpose && slackChannel.purpose.value && slackChannel.purpose.last_set > lastSetTopic) { + if (slackChannel.purpose?.value && (slackChannel.purpose.last_set || 0) > lastSetTopic) { roomUpdate.topic = slackChannel.purpose.value; } - await Rooms.addImportIds(slackChannel.rocketId, slackChannel.id); - slack.addSlackChannel(slackChannel.rocketId, slackChannelID); + await Rooms.addImportIds(slackChannel.rocketId as string, [slackChannel.id || slackChannelID]); + slack.addSlackChannel(slackChannel.rocketId as string, slackChannelID); } - addedRoom = await Rooms.findOneById(slackChannel.rocketId); + addedRoom = await Rooms.findOneById(slackChannel.rocketId as string); } } @@ -336,7 +390,7 @@ export default class RocketAdapter { return addedRoom; } - async findUser(slackUserID) { + async findUser(slackUserID: string): Promise { const rocketUser = await Users.findOneByImportId(slackUserID); if (rocketUser && !this.userTags[slackUserID]) { this.userTags[slackUserID] = { @@ -344,22 +398,22 @@ export default class RocketAdapter { rocket: `@${rocketUser.username}`, }; } - return rocketUser; + return rocketUser as IRegisterUser | null; } - async addUser(slackUserID) { + async addUser(slackUserID: string): Promise { rocketLogger.debug({ msg: 'Adding Rocket.Chat user from Slack', slackUserID }); let addedUser; - for await (const slack of this.slackAdapters) { + for await (const slack of this._slackAdapters) { if (addedUser) { - return; + break; } const user = await slack.slackAPI.getUser(slackUserID); if (user) { - const rocketUserData = user; + const rocketUserData: Partial = user; const isBot = rocketUserData.is_bot === true; - const email = (rocketUserData.profile && rocketUserData.profile.email) || ''; + const email = rocketUserData.profile?.email || ''; let existingRocketUser; if (!isBot) { existingRocketUser = @@ -372,7 +426,7 @@ export default class RocketAdapter { rocketUserData.rocketId = existingRocketUser._id; rocketUserData.name = existingRocketUser.username; } else { - const newUser = { + const newUser: Parameters[0] & { joinDefaultChannels?: boolean } = { password: Random.id(), username: rocketUserData.name, }; @@ -386,12 +440,12 @@ export default class RocketAdapter { } rocketUserData.rocketId = await Accounts.createUserAsync(newUser); - const userUpdate = { - utcOffset: rocketUserData.tz_offset / 3600, // Slack's is -18000 which translates to Rocket.Chat's after dividing by 3600, + const userUpdate: DeepWritable> = { + utcOffset: (rocketUserData.tz_offset || 0) / 3600, // Slack's is -18000 which translates to Rocket.Chat's after dividing by 3600, roles: isBot ? ['bot'] : ['user'], }; - if (rocketUserData.profile && rocketUserData.profile.real_name) { + if (rocketUserData.profile?.real_name) { userUpdate.name = rocketUserData.profile.real_name; } @@ -414,14 +468,15 @@ export default class RocketAdapter { } if (url) { try { - await setUserAvatar(user, url, null, 'url'); + // added typecast on conversion to TS as this doesn't match any valid signature #TODO + await setUserAvatar(user as IUser, url, null as any, 'url'); } catch (err) { rocketLogger.debug({ msg: 'Error setting user avatar from Slack', err }); } } } - const importIds = [rocketUserData.id]; + const importIds = [rocketUserData.id as string]; if (isBot && rocketUserData.profile && rocketUserData.profile.bot_id) { importIds.push(rocketUserData.profile.bot_id); } @@ -432,7 +487,7 @@ export default class RocketAdapter { rocket: `@${rocketUserData.name}`, }; } - addedUser = await Users.findOneById(rocketUserData.rocketId); + addedUser = await Users.findOneById(rocketUserData.rocketId); } } @@ -440,11 +495,11 @@ export default class RocketAdapter { rocketLogger.debug('User not added'); } - return addedUser; + return addedUser || null; } - addAliasToMsg(rocketUserName, rocketMsgObj) { - const aliasFormat = settings.get('SlackBridge_AliasFormat'); + addAliasToMsg(rocketUserName: string, rocketMsgObj: Partial): Partial { + const { aliasFormat } = this.slackBridge; if (aliasFormat) { const alias = this.util.format(aliasFormat, rocketUserName); @@ -456,96 +511,139 @@ export default class RocketAdapter { return rocketMsgObj; } - async createAndSaveMessage(rocketChannel, rocketUser, slackMessage, rocketMsgDataDefaults, isImporting, slack) { - if (slackMessage.type === 'message') { - let rocketMsgObj = {}; - if (!_.isEmpty(slackMessage.subtype)) { - rocketMsgObj = await slack.processSubtypedMessage(rocketChannel, rocketUser, slackMessage, isImporting); - if (!rocketMsgObj) { - return; - } - } else { - rocketMsgObj = { - msg: await this.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), - rid: rocketChannel._id, - u: { - _id: rocketUser._id, - username: rocketUser.username, - }, - }; - - this.addAliasToMsg(rocketUser.username, rocketMsgObj); - } - _.extend(rocketMsgObj, rocketMsgDataDefaults); - if (slackMessage.edited) { - rocketMsgObj.editedAt = new Date(parseInt(slackMessage.edited.ts.split('.')[0]) * 1000); - } - rocketMsgObj.slackTs = slackMessage.ts; - if (slackMessage.thread_ts) { - const tmessage = await Messages.findOneBySlackTs(slackMessage.thread_ts); - if (tmessage) { - rocketMsgObj.tmid = tmessage._id; - } - } + async buildMessageObjectFor( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: SlackMessageEvent, + isImporting: boolean, + slack: ISlackAdapter, + ): Promise { + if (slackMessage.subtype) { + return slack.processSubtypedMessage(rocketChannel, rocketUser, slackMessage, isImporting); + } - if (slackMessage.subtype === 'bot_message') { - rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); - } + const rocketMsgObj = { + msg: await this.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), + rid: rocketChannel._id, + u: { + _id: rocketUser._id, + username: rocketUser.username, + }, + }; - if (slackMessage.pinned_to && slackMessage.pinned_to.indexOf(slackMessage.channel) !== -1) { - rocketMsgObj.pinned = true; - rocketMsgObj.pinnedAt = Date.now; - rocketMsgObj.pinnedBy = _.pick(rocketUser, '_id', 'username'); - } - if (slackMessage.subtype === 'bot_message') { - setTimeout(async () => { - if (slackMessage.bot_id && slackMessage.ts) { - // Make sure that a message with the same bot_id and timestamp doesn't already exists - const msg = await Messages.findOneBySlackBotIdAndSlackTs(slackMessage.bot_id, slackMessage.ts); - if (!msg) { - void sendMessage(rocketUser, rocketMsgObj, rocketChannel, true); - } - } - }, 500); - } else { - rocketLogger.debug('Send message to Rocket.Chat'); - await sendMessage(rocketUser, rocketMsgObj, rocketChannel, true); - } + this.addAliasToMsg(rocketUser.username, rocketMsgObj); + return rocketMsgObj; + } + + async sendBotMessage(rocketMsgObj: RocketChatMessageData, rocketChannel: IRoom, slackMessage: BotMessageEvent): Promise { + const fromUser = await Users.findOneById>('rocket.cat', { projection: { username: 1 } }); + + if (!fromUser) { + rocketLogger.debug('Unable to save bot message from slack as rocket.cat was not found.'); + return; + } + + if (rocketMsgObj.pinned) { + rocketMsgObj.pinnedBy = { + _id: fromUser._id, + username: fromUser.username, + }; + } + + const { bot_id, ts } = slackMessage; + + if (!bot_id || !ts) { + return; + } + + await promiseTimeout(500); + + // Make sure that a message with the same bot_id and timestamp doesn't already exists + const existingMessage = await Messages.findOneBySlackBotIdAndSlackTs(bot_id, ts); + if (!existingMessage) { + await sendMessage(fromUser, rocketMsgObj, rocketChannel, true); } } - async convertSlackMsgTxtToRocketTxtFormat(slackMsgTxt) { - const regex = /(?:<@)([a-zA-Z0-9]+)(?:\|.+)?(?:>)/g; - if (!_.isEmpty(slackMsgTxt)) { - slackMsgTxt = slackMsgTxt.replace(//g, '@all'); - slackMsgTxt = slackMsgTxt.replace(//g, '@all'); - slackMsgTxt = slackMsgTxt.replace(//g, '@here'); - slackMsgTxt = slackMsgTxt.replace(/>/g, '>'); - slackMsgTxt = slackMsgTxt.replace(/</g, '<'); - slackMsgTxt = slackMsgTxt.replace(/&/g, '&'); - slackMsgTxt = slackMsgTxt.replace(/:simple_smile:/g, ':smile:'); - slackMsgTxt = slackMsgTxt.replace(/:memo:/g, ':pencil:'); - slackMsgTxt = slackMsgTxt.replace(/:piggy:/g, ':pig:'); - slackMsgTxt = slackMsgTxt.replace(/:uk:/g, ':gb:'); - slackMsgTxt = slackMsgTxt.replace(/<(http[s]?:[^>]*)>/g, '$1'); - - const promises = []; - - slackMsgTxt.replace(regex, async (match, userId) => { - if (!this.userTags[userId]) { - (await this.findUser(userId)) || (await this.addUser(userId)); // This adds userTags for the userId - } - const userTags = this.userTags[userId]; - if (userTags) { - promises.push(slackMsgTxt.replace(userTags.slack, userTags.rocket)); - } - }); + async createAndSaveMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: MessageEvent, + rocketMsgDataDefaults: Partial, + isImporting: boolean, + slack: ISlackAdapter, + ) { + if (slackMessage.type !== 'message') { + return; + } + + const messageData = await this.buildMessageObjectFor(rocketChannel, rocketUser, slackMessage, isImporting, slack); + if (!messageData) { + return; + } - const result = await Promise.all(promises); - slackMsgTxt = slackMsgTxt.replace(regex, () => result.shift()); + const threadTs = 'thread_ts' in slackMessage && slackMessage.thread_ts; + const threadMessage = threadTs && (await Messages.findOneBySlackTs(threadTs)); + const isPinned = 'pinned_to' in slackMessage && slackMessage.pinned_to?.includes(slackMessage.channel); + + const rocketMsgObj: RocketChatMessageData = { + ...messageData, + ...rocketMsgDataDefaults, + ...('edited' in slackMessage && + slackMessage.edited && { + editedAt: new Date(parseInt(slackMessage.edited.ts.split('.')[0]) * 1000), + }), + slackTs: slackMessage.ts, + ...(threadMessage && { + tmid: threadMessage._id, + }), + ...(isPinned && { + pinned: true, + pinnedAt: Date.now(), + pinnedBy: { + _id: rocketUser._id, + username: rocketUser.username, + }, + }), + }; + + if (slackMessage.subtype === 'bot_message') { + await this.sendBotMessage(rocketMsgObj, rocketChannel, slackMessage); } else { - slackMsgTxt = ''; + rocketLogger.debug('Send message to Rocket.Chat'); + await sendMessage(rocketUser, rocketMsgObj, rocketChannel, true); } - return slackMsgTxt; + } + + async convertSlackMsgTxtToRocketTxtFormat(slackMsgTxt: string | undefined): Promise { + if (!slackMsgTxt) { + return ''; + } + + const replacements: [RegExp, string][] = [ + [//g, '@all'], + [//g, '@all'], + [//g, '@here'], + [/>/g, '>'], + [/</g, '<'], + [/&/g, '&'], + [/:simple_smile:/g, ':smile:'], + [/:memo:/g, ':pencil:'], + [/:piggy:/g, ':pig:'], + [/:uk:/g, ':gb:'], + [/<(http[s]?:[^>]*)>/g, '$1'], + ]; + + const msgWithReplacedTags = replacements.reduce((acc, [reg, str]) => acc.replace(reg, str), slackMsgTxt); + + const regex = /(?:<@)([a-zA-Z0-9]+)(?:\|.+)?(?:>)/g; + return replace(msgWithReplacedTags, regex, async (match, userId) => { + if (!this.userTags[userId]) { + (await this.findUser(userId)) || (await this.addUser(userId)); // This adds userTags for the userId + } + const userTags = this.userTags[userId]; + + return userTags?.rocket || match; + }); } } diff --git a/apps/meteor/app/slackbridge/server/SlackAPI.ts b/apps/meteor/app/slackbridge/server/SlackAPI.ts index e9338ce5792e2..204ed10392515 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.ts +++ b/apps/meteor/app/slackbridge/server/SlackAPI.ts @@ -1,16 +1,31 @@ -// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS -// TODO: Remove the following lint/ts instructions when the file gets properly converted -/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ -// @ts-nocheck import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import type { + ChatDeleteArguments, + ChatPostMessageArguments, + ChatPostMessageResponse, + ChatUpdateArguments, + ConversationsHistoryArguments, + ConversationsHistoryResponse, + ConversationsInfoResponse, + ConversationsListResponse, + ConversationsMembersResponse, + PinsListResponse, + ReactionsAddArguments, + ReactionsRemoveArguments, + UsersInfoResponse, +} from '@slack/web-api'; -export class SlackAPI { - constructor(apiOrBotToken) { +import type { ISlackAPI } from './definition/ISlackAPI'; + +export class SlackAPI implements ISlackAPI { + private token: string; + + constructor(apiOrBotToken: string) { this.token = apiOrBotToken; } - async getChannels(cursor = null) { - let channels = []; + async getChannels(cursor: string | null = null): Promise['channels']> { + let channels: ConversationsListResponse['channels'] = []; const request = await fetch('https://slack.com/api/conversations.list', { headers: { Authorization: `Bearer ${this.token}`, @@ -22,11 +37,11 @@ export class SlackAPI { ...(cursor && { cursor }), }, }); - const response = await request.json(); + const response: ConversationsListResponse = await request.json(); - if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { + if (response && Array.isArray(response.channels) && response.channels.length > 0) { channels = channels.concat(response.channels); - if (response.response_metadata && response.response_metadata.next_cursor) { + if (response.response_metadata?.next_cursor) { const nextChannels = await this.getChannels(response.response_metadata.next_cursor); channels = channels.concat(nextChannels); } @@ -35,8 +50,8 @@ export class SlackAPI { return channels; } - async getGroups(cursor = null) { - let groups = []; + async getGroups(cursor: string | null = null): Promise['channels']> { + let groups: ConversationsListResponse['channels'] = []; const request = await fetch('https://slack.com/api/conversations.list', { headers: { Authorization: `Bearer ${this.token}`, @@ -48,11 +63,11 @@ export class SlackAPI { ...(cursor && { cursor }), }, }); - const response = await request.json(); + const response: ConversationsListResponse = await request.json(); - if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { + if (response && Array.isArray(response.channels) && response.channels.length > 0) { groups = groups.concat(response.channels); - if (response.response_metadata && response.response_metadata.next_cursor) { + if (response.response_metadata?.next_cursor) { const nextGroups = await this.getGroups(response.response_metadata.next_cursor); groups = groups.concat(nextGroups); } @@ -61,7 +76,7 @@ export class SlackAPI { return groups; } - async getRoomInfo(roomId) { + async getRoomInfo(roomId: string): Promise { const request = await fetch(`https://slack.com/api/conversations.info`, { headers: { Authorization: `Bearer ${this.token}`, @@ -71,14 +86,19 @@ export class SlackAPI { include_num_members: true, }, }); - const response = await request.json(); - return response && response && request.status === 200 && request.ok && response.channel; + const response: ConversationsInfoResponse = await request.json(); + return (response && request.status === 200 && request.ok && response.channel) || undefined; } - async getMembers(channelId) { - const { num_members } = this.getRoomInfo(channelId); + async getMembers(channelId: string): Promise { + const roomInfo = await this.getRoomInfo(channelId); + if (!roomInfo?.num_members) { + return []; + } + + const { num_members } = roomInfo; const MAX_MEMBERS_PER_CALL = 100; - let members = []; + let members: ConversationsMembersResponse['members'] = []; let currentCursor = ''; for (let index = 0; index < num_members; index += MAX_MEMBERS_PER_CALL) { // eslint-disable-next-line no-await-in-loop @@ -94,9 +114,9 @@ export class SlackAPI { }); // eslint-disable-next-line no-await-in-loop const response = await request.json(); - if (response && response && request.status === 200 && request.ok && Array.isArray(response.members)) { + if (response && request.status === 200 && request.ok && Array.isArray(response.members)) { members = members.concat(response.members); - const hasMoreItems = response.response_metadata && response.response_metadata.next_cursor; + const hasMoreItems = response.response_metadata?.next_cursor; if (hasMoreItems) { currentCursor = response.response_metadata.next_cursor; } @@ -105,7 +125,7 @@ export class SlackAPI { return members; } - async react(data) { + async react(data: ReactionsAddArguments): Promise { const request = await fetch('https://slack.com/api/reactions.add', { headers: { Authorization: `Bearer ${this.token}`, @@ -114,10 +134,10 @@ export class SlackAPI { params: data, }); const response = await request.json(); - return response && request.status === 200 && response && request.ok; + return response && request.status === 200 && request.ok; } - async removeReaction(data) { + async removeReaction(data: ReactionsRemoveArguments): Promise { const request = await fetch('https://slack.com/api/reactions.remove', { headers: { Authorization: `Bearer ${this.token}`, @@ -126,10 +146,10 @@ export class SlackAPI { params: data, }); const response = await request.json(); - return response && request.status === 200 && response && request.ok; + return response && request.status === 200 && request.ok; } - async removeMessage(data) { + async removeMessage(data: ChatDeleteArguments): Promise { const request = await fetch('https://slack.com/api/chat.delete', { headers: { Authorization: `Bearer ${this.token}`, @@ -138,10 +158,10 @@ export class SlackAPI { params: data, }); const response = await request.json(); - return response && request.status === 200 && response && request.ok; + return response && request.status === 200 && request.ok; } - async sendMessage(data) { + async sendMessage(data: ChatPostMessageArguments): Promise { const request = await fetch('https://slack.com/api/chat.postMessage', { headers: { Authorization: `Bearer ${this.token}`, @@ -152,7 +172,7 @@ export class SlackAPI { return request.json(); } - async updateMessage(data) { + async updateMessage(data: ChatUpdateArguments): Promise { const request = await fetch('https://slack.com/api/chat.update', { headers: { Authorization: `Bearer ${this.token}`, @@ -161,10 +181,10 @@ export class SlackAPI { params: data, }); const response = await request.json(); - return response && request.status === 200 && response && request.ok; + return response && request.status === 200 && request.ok; } - async getHistory(options) { + async getHistory(options: ConversationsHistoryArguments): Promise { const request = await fetch(`https://slack.com/api/conversations.history`, { headers: { Authorization: `Bearer ${this.token}`, @@ -175,7 +195,7 @@ export class SlackAPI { return response; } - async getPins(channelId) { + async getPins(channelId: string): Promise { const request = await fetch('https://slack.com/api/pins.list', { headers: { Authorization: `Bearer ${this.token}`, @@ -185,10 +205,10 @@ export class SlackAPI { }, }); const response = await request.json(); - return response && response && request.status === 200 && request.ok && response.items; + return (response && request.status === 200 && request.ok && response.items) || undefined; } - async getUser(userId) { + async getUser(userId: string): Promise { const request = await fetch('https://slack.com/api/users.info', { headers: { Authorization: `Bearer ${this.token}`, @@ -198,10 +218,25 @@ export class SlackAPI { }, }); const response = await request.json(); - return response && response && request.status === 200 && request.ok && response.user; + return (response && request.status === 200 && request.ok && response.user) || undefined; + } + + async getFile(fileUrl: string): Promise | undefined> { + const request = await fetch(fileUrl, { + headers: { + Authorization: `Bearer ${this.token}`, + }, + }); + + // #ToDo: Confirm this works the same way as the old https.get code + const fileBuffer = await request.buffer(); + if (request.status !== 200 || !request.ok) { + return undefined; + } + return fileBuffer; } - static async verifyToken(token) { + static async verifyToken(token: string): Promise { const request = await fetch('https://slack.com/api/auth.test', { headers: { Authorization: `Bearer ${token}`, @@ -209,10 +244,10 @@ export class SlackAPI { method: 'POST', }); const response = await request.json(); - return response && response && request.status === 200 && request.ok && response.ok; + return response && request.status === 200 && request.ok && response.ok; } - static async verifyAppCredentials({ botToken, appToken }) { + static async verifyAppCredentials({ botToken, appToken }: { botToken: string; appToken: string }): Promise { const request = await fetch('https://slack.com/api/apps.connections.open', { headers: { Authorization: `Bearer ${appToken}`, @@ -220,7 +255,7 @@ export class SlackAPI { method: 'POST', }); const response = await request.json(); - const isAppTokenOk = response && response && request.status === 200 && request.ok && response.ok; + const isAppTokenOk = response && request.status === 200 && request.ok && response.ok; const isBotTokenOk = await this.verifyToken(botToken); return isAppTokenOk && isBotTokenOk; } diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.ts b/apps/meteor/app/slackbridge/server/SlackAdapter.ts index 2bc5d56da88b3..62c9176a69a7f 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -1,20 +1,44 @@ -// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS -// TODO: Remove the following lint/ts instructions when the file gets properly converted -/* eslint-disable @typescript-eslint/no-empty-function */ -/* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ -// @ts-nocheck -import http from 'http'; -import https from 'https'; -import url from 'url'; - import { Message } from '@rocket.chat/core-services'; +import type { FileAttachmentProps, IMessage, IRegisterUser, IRoom, IUpload, FileProp } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, ReadReceipts } from '@rocket.chat/models'; -import { App as SlackApp } from '@slack/bolt'; -import { RTMClient } from '@slack/rtm-api'; +import type { + BotMessageEvent, + ChannelJoinMessageEvent, + ChannelLeaveMessageEvent, + ChannelNameMessageEvent, + ChannelPurposeMessageEvent, + ChannelTopicMessageEvent, + FileShareMessageEvent, + GenericMessageEvent, + MeMessageEvent, + MessageChangedEvent, + MessageDeletedEvent, + ChannelLeftEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + MemberJoinedChannelEvent, +} from '@slack/types'; +import type { ChatUpdateArguments, ConversationsHistoryArguments, ConversationsListResponse } from '@slack/web-api'; import { Meteor } from 'meteor/meteor'; -import { SlackAPI } from './SlackAPI'; +import type { IMessageSyncedWithSlack, SlackTS } from './definition/IMessageSyncedWithSlack'; +import { isMessageImportedFromSlack } from './definition/IMessageSyncedWithSlack'; +import type { IRocketChatAdapter } from './definition/IRocketChatAdapter'; +import type { ISlackAPI, SlackPostMessage } from './definition/ISlackAPI'; +import type { ISlackAdapter, SlackAppCredentials, SlackChannel } from './definition/ISlackAdapter'; +import type { ISlackbridge } from './definition/ISlackbridge'; +import type { RocketChatMessageData } from './definition/RocketChatMessageData'; +import type { + FileCommentMessageEvent, + FileMentionMessageEvent, + GroupJoinMessageEvent, + GroupLeaveMessageEvent, + GroupNameMessageEvent, + GroupPurposeMessageEvent, + GroupTopicMessageEvent, + PinnedItemMessageEvent, + SlackMessageEvent, +} from './definition/SlackMessageEvent'; import { slackLogger } from './logger'; import { saveRoomName, saveRoomTopic } from '../../channel-settings/server'; import { FileUpload } from '../../file-upload/server'; @@ -29,26 +53,34 @@ import { executeSetReaction } from '../../reactions/server/setReaction'; import { settings } from '../../settings/server'; import { getUserAvatarURL } from '../../utils/server/getUserAvatarURL'; -export default class SlackAdapter { - constructor(slackBridge) { +export default abstract class SlackAdapter implements ISlackAdapter { + protected _slackAPI: ISlackAPI | null = null; + + protected messagesBeingSent: SlackPostMessage[]; + + private slackChannelRocketBotMembershipMap = new Map(); + + private slackBotId: string | false; + + public get slackAPI(): ISlackAPI { + return this._slackAPI as ISlackAPI; + } + + constructor( + protected slackBridge: ISlackbridge, + protected rocket: IRocketChatAdapter, + ) { slackLogger.debug({ msg: 'constructor' }); - this.slackBridge = slackBridge; - this.rtm = {}; // slack-client Real Time Messaging API - this.apiToken = {}; // Slack API Token passed in via Connect - this.slackApp = {}; - this.appCredential = {}; + // On Slack, a rocket integration bot will be added to slack channels, this is the list of those channels, key is Rocket Ch ID this.slackChannelRocketBotMembershipMap = new Map(); // Key=RocketChannelID, Value=SlackChannel - this.rocket = {}; this.messagesBeingSent = []; this.slackBotId = false; - - this.slackAPI = {}; } - async connect({ apiToken, appCredential }) { + async connect({ apiToken, appCredential }: { apiToken?: string; appCredential?: SlackAppCredentials }) { try { - const connectResult = await (appCredential ? this.connectApp(appCredential) : this.connectLegacy(apiToken)); + const connectResult = await (appCredential ? this.connectApp(appCredential) : this.connectLegacy(apiToken as string)); if (connectResult) { slackLogger.info({ msg: 'Connected to Slack' }); @@ -56,465 +88,38 @@ export default class SlackAdapter { Meteor.startup(async () => { try { await this.populateMembershipChannelMap(); // If run outside of Meteor.startup, HTTP is not defined - } catch (err) { + } catch (err: any) { slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); if (err.data.error === 'invalid_auth') { slackLogger.error('The provided token is invalid'); } - this.slackBridge.disconnect(); + // Using "void" because the JS code didn't have anything + void this.slackBridge.disconnect(); } }); } } catch (err) { slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); - this.slackBridge.disconnect(); - } - } - - /** - * Connect to the remote Slack server using the passed in app credential and register for Slack events. - * @typedef {Object} AppCredential - * @property {string} botToken - * @property {string} appToken - * @property {string} signingSecret - * @param {AppCredential} appCredential - */ - async connectApp(appCredential) { - this.appCredential = appCredential; - - // Invalid app credentials causes unhandled errors - if (!(await SlackAPI.verifyAppCredentials(appCredential))) { - throw new Error('Invalid app credentials (botToken or appToken) for the slack app'); + // Using "void" because the JS code didn't have anything + void this.slackBridge.disconnect(); } - this.slackAPI = new SlackAPI(this.appCredential.botToken); - - this.slackApp = new SlackApp({ - appToken: this.appCredential.appToken, - signingSecret: this.appCredential.signingSecret, - token: this.appCredential.botToken, - socketMode: true, - }); - - this.registerForEvents(); - - const connectionResult = await this.slackApp.start(); - - return connectionResult; } - /** - * Connect to the remote Slack server using the passed in token API and register for Slack events. - * @param apiToken - * @deprecated - */ - async connectLegacy(apiToken) { - this.apiToken = apiToken; - - // Invalid apiToken causes unhandled errors - if (!(await SlackAPI.verifyToken(apiToken))) { - throw new Error('Invalid ApiToken for the slack legacy bot integration'); - } - - if (RTMClient != null) { - RTMClient.disconnect; - } - this.slackAPI = new SlackAPI(this.apiToken); - this.rtm = new RTMClient(this.apiToken); - - this.registerForEventsLegacy(); - - const connectionResult = await this.rtm.start(); + abstract connectApp(appCredential: SlackAppCredentials): Promise; - return connectionResult; - } + abstract connectLegacy(apiToken: string): Promise; /** * Unregister for slack events and disconnect from Slack */ - async disconnect() { - if (this.rtm.connected && this.rtm.disconnect) { - await this.rtm.disconnect(); - } else if (this.slackApp.stop) { - await this.slackApp.stop(); - } - } - - setRocket(rocket) { - this.rocket = rocket; - } - - registerForEvents() { - /** - * message: { - * "client_msg_id": "caab144d-41e7-47cc-87fa-af5d50c02784", - * "type": "message", - * "text": "heyyyyy", - * "user": "U060WD4QW81", - * "ts": "1697054782.214569", - * "blocks": [], - * "team": "T060383CUDV", - * "channel": "C060HSLQPCN", - * "event_ts": "1697054782.214569", - * "channel_type": "channel" - * } - */ - this.slackApp.message(async ({ message }) => { - slackLogger.debug({ msg: 'OnSlackEvent-MESSAGE', message }); - if (message) { - try { - await this.onMessage(message); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onMessage', err }); - } - } - }); - - /** - * Event fired when a message is reacted in a channel or group app is added in - * event: { - * "type": "reaction_added", - * "user": "U060WD4QW81", - * "reaction": "telephone_receiver", - * "item": { - * "type": "message", - * "channel": "C06196XMUMN", - * "ts": "1697037020.309679" - * }, - * "item_user": "U060WD4QW81", - * "event_ts": "1697037219.001600" - * } - */ - this.slackApp.event('reaction_added', async ({ event }) => { - slackLogger.debug({ msg: 'OnSlackEvent-REACTION_ADDED', event }); - try { - await this.onReactionAdded(event); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); - } - }); - - /** - * Event fired when a reaction is removed from a message in a channel or group app is added in. - * event: { - * "type": "reaction_removed", - * "user": "U060WD4QW81", - * "reaction": "raised_hands", - * "item": { - * "type": "message", - * "channel": "C06196XMUMN", - * "ts": "1697028997.057629" - * }, - * "item_user": "U060WD4QW81", - * "event_ts": "1697029220.000600" - * } - */ - this.slackApp.event('reaction_removed', async ({ event }) => { - slackLogger.debug({ msg: 'OnSlackEvent-REACTION_REMOVED', event }); - try { - await this.onReactionRemoved(event); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onReactionRemoved', err }); - } - }); - - /** - * Event fired when a members joins a channel - * event: { - * "type": "member_joined_channel", - * "user": "U06039U8WK1", - * "channel": "C060HT033E2", - * "channel_type": "C", - * "team": "T060383CUDV", - * "inviter": "U060WD4QW81", - * "event_ts": "1697042377.000800" - * } - */ - this.slackApp.event('member_joined_channel', async ({ event, context }) => { - slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', event }); - try { - await this.processMemberJoinChannel(event, context); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); - } - }); - - this.slackApp.event('channel_left', async ({ event }) => { - slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', event }); - try { - this.onChannelLeft(event); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); - } - }); - - this.slackApp.error((error) => { - slackLogger.error({ msg: 'Error on SlackApp', error }); - }); - } - - /** - * @deprecated - */ - registerForEventsLegacy() { - slackLogger.debug({ msg: 'Register for events' }); - this.rtm.on('authenticated', () => { - slackLogger.info('Connected to Slack'); - }); - - this.rtm.on('unable_to_rtm_start', () => { - this.slackBridge.disconnect(); - }); - - this.rtm.on('disconnected', () => { - slackLogger.info('Disconnected from Slack'); - this.slackBridge.disconnect(); - }); + abstract disconnect(): Promise; - /** - * Event fired when someone messages a channel the bot is in - * { - * type: 'message', - * channel: [channel_id], - * user: [user_id], - * text: [message], - * ts: [ts.milli], - * team: [team_id], - * subtype: [message_subtype], - * inviter: [message_subtype = 'group_join|channel_join' -> user_id] - * } - **/ - this.rtm.on('message', async (slackMessage) => { - slackLogger.debug({ msg: 'OnSlackEvent-MESSAGE', slackMessage }); - if (slackMessage) { - try { - await this.onMessage(slackMessage); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onMessage', err }); - } - } - }); - - this.rtm.on('reaction_added', async (reactionMsg) => { - slackLogger.debug({ msg: 'OnSlackEvent-REACTION_ADDED', reactionMsg }); - if (reactionMsg) { - try { - await this.onReactionAdded(reactionMsg); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); - } - } - }); - - this.rtm.on('reaction_removed', async (reactionMsg) => { - slackLogger.debug({ msg: 'OnSlackEvent-REACTION_REMOVED', reactionMsg }); - if (reactionMsg) { - try { - await this.onReactionRemoved(reactionMsg); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onReactionRemoved', err }); - } - } - }); - - /** - * Event fired when someone creates a public channel - * { - * type: 'channel_created', - * channel: { - * id: [channel_id], - * is_channel: true, - * name: [channel_name], - * created: [ts], - * creator: [user_id], - * is_shared: false, - * is_org_shared: false - * }, - * event_ts: [ts.milli] - * } - **/ - this.rtm.on('channel_created', () => {}); - - /** - * Event fired when the bot joins a public channel - * { - * type: 'channel_joined', - * channel: { - * id: [channel_id], - * name: [channel_name], - * is_channel: true, - * created: [ts], - * creator: [user_id], - * is_archived: false, - * is_general: false, - * is_member: true, - * last_read: [ts.milli], - * latest: [message_obj], - * unread_count: 0, - * unread_count_display: 0, - * members: [ user_ids ], - * topic: { - * value: [channel_topic], - * creator: [user_id], - * last_set: 0 - * }, - * purpose: { - * value: [channel_purpose], - * creator: [user_id], - * last_set: 0 - * } - * } - * } - **/ - this.rtm.on('channel_joined', () => {}); - - /** - * Event fired when the bot leaves (or is removed from) a public channel - * { - * type: 'channel_left', - * channel: [channel_id] - * } - **/ - this.rtm.on('channel_left', (channelLeftMsg) => { - slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', channelLeftMsg }); - if (channelLeftMsg) { - try { - this.onChannelLeft(channelLeftMsg); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); - } - } - }); - - /** - * Event fired when an archived channel is deleted by an admin - * { - * type: 'channel_deleted', - * channel: [channel_id], - * event_ts: [ts.milli] - * } - **/ - this.rtm.on('channel_deleted', () => {}); - - /** - * Event fired when the channel has its name changed - * { - * type: 'channel_rename', - * channel: { - * id: [channel_id], - * name: [channel_name], - * is_channel: true, - * created: [ts] - * }, - * event_ts: [ts.milli] - * } - **/ - this.rtm.on('channel_rename', () => {}); - - /** - * Event fired when the bot joins a private channel - * { - * type: 'group_joined', - * channel: { - * id: [channel_id], - * name: [channel_name], - * is_group: true, - * created: [ts], - * creator: [user_id], - * is_archived: false, - * is_mpim: false, - * is_open: true, - * last_read: [ts.milli], - * latest: [message_obj], - * unread_count: 0, - * unread_count_display: 0, - * members: [ user_ids ], - * topic: { - * value: [channel_topic], - * creator: [user_id], - * last_set: 0 - * }, - * purpose: { - * value: [channel_purpose], - * creator: [user_id], - * last_set: 0 - * } - * } - * } - **/ - this.rtm.on('group_joined', () => {}); - - /** - * Event fired when the bot leaves (or is removed from) a private channel - * { - * type: 'group_left', - * channel: [channel_id] - * } - **/ - this.rtm.on('group_left', () => {}); - - /** - * Event fired when the private channel has its name changed - * { - * type: 'group_rename', - * channel: { - * id: [channel_id], - * name: [channel_name], - * is_group: true, - * created: [ts] - * }, - * event_ts: [ts.milli] - * } - **/ - this.rtm.on('group_rename', () => {}); - - /** - * Event fired when a new user joins the team - * { - * type: 'team_join', - * user: - * { - * id: [user_id], - * team_id: [team_id], - * name: [user_name], - * deleted: false, - * status: null, - * color: [color_code], - * real_name: '', - * tz: [timezone], - * tz_label: [timezone_label], - * tz_offset: [timezone_offset], - * profile: - * { - * avatar_hash: '', - * real_name: '', - * real_name_normalized: '', - * email: '', - * image_24: '', - * image_32: '', - * image_48: '', - * image_72: '', - * image_192: '', - * image_512: '', - * fields: null - * }, - * is_admin: false, - * is_owner: false, - * is_primary_owner: false, - * is_restricted: false, - * is_ultra_restricted: false, - * is_bot: false, - * presence: [user_presence] - * }, - * cache_ts: [ts] - * } - **/ - this.rtm.on('team_join', () => {}); - } + abstract registerForEvents(): void; /* https://api.slack.com/events/reaction_removed */ - async onReactionRemoved(slackReactionMsg) { + async onReactionRemoved(slackReactionMsg: ReactionRemovedEvent) { if (slackReactionMsg) { if (!this.slackBridge.isReactionsEnabled) { return; @@ -531,14 +136,14 @@ export default class SlackAdapter { if (rocketMsg && rocketUser) { const rocketReaction = `:${slackReactionMsg.reaction}:`; - const theReaction = (rocketMsg.reactions || {})[rocketReaction]; + const theReaction = rocketMsg.reactions?.[rocketReaction]; // If the Rocket user has already been removed, then this is an echo back from slack if (rocketMsg.reactions && theReaction) { if (rocketUser.roles.includes('bot')) { return; } - if (theReaction.usernames.indexOf(rocketUser.username) === -1) { + if (rocketUser.username && !theReaction.usernames.includes(rocketUser.username)) { return; // Reaction already removed } } else { @@ -557,14 +162,14 @@ export default class SlackAdapter { /* https://api.slack.com/events/reaction_added */ - async onReactionAdded(slackReactionMsg) { + async onReactionAdded(slackReactionMsg: ReactionAddedEvent) { if (slackReactionMsg) { if (!this.slackBridge.isReactionsEnabled) { return; } const rocketUser = await this.rocket.getUser(slackReactionMsg.user); - if (rocketUser.roles.includes('bot')) { + if (rocketUser?.roles.includes('bot')) { return; } @@ -583,10 +188,8 @@ export default class SlackAdapter { // If the Rocket user has already reacted, then this is Slack echoing back to us if (rocketMsg.reactions) { const theReaction = rocketMsg.reactions[rocketReaction]; - if (theReaction) { - if (theReaction.usernames.indexOf(rocketUser.username) !== -1) { - return; // Already reacted - } + if (rocketUser.username && theReaction?.usernames.includes(rocketUser.username)) { + return; // Already reacted } } @@ -598,7 +201,7 @@ export default class SlackAdapter { } } - onChannelLeft(channelLeftMsg) { + onChannelLeft(channelLeftMsg: ChannelLeftEvent) { this.removeSlackChannel(channelLeftMsg.channel); } @@ -606,8 +209,8 @@ export default class SlackAdapter { * We have received a message from slack and we need to save/delete/update it into rocket * https://api.slack.com/events/message */ - async onMessage(slackMessage, isImporting) { - const isAFileShare = slackMessage && slackMessage.files && Array.isArray(slackMessage.files) && slackMessage.files.length; + async onMessage(slackMessage: SlackMessageEvent, isImporting?: boolean) { + const isAFileShare = 'files' in slackMessage && slackMessage?.files && Array.isArray(slackMessage.files) && slackMessage.files.length; if (isAFileShare) { await this.processFileShare(slackMessage); return; @@ -633,7 +236,7 @@ export default class SlackAdapter { } } - async postFindChannel(rocketChannelName) { + async postFindChannel(rocketChannelName: string) { slackLogger.debug({ msg: 'Searching for Slack channel or group', rocketChannelName }); const channels = await this.slackAPI.getChannels(); if (channels && channels.length > 0) { @@ -659,22 +262,18 @@ export default class SlackAdapter { * @returns Slack TS or undefined if not a message that originated from slack * @private */ - getTimeStamp(rocketMsg) { + getTimeStamp(rocketMsg: IMessage): SlackTS | undefined { // slack-G3KJGGE15-1483081061-000169 - let slackTS; - let index = rocketMsg._id.indexOf('slack-'); - if (index === 0) { + if (isMessageImportedFromSlack(rocketMsg)) { // This is a msg that originated from Slack - slackTS = rocketMsg._id.substr(6, rocketMsg._id.length); - index = slackTS.indexOf('-'); + let slackTS = rocketMsg._id.substr(6, rocketMsg._id.length); + const index = slackTS.indexOf('-'); slackTS = slackTS.substr(index + 1, slackTS.length); - slackTS = slackTS.replace('-', '.'); - } else { - // This probably originated as a Rocket msg, but has been sent to Slack - slackTS = rocketMsg.slackTs; + return slackTS.replace('-', '.'); } - return slackTS; + // This probably originated as a Rocket msg, but has been sent to Slack + return (rocketMsg as IMessageSyncedWithSlack).slackTs; } /** @@ -682,7 +281,7 @@ export default class SlackAdapter { * @param rocketChID * @param slackChID */ - addSlackChannel(rocketChID, slackChID) { + addSlackChannel(rocketChID: string, slackChID: SlackChannel['id']) { const ch = this.getSlackChannel(rocketChID); if (ch == null) { slackLogger.debug({ msg: 'Added channel', rocketChID, slackChID }); @@ -693,13 +292,12 @@ export default class SlackAdapter { } } - removeSlackChannel(slackChID) { + removeSlackChannel(slackChID: SlackChannel['id']) { const keys = this.slackChannelRocketBotMembershipMap.keys(); - let slackChannel; let key; while ((key = keys.next().value) != null) { - slackChannel = this.slackChannelRocketBotMembershipMap.get(key); - if (slackChannel.id === slackChID) { + const slackChannel = this.slackChannelRocketBotMembershipMap.get(key); + if (slackChannel?.id === slackChID) { // Found it, need to delete it this.slackChannelRocketBotMembershipMap.delete(key); break; @@ -707,52 +305,41 @@ export default class SlackAdapter { } } - getSlackChannel(rocketChID) { + getSlackChannel(rocketChID: string) { return this.slackChannelRocketBotMembershipMap.get(rocketChID); } - async populateMembershipChannelMapByChannels() { - const channels = await this.slackAPI.getChannels(); - if (!channels || channels.length <= 0) { + private async populateMembershipChannelMapByChannels(channels?: Required['channels']) { + if (!channels?.length) { return; } for await (const slackChannel of channels) { - const rocketchat_room = - (await Rooms.findOneByName(slackChannel.name, { projection: { _id: 1 } })) || - (await Rooms.findOneByImportId(slackChannel.id, { projection: { _id: 1 } })); - if (rocketchat_room && slackChannel.is_member) { - this.addSlackChannel(rocketchat_room._id, slackChannel.id); + if (!(slackChannel.name || slackChannel.id) || !slackChannel.is_member) { + continue; } - } - } - async populateMembershipChannelMapByGroups() { - const groups = await this.slackAPI.getGroups(); - if (!groups || groups.length <= 0) { - return; - } + const rcRoom = + (slackChannel.name && (await Rooms.findOneByName(slackChannel.name, { projection: { _id: 1 } }))) || + (slackChannel.id && (await Rooms.findOneByImportId(slackChannel.id, { projection: { _id: 1 } }))) || + undefined; - for await (const slackGroup of groups) { - const rocketchat_room = - (await Rooms.findOneByName(slackGroup.name, { projection: { _id: 1 } })) || - (await Rooms.findOneByImportId(slackGroup.id, { projection: { _id: 1 } })); - if (rocketchat_room && slackGroup.is_member) { - this.addSlackChannel(rocketchat_room._id, slackGroup.id); + if (rcRoom) { + this.addSlackChannel(rcRoom._id, (slackChannel.id || slackChannel.name) as string); } } } async populateMembershipChannelMap() { slackLogger.debug('Populating channel map'); - await this.populateMembershipChannelMapByChannels(); - await this.populateMembershipChannelMapByGroups(); + await this.populateMembershipChannelMapByChannels(await this.slackAPI.getChannels()); + await this.populateMembershipChannelMapByChannels(await this.slackAPI.getGroups()); } /* https://api.slack.com/methods/reactions.add */ - async postReactionAdded(reaction, slackChannel, slackTS) { + async postReactionAdded(reaction: string, slackChannel: string, slackTS: SlackTS) { if (reaction && slackChannel && slackTS) { const data = { name: reaction, @@ -771,7 +358,7 @@ export default class SlackAdapter { /* https://api.slack.com/methods/reactions.remove */ - async postReactionRemove(reaction, slackChannel, slackTS) { + async postReactionRemove(reaction: string, slackChannel: string, slackTS: SlackTS) { if (reaction && slackChannel && slackTS) { const data = { name: reaction, @@ -787,38 +374,47 @@ export default class SlackAdapter { } } - async postDeleteMessage(rocketMessage) { - if (rocketMessage) { - const slackChannel = this.getSlackChannel(rocketMessage.rid); + async postDeleteMessage(rocketMessage: IMessage) { + if (!rocketMessage) { + return; + } - if (slackChannel != null) { - const data = { - ts: this.getTimeStamp(rocketMessage), - channel: this.getSlackChannel(rocketMessage.rid).id, - as_user: true, - }; + const slackChannel = this.getSlackChannel(rocketMessage.rid); + if (!slackChannel) { + return; + } - slackLogger.debug('Post Delete Message to Slack', data); - const postResult = await this.slackAPI.removeMessage(data); - if (postResult) { - slackLogger.debug('Message deleted on Slack'); - } - } + const ts = this.getTimeStamp(rocketMessage); + if (!ts) { + slackLogger.debug('Not posting message deletion to slack because the message doesnt have a slack timestamp'); + return; + } + + const data = { + ts, + channel: slackChannel.id, + as_user: true, + }; + + slackLogger.debug({ msg: 'Post Delete Message to Slack', data }); + const postResult = await this.slackAPI.removeMessage(data); + if (postResult) { + slackLogger.debug('Message deleted on Slack'); } } - storeMessageBeingSent(data) { + storeMessageBeingSent(data: SlackPostMessage) { this.messagesBeingSent.push(data); } - removeMessageBeingSent(data) { + removeMessageBeingSent(data: SlackPostMessage) { const idx = this.messagesBeingSent.indexOf(data); if (idx >= 0) { this.messagesBeingSent.splice(idx, 1); } } - isMessageBeingSent(username, channel) { + isMessageBeingSent(username: string, channel: string) { if (!this.messagesBeingSent.length) { return false; } @@ -836,88 +432,97 @@ export default class SlackAdapter { }); } - createSlackMessageId(ts, channelId) { + createSlackMessageId(ts: string, channelId?: string): string { return `slack${channelId ? `-${channelId}` : ''}-${ts.replace(/\./g, '-')}`; } - async postMessage(slackChannel, rocketMessage) { - if (slackChannel && slackChannel.id) { - let iconUrl = getUserAvatarURL(rocketMessage.u && rocketMessage.u.username); - if (iconUrl) { - iconUrl = Meteor.absoluteUrl().replace(/\/$/, '') + iconUrl; - } - const data = { - text: rocketMessage.msg, - channel: slackChannel.id, - username: rocketMessage.u && rocketMessage.u.username, - icon_url: iconUrl, - link_names: 1, - }; + async postMessage(slackChannel: SlackChannel | undefined, rocketMessage: IMessage): Promise { + if (!slackChannel?.id) { + return; + } - if (rocketMessage.tmid) { - const tmessage = await Messages.findOneById(rocketMessage.tmid); - if (tmessage && tmessage.slackTs) { - data.thread_ts = tmessage.slackTs; - } - } - slackLogger.debug({ msg: 'Post Message To Slack', data }); + let iconUrl = getUserAvatarURL(rocketMessage.u?.username); + if (iconUrl) { + iconUrl = Meteor.absoluteUrl().replace(/\/$/, '') + iconUrl; + } + const data: SlackPostMessage = { + text: rocketMessage.msg, + channel: slackChannel.id, + username: rocketMessage.u?.username, + icon_url: iconUrl, + link_names: true, + }; - // If we don't have the bot id yet and we have multiple slack bridges, we need to keep track of the messages that are being sent - if (!this.slackBotId && this.rocket.slackAdapters && this.rocket.slackAdapters.length >= 2) { - this.storeMessageBeingSent(data); + if (rocketMessage.tmid) { + const tmessage = await Messages.findOneById(rocketMessage.tmid); + if (tmessage?.slackTs) { + data.thread_ts = tmessage.slackTs; } + } + slackLogger.debug({ msg: 'Post Message To Slack', data }); + + // If we don't have the bot id yet and we have multiple slack bridges, we need to keep track of the messages that are being sent + if (!this.slackBotId && this.rocket.slackAdapters?.length >= 2) { + this.storeMessageBeingSent(data); + } - const postResult = await this.slackAPI.sendMessage(data); + const postResult = await this.slackAPI.sendMessage(data); - if (!this.slackBotId && this.rocket.slackAdapters && this.rocket.slackAdapters.length >= 2) { - this.removeMessageBeingSent(data); - } + if (!this.slackBotId && this.rocket.slackAdapters?.length >= 2) { + this.removeMessageBeingSent(data); + } - if (postResult && postResult.message && postResult.message.bot_id && postResult.message.ts) { - this.slackBotId = postResult.message.bot_id; - await Messages.setSlackBotIdAndSlackTs(rocketMessage._id, postResult.message.bot_id, postResult.message.ts); - slackLogger.debug({ - msg: 'Message posted to Slack', - rocketMessageId: rocketMessage._id, - slackMessageId: postResult.message.ts, - slackBotId: postResult.message.bot_id, - }); - } + if (!postResult?.ok || !postResult.message) { + slackLogger.debug({ msg: 'Failed to send message to Slack', postResult }); + return; + } + + if ('bot_id' in postResult.message && postResult.message.bot_id && postResult.message.ts) { + this.slackBotId = postResult.message.bot_id; + await Messages.setSlackBotIdAndSlackTs(rocketMessage._id, postResult.message.bot_id, postResult.message.ts); + slackLogger.debug({ + msg: 'Message posted to Slack', + rocketMessageId: rocketMessage._id, + slackMessageId: postResult.message.ts, + slackBotId: postResult.message.bot_id, + }); } } /* https://api.slack.com/methods/chat.update */ - async postMessageUpdate(slackChannel, rocketMessage) { - if (slackChannel && slackChannel.id) { - const data = { - ts: this.getTimeStamp(rocketMessage), - channel: slackChannel.id, - text: rocketMessage.msg, - as_user: true, - }; - slackLogger.debug({ msg: 'Post UpdateMessage To Slack', data }); - const postResult = await this.slackAPI.updateMessage(data); - if (postResult) { - slackLogger.debug('Message updated on Slack'); - } + async postMessageUpdate(slackChannel: SlackChannel | undefined, rocketMessage: IMessage): Promise { + if (!slackChannel?.id) { + return; + } + + const data: ChatUpdateArguments = { + ts: this.getTimeStamp(rocketMessage) as SlackTS, + channel: slackChannel.id, + text: rocketMessage.msg, + as_user: true, + }; + slackLogger.debug({ msg: 'Post UpdateMessage To Slack', data }); + const postResult = await this.slackAPI.updateMessage(data); + if (postResult) { + slackLogger.debug('Message updated on Slack'); } } - async processMemberJoinChannel(event, context) { + async processMemberJoinChannel(event: MemberJoinedChannelEvent, context: Record) { slackLogger.debug({ msg: 'Member join channel', channel: event.channel }); const rocketCh = await this.rocket.getChannel({ channel: event.channel }); if (rocketCh != null) { this.addSlackChannel(rocketCh._id, event.channel); if (context?.botUserId !== event?.user) { const rocketChatUser = await this.rocket.getUser(event.user); - await addUserToRoom(rocketCh._id, rocketChatUser); + rocketChatUser && (await addUserToRoom(rocketCh._id, rocketChatUser)); } } } - async processChannelJoin(slackMessage) { + async processChannelJoin(slackMessage: ChannelJoinMessageEvent) { slackLogger.debug({ msg: 'Channel join', channelId: slackMessage.channel.id }); const rocketCh = await this.rocket.addChannel(slackMessage.channel); if (rocketCh != null) { @@ -925,51 +530,66 @@ export default class SlackAdapter { } } - async processFileShare(slackMessage) { + async processFileShare(slackMessage: GenericMessageEvent | FileShareMessageEvent) { if (!settings.get('SlackBridge_FileUpload_Enabled')) { return; } - const file = slackMessage.files[0]; - - if (file && file.url_private_download !== undefined) { - const rocketChannel = await this.rocket.getChannel(slackMessage); - const rocketUser = await this.rocket.getUser(slackMessage.user); + const file = slackMessage.files?.[0]; + if (file?.url_private_download === undefined) { + return; + } - // Hack to notify that a file was attempted to be uploaded - delete slackMessage.subtype; + const rocketChannel = await this.rocket.getChannel(slackMessage); + if (!rocketChannel) { + slackLogger.debug('Unable to processFileShare: RC channel not found.'); + return; + } - // If the text includes the file link, simply use the same text for the rocket message. - // If the link was not included, then use it instead of the message. + const rocketUser = await this.rocket.getUser(slackMessage.user); + if (!rocketUser) { + slackLogger.debug('Unable to processFileShare: RC user not found.'); + return; + } - if (slackMessage.text.indexOf(file.permalink) < 0) { - slackMessage.text = file.permalink; - } + // Hack to notify that a file was attempted to be uploaded + delete slackMessage.subtype; - const ts = new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000); - const msgDataDefaults = { - _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.ts), - ts, - updatedBySlack: true, - }; + // If the text includes the file link, simply use the same text for the rocket message. + // If the link was not included, then use it instead of the message. - await this.rocket.createAndSaveMessage(rocketChannel, rocketUser, slackMessage, msgDataDefaults, false); + if (!slackMessage.text?.includes(file.permalink)) { + slackMessage.text = file.permalink; } + + const ts = new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000); + const msgDataDefaults = { + _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.ts), + ts, + updatedBySlack: true, + }; + + await this.rocket.createAndSaveMessage( + rocketChannel, + rocketUser, + slackMessage, + msgDataDefaults, + false, + this as unknown as ISlackAdapter, + ); } /* https://api.slack.com/events/message/message_deleted */ - async processMessageDeleted(slackMessage) { + async processMessageDeleted(slackMessage: MessageDeletedEvent) { if (slackMessage.previous_message) { const rocketChannel = await this.rocket.getChannel(slackMessage); const rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); if (rocketChannel && rocketUser) { + const botId = 'bot_id' in slackMessage.previous_message && slackMessage.previous_message.bot_id; // Find the Rocket message to delete - let rocketMsgObj = await Messages.findOneBySlackBotIdAndSlackTs( - slackMessage.previous_message.bot_id, - slackMessage.previous_message.ts, - ); + let rocketMsgObj = botId && (await Messages.findOneBySlackBotIdAndSlackTs(botId, slackMessage.previous_message.ts)); if (!rocketMsgObj) { // Must have been a Slack originated msg @@ -988,47 +608,80 @@ export default class SlackAdapter { /* https://api.slack.com/events/message/message_changed */ - async processMessageChanged(slackMessage) { - if (slackMessage.previous_message) { - const currentMsg = await Messages.findOneById(this.rocket.createRocketID(slackMessage.channel, slackMessage.message.ts)); - - // Only process this change, if its an actual update (not just Slack repeating back our Rocket original change) - if (currentMsg && slackMessage.message.text !== currentMsg.msg) { - const rocketChannel = await this.rocket.getChannel(slackMessage); - const rocketUser = slackMessage.previous_message.user - ? (await this.rocket.findUser(slackMessage.previous_message.user)) || - (await this.rocket.addUser(slackMessage.previous_message.user)) - : null; - - const rocketMsgObj = { - // @TODO _id - _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.previous_message.ts), - rid: rocketChannel._id, - msg: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.message.text), - updatedBySlack: true, // We don't want to notify slack about this change since Slack initiated it - }; + async processMessageChanged(slackMessage: MessageChangedEvent) { + if (!slackMessage.previous_message) { + return; + } - await updateMessage(rocketMsgObj, rocketUser); - slackLogger.debug('Rocket message updated by Slack'); - } + const currentMsg = await Messages.findOneById(this.rocket.createRocketID(slackMessage.channel, slackMessage.message.ts)); + if (!currentMsg) { + return; + } + + // Only process this change, if its an actual update (not just Slack repeating back our Rocket original change) + const messageText = ('text' in slackMessage.message && slackMessage.message.text) || ''; + if (messageText === currentMsg.msg) { + return; + } + + const rocketChannel = await this.rocket.getChannel(slackMessage); + if (!rocketChannel) { + slackLogger.debug('Unable to processMessageChanged: RC channel not found.'); + return; + } + + const previousUser = 'user' in slackMessage.previous_message && slackMessage.previous_message.user; + if (!previousUser) { + slackLogger.debug('Unable to processMessageChanged: previous user not found.'); + return; + } + + const rocketUser = await this.rocket.getUser(previousUser); + + if (!rocketUser) { + slackLogger.debug('Unable to processMessageChanged: RC user not found.'); + return; + } + + const rocketMsgObj = { + // @TODO _id + _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.previous_message.ts), + rid: rocketChannel._id, + msg: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(messageText), + updatedBySlack: true, // We don't want to notify slack about this change since Slack initiated it + }; + + await updateMessage(rocketMsgObj, rocketUser); + slackLogger.debug('Rocket message updated by Slack'); + } + + async getUserForMessage( + slackMessage: Exclude, + ): Promise { + if (slackMessage.subtype === 'bot_message') { + return Users.findOneById('rocket.cat'); + } + + const slackUser = 'user' in slackMessage && slackMessage.user; + if (!slackUser) { + return null; } + + return this.rocket.findUser(slackUser); } /* This method will get refactored and broken down into single responsibilities */ - async processNewMessage(slackMessage, isImporting) { - const rocketChannel = await this.rocket.getChannel(slackMessage); - let rocketUser = null; - if (slackMessage.subtype === 'bot_message') { - rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); - } else { - rocketUser = slackMessage.user - ? (await this.rocket.findUser(slackMessage.user)) || (await this.rocket.addUser(slackMessage.user)) - : null; - } + async processNewMessage( + slackMessage: Exclude, + isImporting?: boolean, + ) { + const rocketChannel = 'channel' in slackMessage && (await this.rocket.getChannel(slackMessage)); + const rocketUser = await this.getUserForMessage(slackMessage); + if (rocketChannel && rocketUser) { - const msgDataDefaults = { + const msgDataDefaults: Partial = { _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.ts), ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), }; @@ -1036,8 +689,15 @@ export default class SlackAdapter { msgDataDefaults.imported = 'slackbridge'; } try { - await this.rocket.createAndSaveMessage(rocketChannel, rocketUser, slackMessage, msgDataDefaults, isImporting, this); - } catch (e) { + await this.rocket.createAndSaveMessage( + rocketChannel, + rocketUser, + slackMessage, + msgDataDefaults, + Boolean(isImporting), + this as unknown as ISlackAdapter, + ); + } catch (e: any) { // http://www.mongodb.org/about/contributors/error-codes/ // 11000 == duplicate key error if (e.name === 'MongoError' && e.code === 11000) { @@ -1049,8 +709,8 @@ export default class SlackAdapter { } } - async processBotMessage(rocketChannel, slackMessage) { - const excludeBotNames = settings.get('SlackBridge_ExcludeBotnames'); + async processBotMessage(rocketChannel: IRoom, slackMessage: BotMessageEvent): Promise { + const { excludeBotNames } = this.slackBridge; if (slackMessage.username !== undefined && excludeBotNames && slackMessage.username.match(excludeBotNames)) { return; } @@ -1061,18 +721,18 @@ export default class SlackAdapter { } } else { const slackChannel = this.getSlackChannel(rocketChannel._id); - if (this.isMessageBeingSent(slackMessage.username || slackMessage.bot_id, slackChannel.id)) { + if (slackChannel && this.isMessageBeingSent(slackMessage.username || slackMessage.bot_id, slackChannel.id)) { return; } } - const rocketMsgObj = { + const rocketMsgObj: Partial = { msg: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), rid: rocketChannel._id, bot: true, attachments: slackMessage.attachments, username: slackMessage.username || slackMessage.bot_id, - }; + } as any; this.rocket.addAliasToMsg(slackMessage.username || slackMessage.bot_id, rocketMsgObj); if (slackMessage.icons) { rocketMsgObj.emoji = slackMessage.icons.emoji; @@ -1080,13 +740,18 @@ export default class SlackAdapter { return rocketMsgObj; } - async processMeMessage(rocketUser, slackMessage) { + async processMeMessage(rocketUser: IRegisterUser, slackMessage: MeMessageEvent): Promise { return this.rocket.addAliasToMsg(rocketUser.username, { msg: `_${await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text)}_`, }); } - async processChannelJoinMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processChannelJoinMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: ChannelJoinMessageEvent, + isImporting: boolean, + ): Promise { if (isImporting) { await Message.saveSystemMessage('uj', rocketChannel._id, rocketUser.username, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -1097,23 +762,37 @@ export default class SlackAdapter { } } - async processGroupJoinMessage(rocketChannel, rocketUser, slackMessage, isImporting) { - if (slackMessage.inviter) { - const inviter = slackMessage.inviter - ? (await this.rocket.findUser(slackMessage.inviter)) || (await this.rocket.addUser(slackMessage.inviter)) - : null; - if (isImporting) { - await Message.saveSystemMessage('au', rocketChannel._id, rocketUser.username, inviter, { - ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), - imported: 'slackbridge', - }); - } else { - await addUserToRoom(rocketChannel._id, rocketUser, inviter); - } + async processGroupJoinMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: ChannelJoinMessageEvent | GroupJoinMessageEvent, + isImporting: boolean, + ): Promise { + if (!slackMessage.inviter) { + return; + } + + const inviter = await this.rocket.getUser(slackMessage.inviter); + if (!inviter) { + return; + } + + if (isImporting) { + await Message.saveSystemMessage('au', rocketChannel._id, rocketUser.username, inviter, { + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + imported: 'slackbridge', + }); + } else { + await addUserToRoom(rocketChannel._id, rocketUser, inviter); } } - async processLeaveMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processLeaveMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: ChannelLeaveMessageEvent | GroupLeaveMessageEvent, + isImporting: boolean, + ): Promise { if (isImporting) { await Message.saveSystemMessage('ul', rocketChannel._id, rocketUser.username, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -1124,7 +803,12 @@ export default class SlackAdapter { } } - async processTopicMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processTopicMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: ChannelTopicMessageEvent | GroupTopicMessageEvent, + isImporting: boolean, + ): Promise { if (isImporting) { await Message.saveSystemMessage('room_changed_topic', rocketChannel._id, slackMessage.topic, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -1135,7 +819,12 @@ export default class SlackAdapter { } } - async processPurposeMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processPurposeMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: ChannelPurposeMessageEvent | GroupPurposeMessageEvent, + isImporting: boolean, + ): Promise { if (isImporting) { await Message.saveSystemMessage('room_changed_topic', rocketChannel._id, slackMessage.purpose, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -1146,7 +835,12 @@ export default class SlackAdapter { } } - async processNameMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processNameMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: ChannelNameMessageEvent | GroupNameMessageEvent, + isImporting: boolean, + ): Promise { if (isImporting) { await Message.saveSystemMessage('r', rocketChannel._id, slackMessage.name, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -1157,61 +851,92 @@ export default class SlackAdapter { } } - async processShareMessage(rocketChannel, rocketUser, slackMessage, isImporting) { - if (slackMessage.file && slackMessage.file.url_private_download !== undefined) { - const details = { - message_id: this.createSlackMessageId(slackMessage.ts), - name: slackMessage.file.name, - size: slackMessage.file.size, - type: slackMessage.file.mimetype, - rid: rocketChannel._id, - }; - return this.uploadFileFromSlack( - details, - slackMessage.file.url_private_download, - rocketUser, - rocketChannel, - new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), - isImporting, - ); + async processShareMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: FileShareMessageEvent, + isImporting: boolean, + ): Promise { + // #TODO: file supposedly doesn't exist in this type, but this is what our legacy code expected + const { file } = slackMessage as any; + if (!file?.url_private_download) { + return; } + + const details = { + message_id: this.createSlackMessageId(slackMessage.ts), + name: file.name, + size: file.size, + type: file.mimetype, + rid: rocketChannel._id, + }; + + return this.uploadFileFromSlack( + details, + file.url_private_download, + rocketUser, + rocketChannel, + new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + isImporting, + ); } - async processPinnedItemMessage(rocketChannel, rocketUser, slackMessage, isImporting) { - if (slackMessage.attachments && slackMessage.attachments[0] && slackMessage.attachments[0].text) { - // TODO: refactor this logic to use the service to send this system message instead of using sendMessage - const rocketMsgObj = { - rid: rocketChannel._id, - t: 'message_pinned', - msg: '', - u: { - _id: rocketUser._id, - username: rocketUser.username, + async processPinnedItemMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: PinnedItemMessageEvent, + isImporting: boolean, + ): Promise { + const attachment = slackMessage.attachments?.[0]; + + if (!attachment?.text) { + slackLogger.error('Pinned item with no attachment'); + return; + } + + // TODO: refactor this logic to use the service to send this system message instead of using sendMessage + const rocketMsgObj = { + rid: rocketChannel._id, + t: 'message_pinned', + msg: '', + u: { + _id: rocketUser._id, + username: rocketUser.username, + }, + attachments: [ + { + text: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(attachment.text), + author_name: attachment.author_subname, + author_icon: getUserAvatarURL(attachment.author_subname as string), + ...(attachment.ts && { + ts: new Date(parseInt(attachment.ts.split('.')[0]) * 1000), + }), }, - attachments: [ - { - text: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.attachments[0].text), - author_name: slackMessage.attachments[0].author_subname, - author_icon: getUserAvatarURL(slackMessage.attachments[0].author_subname), - ts: new Date(parseInt(slackMessage.attachments[0].ts.split('.')[0]) * 1000), - }, - ], - }; + ], + }; - if (!isImporting && slackMessage.attachments[0].channel_id && slackMessage.attachments[0].ts) { - const messageId = this.createSlackMessageId(slackMessage.attachments[0].ts, slackMessage.attachments[0].channel_id); + if (!isImporting) { + // #TODO: channel_id supposedly doesn't exist on this type, but it is what our legacy code expects + const channelId = 'channel_id' in attachment ? (attachment.channel_id as string) : ''; + + if (channelId && attachment.ts) { + const messageId = this.createSlackMessageId(attachment.ts, channelId); await Messages.setPinnedByIdAndUserId(messageId, rocketMsgObj.u, true, new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000)); if (settings.get('Message_Read_Receipt_Store_Users')) { await ReadReceipts.setPinnedByMessageId(messageId, true); } } - - return rocketMsgObj; } - slackLogger.error('Pinned item with no attachment'); + + return rocketMsgObj; } - async processSubtypedMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processSubtypedMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: SlackMessageEvent, + isImporting: boolean, + ): Promise { switch (slackMessage.subtype) { case 'bot_message': return this.processBotMessage(rocketChannel, slackMessage); @@ -1236,13 +961,13 @@ export default class SlackAdapter { case 'channel_archive': case 'group_archive': if (!isImporting) { - await archiveRoom(rocketChannel, rocketUser); + await archiveRoom(rocketChannel._id, rocketUser); } return; case 'channel_unarchive': case 'group_unarchive': if (!isImporting) { - await unarchiveRoom(rocketChannel); + await unarchiveRoom(rocketChannel._id, rocketUser); } return; case 'file_share': @@ -1268,80 +993,96 @@ export default class SlackAdapter { @param [Object] room the Rocket.Chat room @param [Date] timeStamp the timestamp the file was uploaded **/ - // details, slackMessage.file.url_private_download, rocketUser, rocketChannel, new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), isImporting); - async uploadFileFromSlack(details, slackFileURL, rocketUser, rocketChannel, timeStamp, isImporting) { - const requestModule = /https/i.test(slackFileURL) ? https : http; - const parsedUrl = url.parse(slackFileURL, true); - parsedUrl.headers = { Authorization: `Bearer ${this.apiToken}` }; - await requestModule.get(parsedUrl, async (stream) => { - const fileStore = FileUpload.getStore('Uploads'); - - const file = await fileStore.insert(details, stream); - - const url = file.url.replace(Meteor.absoluteUrl(), '/'); - const attachment = { - title: file.name, - title_link: url, - }; + async uploadFileFromSlack( + details: Partial, + slackFileURL: string, + rocketUser: IRegisterUser, + rocketChannel: IRoom, + timeStamp: Date, + isImporting: boolean, + ) { + const fileBuffer = await this.slackAPI.getFile(slackFileURL); + if (!fileBuffer) { + slackLogger.debug('Unable to uploadFileFromSlack: file contents not downloaded.'); + return; + } - if (/^image\/.+/.test(file.type)) { - attachment.image_url = url; - attachment.image_type = file.type; - attachment.image_size = file.size; - attachment.image_dimensions = file.identify && file.identify.size; - } - if (/^audio\/.+/.test(file.type)) { - attachment.audio_url = url; - attachment.audio_type = file.type; - attachment.audio_size = file.size; - } - if (/^video\/.+/.test(file.type)) { - attachment.video_url = url; - attachment.video_type = file.type; - attachment.video_size = file.size; - } + const fileStore = FileUpload.getStore('Uploads'); - const msg = { - rid: details.rid, - ts: timeStamp, - msg: '', - file: { - _id: file._id, - }, - groupable: false, - attachments: [attachment], - }; + const file = await fileStore.insert(details, fileBuffer); - if (isImporting) { - msg.imported = 'slackbridge'; - } + const { url: fileUrl, type = '' } = file; - if (details.message_id && typeof details.message_id === 'string') { - msg._id = details.message_id; - } + if (!fileUrl) { + slackLogger.debug('Unable to uploadFileFromSlack: file url is empty'); + return; + } - void sendMessage(rocketUser, msg, rocketChannel, true); - }); + const url = fileUrl.replace(Meteor.absoluteUrl(), '/'); + const attachment: FileAttachmentProps = { + type: 'file', + title: file.name, + title_link: url, + ...(/^image\/.+/.test(type) && { + image_url: url, + image_type: type, + image_size: file.size, + image_dimensions: file.identify?.size, + }), + ...(/^audio\/.+/.test(type) && { + audio_url: url, + audio_type: type, + audio_size: file.size, + }), + ...(/^video\/.+/.test(type) && { + video_url: url, + video_type: type, + video_size: file.size, + }), + }; + + const msg: Partial = { + rid: details.rid, + ts: timeStamp, + msg: '', + file: { + _id: file._id, + } as FileProp, + groupable: false, + attachments: [attachment], + }; + + if (isImporting) { + msg.imported = 'slackbridge'; + } + + if (details.message_id && typeof details.message_id === 'string') { + msg._id = details.message_id; + } + + void sendMessage(rocketUser, msg, rocketChannel, true); } - async importFromHistory(options) { + async importFromHistory(options: ConversationsHistoryArguments): Promise<{ has_more: boolean | undefined; ts: string } | undefined> { slackLogger.debug('Importing messages history'); const data = await this.slackAPI.getHistory(options); if (Array.isArray(data.messages) && data.messages.length) { - let latest = 0; + let latest = ''; for await (const message of data.messages.reverse()) { slackLogger.debug({ msg: 'MESSAGE', message }); - if (!latest || message.ts > latest) { - latest = message.ts; + if (!latest || (message.ts && message.ts > latest)) { + latest = message.ts || ''; } - message.channel = options.channel; - await this.onMessage(message, true); + + const messageData = message as Exclude; + messageData.channel = options.channel; + await this.onMessage(messageData, true); } return { has_more: data.has_more, ts: latest }; } } - async copyChannelInfo(rid, channelMap) { + async copyChannelInfo(rid: string, channelMap: SlackChannel): Promise { slackLogger.debug({ msg: 'Copying users from Slack channel to Rocket.Chat', channelId: channelMap.id, rid }); const channel = await this.slackAPI.getRoomInfo(channelMap.id); if (channel) { @@ -1351,107 +1092,122 @@ export default class SlackAdapter { const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member)); if (user) { slackLogger.debug({ msg: 'Adding user to room', username: user.username, rid }); - await addUserToRoom(rid, user, null, { skipSystemMessage: true }); + await addUserToRoom(rid, user, undefined, { skipSystemMessage: true }); } } } let topic = ''; - let topic_last_set = 0; - let topic_creator = null; - if (channel && channel.topic && channel.topic.value) { + let topicLastSet = 0; + let topicCreator = null; + if (channel?.topic?.value) { topic = channel.topic.value; - topic_last_set = channel.topic.last_set; - topic_creator = channel.topic.creator; + topicLastSet = channel.topic.last_set || topicLastSet; + topicCreator = channel.topic.creator; } - if (channel && channel.purpose && channel.purpose.value) { - if (topic_last_set) { - if (topic_last_set < channel.purpose.last_set) { - topic = channel.purpose.topic; - topic_creator = channel.purpose.creator; + if (channel?.purpose?.value) { + if (topicLastSet) { + if (topicLastSet < (channel.purpose.last_set || 0)) { + topic = (channel.purpose as any).topic || channel.purpose.value; + topicCreator = channel.purpose.creator; } } else { - topic = channel.purpose.topic; - topic_creator = channel.purpose.creator; + topic = (channel.purpose as any).topic || channel.purpose.value; + topicCreator = channel.purpose.creator; } } if (topic) { - const creator = (await this.rocket.findUser(topic_creator)) || (await this.rocket.addUser(topic_creator)); - slackLogger.debug({ msg: 'Setting room topic', rid, topic, username: creator.username }); - await saveRoomTopic(rid, topic, creator, false); + const creator = topicCreator && (await this.rocket.getUser(topicCreator)); + if (creator) { + slackLogger.debug({ msg: 'Setting room topic', rid, topic, username: creator.username }); + await saveRoomTopic(rid, topic, creator, false); + } else { + slackLogger.debug('Unable to set room topic: topic creator not found.'); + } } } } - async copyPins(rid, channelMap) { + async copyPins(rid: string, channelMap: SlackChannel): Promise { const items = await this.slackAPI.getPins(channelMap.id); if (items && Array.isArray(items) && items.length) { for await (const pin of items) { - if (pin.message) { - const user = await this.rocket.findUser(pin.message.user); - // TODO: send this system message to the room as well (using the service) - const msgObj = { - rid, - t: 'message_pinned', - msg: '', - u: { - _id: user._id, - username: user.username, + // message and channel supposedly doesn't exist on this type #TODO + const { message, channel } = pin as any; + + if (!message) { + continue; + } + + const user = await this.rocket.findUser(message.user); + if (!user) { + continue; + } + + // TODO: send this system message to the room as well (using the service) + const msgObj = { + rid, + t: 'message_pinned', + msg: '', + u: { + _id: user._id, + username: user.username, + }, + attachments: [ + { + text: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(message.text), + author_name: user.username, + author_icon: getUserAvatarURL(user.username), + ts: new Date(parseInt(message.ts.split('.')[0]) * 1000), }, - attachments: [ - { - text: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(pin.message.text), - author_name: user.username, - author_icon: getUserAvatarURL(user.username), - ts: new Date(parseInt(pin.message.ts.split('.')[0]) * 1000), - }, - ], - }; - - const messageId = this.createSlackMessageId(pin.message.ts, pin.channel); - await Messages.setPinnedByIdAndUserId(messageId, msgObj.u, true, new Date(parseInt(pin.message.ts.split('.')[0]) * 1000)); - if (settings.get('Message_Read_Receipt_Store_Users')) { - await ReadReceipts.setPinnedByMessageId(messageId, true); - } + ], + }; + + const messageId = this.createSlackMessageId(message.ts, channel); + await Messages.setPinnedByIdAndUserId(messageId, msgObj.u, true, new Date(parseInt(message.ts.split('.')[0]) * 1000)); + if (settings.get('Message_Read_Receipt_Store_Users')) { + await ReadReceipts.setPinnedByMessageId(messageId, true); } } } } - async importMessages(rid, callback) { + async importMessages(rid: string, callback: (error?: Meteor.Error) => void): Promise { slackLogger.info({ msg: 'importMessages', rid }); - const rocketchat_room = await Rooms.findOneById(rid); - if (rocketchat_room) { - if (this.getSlackChannel(rid)) { - await this.copyChannelInfo(rid, this.getSlackChannel(rid)); + const rcRoom = await Rooms.findOneById(rid); + if (rcRoom) { + const slackChannel = this.getSlackChannel(rid); + + if (slackChannel) { + await this.copyChannelInfo(rid, slackChannel); slackLogger.debug({ msg: 'Importing messages from Slack to Rocket.Chat', slackChannel: this.getSlackChannel(rid), rid }); let results = await this.importFromHistory({ - channel: this.getSlackChannel(rid).id, - oldest: 1, + channel: slackChannel.id, + oldest: '1', }); - while (results && results.has_more) { + while (results?.has_more) { // eslint-disable-next-line no-await-in-loop results = await this.importFromHistory({ - channel: this.getSlackChannel(rid).id, + channel: slackChannel.id, oldest: results.ts, }); } slackLogger.debug({ msg: 'Pinning Slack channel messages to Rocket.Chat', slackChannel: this.getSlackChannel(rid), rid }); - await this.copyPins(rid, this.getSlackChannel(rid)); + await this.copyPins(rid, slackChannel); return callback(); } - const slack_room = await this.postFindChannel(rocketchat_room.name); - if (slack_room) { - this.addSlackChannel(rid, slack_room.id); + const slackRoom = await this.postFindChannel(rcRoom.name as string); + if (slackRoom?.id) { + this.addSlackChannel(rid, slackRoom.id); return this.importMessages(rid, callback); } - slackLogger.error({ msg: 'Could not find Slack room with specified name', roomName: rocketchat_room.name }); + slackLogger.error({ msg: 'Could not find Slack room with specified name', roomName: rcRoom.name }); return callback(new Meteor.Error('error-slack-room-not-found', 'Could not find Slack room with specified name')); } slackLogger.error({ msg: 'Could not find Rocket.Chat room with specified id', rid }); diff --git a/apps/meteor/app/slackbridge/server/SlackAdapterApp.ts b/apps/meteor/app/slackbridge/server/SlackAdapterApp.ts new file mode 100644 index 0000000000000..c451faecaf1e6 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/SlackAdapterApp.ts @@ -0,0 +1,176 @@ +import { App as SlackApp } from '@slack/bolt'; + +import { SlackAPI } from './SlackAPI'; +import SlackAdapter from './SlackAdapter'; +import type { IRocketChatAdapter } from './definition/IRocketChatAdapter'; +import type { SlackAppCredentials } from './definition/ISlackAdapter'; +import type { ISlackbridge } from './definition/ISlackbridge'; +import { slackLogger } from './logger'; + +export default class SlackAdapterApp extends SlackAdapter { + private slackApp: SlackApp | null = null; + + private appCredential: SlackAppCredentials | null = null; + + constructor(slackBridge: ISlackbridge, rocket: IRocketChatAdapter) { + super(slackBridge, rocket); + } + + /** + * Connect to the remote Slack server using the passed in app credential and register for Slack events. + */ + async connectApp(appCredential: SlackAppCredentials): Promise { + this.appCredential = appCredential; + + // Invalid app credentials causes unhandled errors + if (!(await SlackAPI.verifyAppCredentials(appCredential))) { + throw new Error('Invalid app credentials (botToken or appToken) for the slack app'); + } + this._slackAPI = new SlackAPI(this.appCredential.botToken); + + this.slackApp = new SlackApp({ + appToken: this.appCredential.appToken, + signingSecret: this.appCredential.signingSecret, + token: this.appCredential.botToken, + socketMode: true, + }); + + this.registerForEvents(); + + const connectResult = await this.slackApp.start(); + if (connectResult) { + slackLogger.info('Connected to Slack'); + slackLogger.debug('Slack connection result: ', connectResult); + } + + return Boolean(connectResult); + } + + async connectLegacy(_apiToken: string): Promise { + return false; + } + + registerForEvents(): void { + if (!this.slackApp) { + return; + } + + /** + * message: { + * "client_msg_id": "caab144d-41e7-47cc-87fa-af5d50c02784", + * "type": "message", + * "text": "heyyyyy", + * "user": "U060WD4QW81", + * "ts": "1697054782.214569", + * "blocks": [], + * "team": "T060383CUDV", + * "channel": "C060HSLQPCN", + * "event_ts": "1697054782.214569", + * "channel_type": "channel" + * } + */ + this.slackApp.message(async (event) => { + const { message } = event; + slackLogger.debug('OnSlackEvent-MESSAGE: ', message); + if (message) { + try { + await this.onMessage(message); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onMessage', err }); + } + } + }); + + /** + * Event fired when a message is reacted in a channel or group app is added in + * event: { + * "type": "reaction_added", + * "user": "U060WD4QW81", + * "reaction": "telephone_receiver", + * "item": { + * "type": "message", + * "channel": "C06196XMUMN", + * "ts": "1697037020.309679" + * }, + * "item_user": "U060WD4QW81", + * "event_ts": "1697037219.001600" + * } + */ + this.slackApp.event('reaction_added', async ({ event }) => { + slackLogger.debug('OnSlackEvent-REACTION_ADDED: ', event); + try { + slackLogger.error({ event }); + await this.onReactionAdded(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); + } + }); + + /** + * Event fired when a reaction is removed from a message in a channel or group app is added in. + * event: { + * "type": "reaction_removed", + * "user": "U060WD4QW81", + * "reaction": "raised_hands", + * "item": { + * "type": "message", + * "channel": "C06196XMUMN", + * "ts": "1697028997.057629" + * }, + * "item_user": "U060WD4QW81", + * "event_ts": "1697029220.000600" + * } + */ + this.slackApp.event('reaction_removed', async ({ event }) => { + slackLogger.debug('OnSlackEvent-REACTION_REMOVED: ', event); + try { + await this.onReactionRemoved(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionRemoved', err }); + } + }); + + /** + * Event fired when a members joins a channel + * event: { + * "type": "member_joined_channel", + * "user": "U06039U8WK1", + * "channel": "C060HT033E2", + * "channel_type": "C", + * "team": "T060383CUDV", + * "inviter": "U060WD4QW81", + * "event_ts": "1697042377.000800" + * } + */ + this.slackApp.event('member_joined_channel', async ({ event, context }) => { + slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', event); + try { + await this.processMemberJoinChannel(event, context); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + }); + + this.slackApp.event('channel_left', async ({ event }) => { + slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', event); + try { + this.onChannelLeft(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + }); + + this.slackApp.error(async (error) => { + slackLogger.error({ msg: 'Error on SlackApp', error }); + }); + } + + /** + * Unregister for slack events and disconnect from Slack + */ + async disconnect() { + if (this.slackApp?.stop) { + await this.slackApp.stop(); + } + } +} diff --git a/apps/meteor/app/slackbridge/server/SlackAdapterLegacy.ts b/apps/meteor/app/slackbridge/server/SlackAdapterLegacy.ts new file mode 100644 index 0000000000000..db7f855071b68 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/SlackAdapterLegacy.ts @@ -0,0 +1,336 @@ +import { RTMClient } from '@slack/rtm-api'; + +import { SlackAPI } from './SlackAPI'; +import SlackAdapter from './SlackAdapter'; +import type { IRocketChatAdapter } from './definition/IRocketChatAdapter'; +import type { SlackAppCredentials } from './definition/ISlackAdapter'; +import type { ISlackbridge } from './definition/ISlackbridge'; +import { slackLogger } from './logger'; + +/** + * @deprecated + */ +export default class SlackAdapterLegacy extends SlackAdapter { + // Slack API Token passed in via Connect + private apiToken: string | null = null; + + // slack-client Real Time Messaging API + private rtm: RTMClient | null = null; + + constructor(slackBridge: ISlackbridge, rocket: IRocketChatAdapter) { + super(slackBridge, rocket); + } + + /** + * Connect to the remote Slack server using the passed in token API and register for Slack events. + * @param apiToken + * @deprecated + */ + async connectLegacy(apiToken: string): Promise { + this.apiToken = apiToken; + + // Invalid apiToken causes unhandled errors + if (!(await SlackAPI.verifyToken(apiToken))) { + throw new Error('Invalid ApiToken for the slack legacy bot integration'); + } + + await this.disconnect(); + + this._slackAPI = new SlackAPI(this.apiToken); + this.rtm = new RTMClient(this.apiToken); + + this.registerForEvents(); + + const connectResult = await this.rtm.start(); + + if (connectResult) { + slackLogger.info('Connected to Slack'); + slackLogger.debug('Slack connection result: ', connectResult); + } + + return Boolean(connectResult); + } + + async connectApp(_appCredential: SlackAppCredentials): Promise { + return false; + } + + registerForEvents(): void { + if (!this.rtm) { + return; + } + + slackLogger.debug('Register for events'); + this.rtm.on('authenticated', () => { + slackLogger.info('Connected to Slack'); + }); + + this.rtm.on('unable_to_rtm_start', () => { + // Using "void" because the JS code didn't have anything + void this.slackBridge.disconnect(); + }); + + this.rtm.on('disconnected', () => { + slackLogger.info('Disconnected from Slack'); + // Using "void" because the JS code didn't have anything + void this.slackBridge.disconnect(); + }); + + /** + * Event fired when someone messages a channel the bot is in + * { + * type: 'message', + * channel: [channel_id], + * user: [user_id], + * text: [message], + * ts: [ts.milli], + * team: [team_id], + * subtype: [message_subtype], + * inviter: [message_subtype = 'group_join|channel_join' -> user_id] + * } + **/ + this.rtm.on('message', async (slackMessage) => { + slackLogger.debug('OnSlackEvent-MESSAGE: ', slackMessage); + if (slackMessage) { + try { + await this.onMessage(slackMessage); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onMessage', err }); + } + } + }); + + this.rtm.on('reaction_added', async (reactionMsg) => { + slackLogger.debug('OnSlackEvent-REACTION_ADDED: ', reactionMsg); + if (reactionMsg) { + try { + await this.onReactionAdded(reactionMsg); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); + } + } + }); + + this.rtm.on('reaction_removed', async (reactionMsg) => { + slackLogger.debug('OnSlackEvent-REACTION_REMOVED: ', reactionMsg); + if (reactionMsg) { + try { + await this.onReactionRemoved(reactionMsg); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionRemoved', err }); + } + } + }); + + /** + * Event fired when someone creates a public channel + * { + * type: 'channel_created', + * channel: { + * id: [channel_id], + * is_channel: true, + * name: [channel_name], + * created: [ts], + * creator: [user_id], + * is_shared: false, + * is_org_shared: false + * }, + * event_ts: [ts.milli] + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('channel_created', () => {}); + + /** + * Event fired when the bot joins a public channel + * { + * type: 'channel_joined', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_channel: true, + * created: [ts], + * creator: [user_id], + * is_archived: false, + * is_general: false, + * is_member: true, + * last_read: [ts.milli], + * latest: [message_obj], + * unread_count: 0, + * unread_count_display: 0, + * members: [ user_ids ], + * topic: { + * value: [channel_topic], + * creator: [user_id], + * last_set: 0 + * }, + * purpose: { + * value: [channel_purpose], + * creator: [user_id], + * last_set: 0 + * } + * } + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('channel_joined', () => {}); + + /** + * Event fired when the bot leaves (or is removed from) a public channel + * { + * type: 'channel_left', + * channel: [channel_id] + * } + **/ + this.rtm.on('channel_left', (channelLeftMsg) => { + slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', channelLeftMsg); + if (channelLeftMsg) { + try { + this.onChannelLeft(channelLeftMsg); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + } + }); + + /** + * Event fired when an archived channel is deleted by an admin + * { + * type: 'channel_deleted', + * channel: [channel_id], + * event_ts: [ts.milli] + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('channel_deleted', () => {}); + + /** + * Event fired when the channel has its name changed + * { + * type: 'channel_rename', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_channel: true, + * created: [ts] + * }, + * event_ts: [ts.milli] + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('channel_rename', () => {}); + + /** + * Event fired when the bot joins a private channel + * { + * type: 'group_joined', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_group: true, + * created: [ts], + * creator: [user_id], + * is_archived: false, + * is_mpim: false, + * is_open: true, + * last_read: [ts.milli], + * latest: [message_obj], + * unread_count: 0, + * unread_count_display: 0, + * members: [ user_ids ], + * topic: { + * value: [channel_topic], + * creator: [user_id], + * last_set: 0 + * }, + * purpose: { + * value: [channel_purpose], + * creator: [user_id], + * last_set: 0 + * } + * } + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('group_joined', () => {}); + + /** + * Event fired when the bot leaves (or is removed from) a private channel + * { + * type: 'group_left', + * channel: [channel_id] + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('group_left', () => {}); + + /** + * Event fired when the private channel has its name changed + * { + * type: 'group_rename', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_group: true, + * created: [ts] + * }, + * event_ts: [ts.milli] + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('group_rename', () => {}); + + /** + * Event fired when a new user joins the team + * { + * type: 'team_join', + * user: + * { + * id: [user_id], + * team_id: [team_id], + * name: [user_name], + * deleted: false, + * status: null, + * color: [color_code], + * real_name: '', + * tz: [timezone], + * tz_label: [timezone_label], + * tz_offset: [timezone_offset], + * profile: + * { + * avatar_hash: '', + * real_name: '', + * real_name_normalized: '', + * email: '', + * image_24: '', + * image_32: '', + * image_48: '', + * image_72: '', + * image_192: '', + * image_512: '', + * fields: null + * }, + * is_admin: false, + * is_owner: false, + * is_primary_owner: false, + * is_restricted: false, + * is_ultra_restricted: false, + * is_bot: false, + * presence: [user_presence] + * }, + * cache_ts: [ts] + * } + **/ + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.rtm.on('team_join', () => {}); + } + + /** + * Unregister for slack events and disconnect from Slack + */ + async disconnect() { + if (this.rtm?.connected && this.rtm.disconnect) { + await this.rtm.disconnect(); + } + } +} diff --git a/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts b/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts new file mode 100644 index 0000000000000..9525e9a457eba --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts @@ -0,0 +1,12 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +export type SlackTS = string; + +export type IMessageSyncedWithSlack = IMessage & { slackTs?: SlackTS; updatedBySlack?: boolean }; + +export type IMessageImportedFromSlack = IMessage & { _id: `slack-${string}-${string}-${string}` }; + +export const isMessageSyncedWithSlack = (message: IMessage): message is IMessageSyncedWithSlack => + 'slackTs' in message || 'updatedBySlack' in message; + +export const isMessageImportedFromSlack = (message: IMessage): message is IMessageImportedFromSlack => message._id.startsWith('slack-'); diff --git a/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts b/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts new file mode 100644 index 0000000000000..f71476b2ed1d2 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts @@ -0,0 +1,35 @@ +import type { IMessage, IRegisterUser, IRoom } from '@rocket.chat/core-typings'; +import type { ConversationsInfoResponse } from '@slack/web-api'; + +import type { SlackTS } from './IMessageSyncedWithSlack'; +import type { ISlackAdapter } from './ISlackAdapter'; + +export type RocketChatUserIdentification = Pick; + +export type SlackConversationSyncedWithRocketChat = ConversationsInfoResponse['channel'] & { rocketId?: string }; + +export interface IRocketChatAdapter { + slackAdapters: ISlackAdapter[]; + + connect(): void; + disconnect(): void; + + clearSlackAdapters(): void; + addSlack(slack: ISlackAdapter): void; + addUser(slackUserID: string): Promise; + getChannel(slackMessage: { channel?: string }): Promise; + findUser(slackUserID: string): Promise; + getUser(slackUser: string): Promise; + createAndSaveMessage( + rocketChannel: IRoom, + rocketUser: IRegisterUser, + slackMessage: unknown, + rocketMsgDataDefaults: Partial, + isImporting: boolean, + slack: ISlackAdapter, + ): Promise; + createRocketID(slackChannel: string, ts: SlackTS): string; + addChannel(slackChannelID: string, hasRetried?: boolean): Promise; + convertSlackMsgTxtToRocketTxtFormat(slackMsgTxt: string | undefined): Promise; + addAliasToMsg(rocketUserName: string, rocketMsgObj: Partial): Partial; +} diff --git a/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts b/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts new file mode 100644 index 0000000000000..6b3473dc719c2 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts @@ -0,0 +1,110 @@ +import type { + ConversationsListResponse, + ConversationsInfoResponse, + ConversationsMembersResponse, + ChatPostMessageArguments, + ChatPostMessageResponse, + ChatUpdateArguments, + UsersInfoResponse, + ReactionsAddArguments, + ReactionsRemoveArguments, + ChatDeleteArguments, + ConversationsHistoryArguments, + ConversationsHistoryResponse, + PinsListResponse, +} from '@slack/web-api'; + +export type SlackConversation = { + id: string; + name: string; + is_channel: boolean; + is_group: boolean; + is_im: boolean; + is_mpim: boolean; + is_private: boolean; + created: number; + is_archived: boolean; + is_general: boolean; + unlinked: number; + name_normalized: string; + is_shared: boolean; + is_frozen: boolean; + is_org_shared: boolean; + is_pending_ext_shared: boolean; + pending_shared: unknown[]; + context_team_id: string; + updated: Date; + parent_conversation: unknown | null; + creator: string; + is_ext_shared: boolean; + shared_team_ids: string[]; + is_member: boolean; + topic: { + value: string; + creator: string; + last_set: number; + }; + purpose: { + value: string; + creator: string; + last_set: number; + }; + properties: { + posting_restricted_to: { + type: string[]; + }; + threads_restricted_to: { + type: string[]; + }; + tabs: { + id: 'string'; + label: string; + type: string; + }[]; + }; + previous_names: string[]; +}; + +export type SlackUser = { + id: string; + team_id: string; + name: string; + deleted: boolean; + color: string; + real_name: string; + tz: string; + tz_label: string; + tz_offset: number; + profile: Record; + is_admin: boolean; + is_owner: boolean; + is_primary_owner: boolean; + is_restricted: boolean; + is_ultra_restricted: boolean; + is_bot: boolean; + is_app_user: boolean; + updated: number; + is_email_confirmed: boolean; + who_can_share_contact_card: string; + enterprise_user: Record; +}; + +export type SlackPostMessage = ChatPostMessageArguments & { + username?: string; +}; + +export interface ISlackAPI { + getChannels(cursor?: string | null): Promise['channels']>; + getGroups(cursor?: string | null): Promise['channels']>; + getRoomInfo(roomId: string): Promise; + getMembers(channelId: string): Promise; + react(data: ReactionsAddArguments): Promise; + removeReaction(data: ReactionsRemoveArguments): Promise; + removeMessage(data: ChatDeleteArguments): Promise; + sendMessage(data: ChatPostMessageArguments): Promise; + updateMessage(data: ChatUpdateArguments): Promise; + getHistory(options: ConversationsHistoryArguments): Promise; + getPins(channelId: string): Promise; + getUser(userId: string): Promise; + getFile(fileUrl: string): Promise | undefined>; +} diff --git a/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts b/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts new file mode 100644 index 0000000000000..293ca2b74e078 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts @@ -0,0 +1,47 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; + +import type { SlackTS } from './IMessageSyncedWithSlack'; +import type { RocketChatUserIdentification } from './IRocketChatAdapter'; +import type { ISlackAPI } from './ISlackAPI'; +import type { RocketChatMessageData } from './RocketChatMessageData'; +import type { SlackMessageEvent } from './SlackMessageEvent'; + +export type SlackAppCredentials = { + botToken: string; + appToken: string; + signingSecret: string; +}; + +export type SlackChannel = { + id: string; + family: 'channels' | 'groups'; +}; + +export interface ISlackAdapter { + slackAPI: ISlackAPI; + + connect(params: { apiToken?: string; appCredential?: SlackAppCredentials }): Promise; + connectApp(appCredential: SlackAppCredentials): Promise; + connectLegacy(apiToken: string): Promise; + disconnect(): Promise; + + registerForEvents(): void; + + getSlackChannel(rocketChatChannelId: string): SlackChannel | undefined; + postDeleteMessage(message: IMessage): Promise; + + getTimeStamp(message: IMessage): SlackTS | undefined; + postReactionAdded(reaction: string, slackChannel: SlackChannel['id'], slackTS: SlackTS | undefined): Promise; + postReactionRemove(reaction: string, slackChannel: SlackChannel['id'], slackTS: SlackTS | undefined): Promise; + postMessage(slackChannel: SlackChannel | undefined, rocketMessage: IMessage): Promise; + postMessageUpdate(slackChannel: SlackChannel | undefined, rocketMessage: IMessage): Promise; + + addSlackChannel(rocketChID: string, slackChID: SlackChannel['id']): void; + + processSubtypedMessage( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + slackMessage: SlackMessageEvent, + isImporting: boolean, + ): Promise; +} diff --git a/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts b/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts new file mode 100644 index 0000000000000..1b0f4a5a79bfe --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts @@ -0,0 +1,11 @@ +export interface ISlackbridge { + isReactionsEnabled: boolean; + + reactionsMap: Map; + aliasFormat: string; + excludeBotNames: string; + + connect(): void; + reconnect(): Promise; + disconnect(): Promise; +} diff --git a/apps/meteor/app/slackbridge/server/definition/RocketChatMessageData.ts b/apps/meteor/app/slackbridge/server/definition/RocketChatMessageData.ts new file mode 100644 index 0000000000000..3a404686a87e8 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/RocketChatMessageData.ts @@ -0,0 +1,2 @@ +// message object that is passed to the sendMessage function +export type RocketChatMessageData = Record; diff --git a/apps/meteor/app/slackbridge/server/definition/SlackMessageEvent.ts b/apps/meteor/app/slackbridge/server/definition/SlackMessageEvent.ts new file mode 100644 index 0000000000000..e404437c04634 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/SlackMessageEvent.ts @@ -0,0 +1,122 @@ +import type { + GenericMessageEvent, + BotMessageEvent, + ChannelArchiveMessageEvent, + ChannelJoinMessageEvent, + ChannelLeaveMessageEvent, + ChannelNameMessageEvent, + ChannelPostingPermissionsMessageEvent, + ChannelPurposeMessageEvent, + ChannelTopicMessageEvent, + ChannelUnarchiveMessageEvent, + EKMAccessDeniedMessageEvent, + FileShareMessageEvent, + MeMessageEvent, + MessageChangedEvent, + MessageDeletedEvent, + MessageRepliedEvent, + ThreadBroadcastMessageEvent, + MessageAttachment, +} from '@slack/types'; + +export type SlackMessageEvent = + | GenericMessageEvent + | BotMessageEvent + | ChannelArchiveMessageEvent + | ChannelJoinMessageEvent + | ChannelLeaveMessageEvent + | ChannelNameMessageEvent + | ChannelPostingPermissionsMessageEvent + | ChannelPurposeMessageEvent + | ChannelTopicMessageEvent + | ChannelUnarchiveMessageEvent + | EKMAccessDeniedMessageEvent + | FileShareMessageEvent + | MeMessageEvent + | MessageChangedEvent + | MessageDeletedEvent + | MessageRepliedEvent + | ThreadBroadcastMessageEvent + | GroupJoinMessageEvent + | GroupLeaveMessageEvent + | GroupTopicMessageEvent + | GroupPurposeMessageEvent + | GroupNameMessageEvent + | GroupArchiveMessageEvent + | GroupUnarchiveMessageEvent + | FileCommentMessageEvent + | FileMentionMessageEvent + | PinnedItemMessageEvent + | UnpinnedItemMessageEvent; + +// group_join messages are only emmited by the RTM API +export type GroupJoinMessageEvent = Omit & { + subtype: 'group_join'; +}; + +// group_leave messages are only emmited by the RTM API +export type GroupLeaveMessageEvent = Omit & { + subtype: 'group_leave'; +}; + +// group_topic messages are only emmited by the RTM API +export type GroupTopicMessageEvent = Omit & { + subtype: 'group_topic'; +}; + +// group_purpose messages are only emmited by the RTM API +export type GroupPurposeMessageEvent = Omit & { + subtype: 'group_purpose'; +}; + +// group_name messages are only emmited by the RTM API +export type GroupNameMessageEvent = Omit & { + subtype: 'group_name'; +}; + +// group_archive messages are only emmited by the RTM API +export type GroupArchiveMessageEvent = Omit & { + subtype: 'group_archive'; +}; + +// group_unarchive messages are only emmited by the RTM API +export type GroupUnarchiveMessageEvent = Omit & { + subtype: 'group_unarchive'; +}; + +// file_comment messages are only emmited by the RTM API +export type FileCommentMessageEvent = { + type: 'message'; + subtype: 'file_comment'; + ts: string; + text: string; + file: unknown; + comment: unknown; +}; + +export type FileMentionMessageEvent = { + type: 'message'; + subtype: 'file_mention'; + ts: string; + text: string; + file: unknown; + user: string; +}; + +export type PinnedItemMessageEvent = { + type: 'message'; + subtype: 'pinned_item'; + user: string; + item_type: 'C' | 'G' | 'F' | 'Fc'; + text: string; + item: unknown; + channel: string; + ts: string; + + // According to Slack documentation, there is no such attribute here, but it is what our legacy code expects + attachments?: MessageAttachment[]; +}; + +export type UnpinnedItemMessageEvent = Omit & { + subtype: 'unpinned_item'; +}; diff --git a/apps/meteor/app/slackbridge/server/slackbridge.ts b/apps/meteor/app/slackbridge/server/slackbridge.ts index 13455b8068508..82c28f05d2097 100644 --- a/apps/meteor/app/slackbridge/server/slackbridge.ts +++ b/apps/meteor/app/slackbridge/server/slackbridge.ts @@ -1,36 +1,73 @@ -// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS -// TODO: Remove the following lint/ts instructions when the file gets properly converted -/* eslint-disable @typescript-eslint/no-floating-promises */ -/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ -// @ts-nocheck import { debounce } from 'lodash'; import RocketAdapter from './RocketAdapter'; -import SlackAdapter from './SlackAdapter'; +import SlackAdapterApp from './SlackAdapterApp'; +import SlackAdapterLegacy from './SlackAdapterLegacy'; +import type { IRocketChatAdapter } from './definition/IRocketChatAdapter'; +import type { ISlackAdapter, SlackAppCredentials } from './definition/ISlackAdapter'; +import type { ISlackbridge } from './definition/ISlackbridge'; import { classLogger, connLogger } from './logger'; import { settings } from '../../settings/server'; /** * SlackBridge interfaces between this Rocket installation and a remote Slack installation. */ -class SlackBridgeClass { +class SlackBridgeClass implements ISlackbridge { + private isEnabled = false; + + private isLegacyRTM = true; + + private slackAdapters: ISlackAdapter[] = []; + + private rocket: IRocketChatAdapter; + + private _reactionsMap = new Map(); + + private connected = false; + + private apiTokens = ''; + + private botTokens = ''; + + private appTokens = ''; + + private signingSecrets = ''; + + private _aliasFormat = ''; + + private _excludeBotNames = ''; + + public isReactionsEnabled = true; + + public get reactionsMap(): Map { + return this._reactionsMap; + } + + public get aliasFormat(): string { + return this._aliasFormat; + } + + public get excludeBotNames(): string { + return this._excludeBotNames; + } + constructor() { this.isEnabled = false; this.isLegacyRTM = true; this.slackAdapters = []; this.rocket = new RocketAdapter(this); - this.reactionsMap = new Map(); // Sync object between rocket and slack + this._reactionsMap = new Map(); // Sync object between rocket and slack this.connected = false; this.rocket.clearSlackAdapters(); // Settings that we cache versus looking up at runtime - this.apiTokens = false; - this.botTokens = false; - this.appTokens = false; - this.signingSecrets = false; - this.aliasFormat = ''; - this.excludeBotnames = ''; + this.apiTokens = ''; + this.botTokens = ''; + this.appTokens = ''; + this.signingSecrets = ''; + this._aliasFormat = ''; + this._excludeBotNames = ''; this.isReactionsEnabled = true; this.processSettings(); @@ -45,8 +82,7 @@ class SlackBridgeClass { const tokenList = this.apiTokens.split('\n'); tokenList.forEach((apiToken) => { - const slack = new SlackAdapter(this); - slack.setRocket(this.rocket); + const slack: ISlackAdapter = new SlackAdapterLegacy(this, this.rocket); this.rocket.addSlack(slack); this.slackAdapters.push(slack); @@ -63,15 +99,14 @@ class SlackBridgeClass { return; } - const appCredentials = botTokenList.map((botToken, i) => ({ + const appCredentials: SlackAppCredentials[] = botTokenList.map((botToken, i) => ({ botToken, appToken: appTokenList[i], signingSecret: signingSecretList[i], })); appCredentials.forEach((appCredential) => { - const slack = new SlackAdapter(this); - slack.setRocket(this.rocket); + const slack: ISlackAdapter = new SlackAdapterApp(this, this.rocket); this.rocket.addSlack(slack); this.slackAdapters.push(slack); @@ -100,14 +135,15 @@ class SlackBridgeClass { debouncedReconnectIfEnabled = debounce(() => { if (this.isEnabled) { - this.reconnect(); + // Using "void" because the JS code didn't have anything + void this.reconnect(); } }, 500); async disconnect() { try { if (this.connected === true) { - await this.rocket.disconnect(); + this.rocket.disconnect(); await Promise.all(this.slackAdapters.map((slack) => slack.disconnect())); this.slackAdapters = []; this.connected = false; @@ -120,7 +156,7 @@ class SlackBridgeClass { processSettings() { // Check if legacy realtime api is enabled - settings.watch('SlackBridge_UseLegacy', (value) => { + settings.watch('SlackBridge_UseLegacy', (value) => { if (value !== this.isLegacyRTM) { this.isLegacyRTM = value; this.debouncedReconnectIfEnabled(); @@ -129,7 +165,7 @@ class SlackBridgeClass { }); // Slack installtion Bot token - settings.watch('SlackBridge_BotToken', (value) => { + settings.watch('SlackBridge_BotToken', (value) => { if (value !== this.botTokens) { this.botTokens = value; this.debouncedReconnectIfEnabled(); @@ -137,7 +173,7 @@ class SlackBridgeClass { classLogger.debug({ msg: 'Setting: SlackBridge_BotToken', value }); }); // Slack installtion App token - settings.watch('SlackBridge_AppToken', (value) => { + settings.watch('SlackBridge_AppToken', (value) => { if (value !== this.appTokens) { this.appTokens = value; this.debouncedReconnectIfEnabled(); @@ -145,7 +181,7 @@ class SlackBridgeClass { classLogger.debug({ msg: 'Setting: SlackBridge_AppToken', value }); }); // Slack installtion Signing token - settings.watch('SlackBridge_SigningSecret', (value) => { + settings.watch('SlackBridge_SigningSecret', (value) => { if (value !== this.signingSecrets) { this.signingSecrets = value; this.debouncedReconnectIfEnabled(); @@ -154,7 +190,7 @@ class SlackBridgeClass { }); // Slack installation API token - settings.watch('SlackBridge_APIToken', (value) => { + settings.watch('SlackBridge_APIToken', (value) => { if (value !== this.apiTokens) { this.apiTokens = value; this.debouncedReconnectIfEnabled(); @@ -164,31 +200,32 @@ class SlackBridgeClass { }); // Import messages from Slack with an alias; %s is replaced by the username of the user. If empty, no alias will be used. - settings.watch('SlackBridge_AliasFormat', (value) => { - this.aliasFormat = value; + settings.watch('SlackBridge_AliasFormat', (value) => { + this._aliasFormat = value; classLogger.debug({ msg: 'Setting: SlackBridge_AliasFormat', value }); }); // Do not propagate messages from bots whose name matches the regular expression above. If left empty, all messages from bots will be propagated. - settings.watch('SlackBridge_ExcludeBotnames', (value) => { - this.excludeBotnames = value; + settings.watch('SlackBridge_ExcludeBotnames', (value) => { + this._excludeBotNames = value; classLogger.debug({ msg: 'Setting: SlackBridge_ExcludeBotnames', value }); }); // Reactions - settings.watch('SlackBridge_Reactions_Enabled', (value) => { + settings.watch('SlackBridge_Reactions_Enabled', (value) => { this.isReactionsEnabled = value; classLogger.debug({ msg: 'Setting: SlackBridge_Reactions_Enabled', value }); }); // Is this entire SlackBridge enabled - settings.watch('SlackBridge_Enabled', (value) => { + settings.watch('SlackBridge_Enabled', (value) => { if (this.isEnabled !== value) { this.isEnabled = value; if (this.isEnabled) { this.debouncedReconnectIfEnabled(); } else { - this.disconnect(); + // Using "void" because the JS code didn't have anything + void this.disconnect(); } } classLogger.debug({ msg: 'Setting: SlackBridge_Enabled', value }); diff --git a/apps/meteor/app/slackbridge/server/slackbridge_import.server.ts b/apps/meteor/app/slackbridge/server/slackbridge_import.server.ts index 7eda03f908c44..6e7117af976a2 100644 --- a/apps/meteor/app/slackbridge/server/slackbridge_import.server.ts +++ b/apps/meteor/app/slackbridge/server/slackbridge_import.server.ts @@ -1,7 +1,3 @@ -// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS -// TODO: Remove the following lint/ts instructions when the file gets properly converted -/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ -// @ts-nocheck import { Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { Match } from 'meteor/check'; diff --git a/apps/meteor/lib/utils/stringUtils.ts b/apps/meteor/lib/utils/stringUtils.ts index bd457c2337f01..8d303055da416 100644 --- a/apps/meteor/lib/utils/stringUtils.ts +++ b/apps/meteor/lib/utils/stringUtils.ts @@ -111,3 +111,10 @@ export function pad(_str: unknown, _length: number, padStr?: string, type: 'righ export function lrpad(str: unknown, length: number, padStr?: string): string { return pad(str, length, padStr, 'both'); } + +export async function replace(str: string, regex: RegExp, replaceFn: (...match: string[]) => Promise): Promise { + // Run the regex to get all possible matches and call the async replaceFn for each of them + const newValues = await Promise.all(str.matchAll(regex).map(async (match) => (await replaceFn(...match)) ?? match[0])); + // After waiting for all promises to resolve, the newValues array will have a list with the result of all individual function calls, which we can use to actually replace the matches + return str.replace(regex, () => newValues.shift() as string); +} diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 9f61d6fad1b56..30d5d7e7ca47a 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -156,7 +156,7 @@ export interface IMessage extends IRocketChatRecord { md?: Root; _hidden?: boolean; - imported?: boolean; + imported?: boolean | 'slackbridge'; replies?: IUser['_id'][]; location?: { type: 'Point'; diff --git a/packages/model-typings/src/models/IMessagesModel.ts b/packages/model-typings/src/models/IMessagesModel.ts index ad0b230f6e1d9..f879a6e5151bb 100644 --- a/packages/model-typings/src/models/IMessagesModel.ts +++ b/packages/model-typings/src/models/IMessagesModel.ts @@ -205,7 +205,7 @@ export interface IMessagesModel extends IBaseModel { ): Promise; findVisibleByRoomIdBeforeTimestamp(roomId: string, timestamp: Date, options?: FindOptions): FindCursor; getLastTimestamp(options?: FindOptions): Promise; - findOneBySlackBotIdAndSlackTs(slackBotId: string, slackTs: Date): Promise; + findOneBySlackBotIdAndSlackTs(slackBotId: string, slackTs: string): Promise; findByRoomIdAndMessageIds(rid: string, messageIds: string[], options?: FindOptions): FindCursor; findForUpdates(roomId: IMessage['rid'], timestamp: { $lt: Date } | { $gt: Date }, options?: FindOptions): FindCursor; updateUsernameOfEditByUserId(userId: string, username: string): Promise; @@ -214,7 +214,7 @@ export interface IMessagesModel extends IBaseModel { setUrlsById(_id: string, urls: NonNullable): Promise; getLastVisibleUserMessageSentByRoomId(rid: string, messageId?: string): Promise; - findOneBySlackTs(slackTs: Date): Promise; + findOneBySlackTs(slackTs: string): Promise; cloneAndSaveAsHistoryById(_id: string, user: IMessage['u']): Promise>; cloneAndSaveAsHistoryByRecord(record: IMessage, user: IMessage['u']): Promise>; @@ -239,7 +239,7 @@ export interface IMessagesModel extends IBaseModel { newMessage: string, ): Promise; unlinkUserId(userId: string, newUserId: string, newUsername: string, newNameAlias: string): Promise; - setSlackBotIdAndSlackTs(_id: string, slackBotId: string, slackTs: Date): Promise; + setSlackBotIdAndSlackTs(_id: string, slackBotId: string, slackTs: string): Promise; setMessageAttachments(_id: string, attachments: IMessage['attachments']): Promise; removeByRoomIds(rids: string[]): Promise; diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index ac2a53771d975..ce32360f06e81 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -1053,7 +1053,7 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { return this.find(query, options); } - findOneBySlackBotIdAndSlackTs(slackBotId: string, slackTs: Date): Promise { + findOneBySlackBotIdAndSlackTs(slackBotId: string, slackTs: string): Promise { const query = { slackBotId, slackTs, @@ -1062,7 +1062,7 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { return this.findOne(query); } - findOneBySlackTs(slackTs: Date): Promise { + findOneBySlackTs(slackTs: string): Promise { const query = { slackTs }; return this.findOne(query); @@ -1321,7 +1321,7 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { return this.updateOne(query, update); } - setSlackBotIdAndSlackTs(_id: string, slackBotId: string, slackTs: Date): Promise { + setSlackBotIdAndSlackTs(_id: string, slackBotId: string, slackTs: string): Promise { const query = { _id }; const update: UpdateFilter = { diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 5f60085511b71..20c12d01f9b3f 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -9,6 +9,7 @@ export * from './timezone'; export * from './wrapExceptions'; export * from './getLoginExpiration'; export * from './converter'; +export * from './promiseTimeout'; export * from './removeEmpty'; export * from './isObject'; export * from './isRecord'; diff --git a/packages/tools/src/promiseTimeout.ts b/packages/tools/src/promiseTimeout.ts new file mode 100644 index 0000000000000..4d8f60dc52b80 --- /dev/null +++ b/packages/tools/src/promiseTimeout.ts @@ -0,0 +1,5 @@ +export function promiseTimeout(time: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +}