From f42d6b03418b535e398731a4a0fc100f1f5d3f7c Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 12 Feb 2025 22:10:32 -0300 Subject: [PATCH 1/4] chore: convert slackbridge to ts --- .../{RocketAdapter.js => RocketAdapter.ts} | 399 +++++--- .../server/{SlackAPI.js => SlackAPI.ts} | 24 +- .../{SlackAdapter.js => SlackAdapter.ts} | 916 ++++++------------ .../app/slackbridge/server/SlackAdapterApp.ts | 176 ++++ .../slackbridge/server/SlackAdapterLegacy.ts | 336 +++++++ .../server/definition/IRocketChatAdapter.ts | 26 + .../server/definition/ISlackAPI.ts | 151 +++ .../server/definition/ISlackAdapter.ts | 51 + .../server/definition/ISlackbridge.ts | 9 + .../definition/RocketChatMessageData.ts | 2 + .../server/definition/SlackMessageEvent.ts | 122 +++ .../server/{slackbridge.js => slackbridge.ts} | 85 +- apps/meteor/lib/utils/stringUtils.ts | 7 + .../src/models/IMessagesModel.ts | 6 +- packages/models/src/models/Messages.ts | 6 +- packages/tools/src/index.ts | 1 + packages/tools/src/promiseTimeout.ts | 5 + 17 files changed, 1512 insertions(+), 810 deletions(-) rename apps/meteor/app/slackbridge/server/{RocketAdapter.js => RocketAdapter.ts} (53%) rename apps/meteor/app/slackbridge/server/{SlackAPI.js => SlackAPI.ts} (90%) rename apps/meteor/app/slackbridge/server/{SlackAdapter.js => SlackAdapter.ts} (57%) create mode 100644 apps/meteor/app/slackbridge/server/SlackAdapterApp.ts create mode 100644 apps/meteor/app/slackbridge/server/SlackAdapterLegacy.ts create mode 100644 apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts create mode 100644 apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts create mode 100644 apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts create mode 100644 apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts create mode 100644 apps/meteor/app/slackbridge/server/definition/RocketChatMessageData.ts create mode 100644 apps/meteor/app/slackbridge/server/definition/SlackMessageEvent.ts rename apps/meteor/app/slackbridge/server/{slackbridge.js => slackbridge.ts} (69%) create mode 100644 packages/tools/src/promiseTimeout.ts diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.js b/apps/meteor/app/slackbridge/server/RocketAdapter.ts similarity index 53% rename from apps/meteor/app/slackbridge/server/RocketAdapter.js rename to apps/meteor/app/slackbridge/server/RocketAdapter.ts index 70418d407443e..21d83ed20b8fa 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.js +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -1,26 +1,54 @@ import util from 'util'; +import type { DeepWritable, IMessage, 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 { IRocketChatAdapter, RocketChatUserIdentification } from './definition/IRocketChatAdapter'; +import type { SlackConversation } from './definition/ISlackAPI'; +import type { ISlackAdapter, SlackTS } 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 { callbacks } from '../../../lib/callbacks'; import { sleep } from '../../../lib/utils/sleep'; +import { replace } from '../../../lib/utils/stringUtils'; 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() { @@ -31,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() { @@ -57,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, _room: IRoom) { + 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 @@ -73,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; @@ -87,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); @@ -101,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; @@ -116,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); @@ -130,8 +158,8 @@ export default class RocketAdapter { } } - async onMessage(rocketMessage) { - for await (const slack of this.slackAdapters) { + async onMessage(rocketMessage: IMessage, _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 @@ -139,7 +167,7 @@ export default class RocketAdapter { } rocketLogger.debug('onRocketMessage', rocketMessage); - if (rocketMessage.editedAt) { + if (isEditedMessage(rocketMessage)) { // This is an Edit Event await this.processMessageChanged(rocketMessage, slack); continue; @@ -150,7 +178,7 @@ export default class RocketAdapter { } if (rocketMessage.file) { - await this.processFileShare(rocketMessage, slack); + await this.processFileShare(rocketMessage as RequiredField, slack); continue; } @@ -164,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); @@ -178,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; } @@ -191,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) { @@ -212,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: IMessage, 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: SlackConversation) { 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); } } @@ -255,20 +303,20 @@ export default class RocketAdapter { return rocketUsers; } - async getRocketUserCreator(slackChannel) { + async getRocketUserCreator(slackChannel: SlackConversation) { 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('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: SlackConversation & { rocketId?: string } = await slack.slackAPI.getRoomInfo(slackChannelID); if (slackChannel) { const members = await slack.slackAPI.getMembers(slackChannelID); if (!members) { @@ -279,8 +327,8 @@ export default class RocketAdapter { const rocketRoom = await Rooms.findOneByName(slackChannel.name); 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]); } else { const rocketUsers = await this.getRocketUsers(members, slackChannel); const rocketUserCreator = await this.getRocketUserCreator(slackChannel); @@ -294,7 +342,7 @@ export default class RocketAdapter { const isPrivate = slackChannel.is_private; const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator, rocketUsers); slackChannel.rocketId = rocketChannel.rid; - } catch (e) { + } catch (e: any) { if (!hasRetried) { rocketLogger.debug('Error adding channel from Slack. Will retry in 1s.', e.message); // If first time trying to create channel fails, could be because of multiple messages received at the same time. Try again once after 1s. @@ -304,24 +352,24 @@ export default class RocketAdapter { rocketLogger.error(e); } - const roomUpdate = { + const roomUpdate: Record = { 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.purpose && slackChannel.purpose.value && slackChannel.purpose.last_set > lastSetTopic) { + if (slackChannel.purpose?.value && slackChannel.purpose.last_set > 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]); + slack.addSlackChannel(slackChannel.rocketId as string, slackChannelID); } - addedRoom = await Rooms.findOneById(slackChannel.rocketId); + addedRoom = await Rooms.findOneById(slackChannel.rocketId as string); } } @@ -331,7 +379,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] = { @@ -342,19 +390,19 @@ export default class RocketAdapter { return rocketUser; } - async addUser(slackUserID) { + async addUser(slackUserID: string): Promise { rocketLogger.debug('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 = @@ -367,7 +415,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, }; @@ -381,12 +429,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; } @@ -409,14 +457,15 @@ export default class RocketAdapter { } if (url) { try { - await setUserAvatar(user, url, null, 'url'); - } catch (error) { + // 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 (error: any) { rocketLogger.debug('Error setting user avatar', error.message); } } } - const importIds = [rocketUserData.id]; + const importIds = [rocketUserData.id as string]; if (isBot && rocketUserData.profile && rocketUserData.profile.bot_id) { importIds.push(rocketUserData.profile.bot_id); } @@ -435,10 +484,10 @@ export default class RocketAdapter { rocketLogger.debug('User not added'); } - return addedUser; + return addedUser || null; } - addAliasToMsg(rocketUserName, rocketMsgObj) { + addAliasToMsg(rocketUserName: string, rocketMsgObj: Partial) { const aliasFormat = settings.get('SlackBridge_AliasFormat'); if (aliasFormat) { const alias = this.util.format(aliasFormat, rocketUserName); @@ -451,95 +500,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; - } - } - if (slackMessage.subtype === 'bot_message') { - rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); - } + async buildMessageObjectFor( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + slackMessage: SlackMessageEvent, + isImporting: boolean, + slack: ISlackAdapter, + ): Promise { + if (slackMessage.subtype) { + return slack.processSubtypedMessage(rocketChannel, rocketUser, slackMessage, isImporting); + } - 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); - } + const rocketMsgObj = { + msg: await this.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), + rid: rocketChannel._id, + u: { + _id: rocketUser._id, + username: rocketUser.username, + }, + }; + + 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: RocketChatUserIdentification, + 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.js b/apps/meteor/app/slackbridge/server/SlackAPI.ts similarity index 90% rename from apps/meteor/app/slackbridge/server/SlackAPI.js rename to apps/meteor/app/slackbridge/server/SlackAPI.ts index 540aa3b911605..b1f5539cc9c24 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.js +++ b/apps/meteor/app/slackbridge/server/SlackAPI.ts @@ -1,7 +1,17 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -export class SlackAPI { - constructor(apiOrBotToken) { +import type { + ISlackAPI, + SlackPostMessage, + SlackPostMessageResponse, + SlackUpdateMessage, + SlackUpdateMessageResponse, +} from './definition/ISlackAPI'; + +export class SlackAPI implements ISlackAPI { + private token: string; + + constructor(apiOrBotToken: string) { this.token = apiOrBotToken; } @@ -22,7 +32,7 @@ export class SlackAPI { if (response && 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); } @@ -48,7 +58,7 @@ export class SlackAPI { if (response && 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); } @@ -92,7 +102,7 @@ export class SlackAPI { const response = await request.json(); if (response && 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; } @@ -137,7 +147,7 @@ export class SlackAPI { return response && request.status === 200 && response && request.ok; } - async sendMessage(data) { + async sendMessage(data: SlackPostMessage): Promise { const request = await fetch('https://slack.com/api/chat.postMessage', { headers: { Authorization: `Bearer ${this.token}`, @@ -148,7 +158,7 @@ export class SlackAPI { return request.json(); } - async updateMessage(data) { + async updateMessage(data: SlackUpdateMessage): Promise { const request = await fetch('https://slack.com/api/chat.update', { headers: { Authorization: `Bearer ${this.token}`, diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.js b/apps/meteor/app/slackbridge/server/SlackAdapter.ts similarity index 57% rename from apps/meteor/app/slackbridge/server/SlackAdapter.js rename to apps/meteor/app/slackbridge/server/SlackAdapter.ts index 46a5ab6d35b5e..55f9054b03100 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.js +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -3,12 +3,41 @@ import https from 'https'; import url from 'url'; import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IRoom } 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 { Meteor } from 'meteor/meteor'; -import { SlackAPI } from './SlackAPI'; +import type { IRocketChatAdapter, RocketChatUserIdentification } from './definition/IRocketChatAdapter'; +import type { ISlackAPI, SlackPostMessage, SlackUpdateMessage } from './definition/ISlackAPI'; +import type { ISlackAdapter, SlackAppCredentials, SlackChannel, SlackTS } from './definition/ISlackAdapter'; +import type { ISlackbridge } from './definition/ISlackbridge'; +import type { RocketChatMessageData } from './definition/RocketChatMessageData'; +import type { + 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'; @@ -23,26 +52,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('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('Connected to Slack'); @@ -50,466 +87,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(); + // Using "void" because the JS code didn't have anything + void 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'); - } - 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(); + abstract connectApp(appCredential: SlackAppCredentials): Promise; - 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(); - - 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('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((error) => { - slackLogger.error({ msg: 'Error on SlackApp', error }); - }); - } - - /** - * @deprecated - */ - registerForEventsLegacy() { - slackLogger.debug('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(); - }); - - /** - * 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] - * } - **/ - 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('OnSlackEvent-CHANNEL_LEFT: ', channelLeftMsg); - if (channelLeftMsg) { - try { - this.onChannelLeft(channelLeftMsg); - } catch (err) { - slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); - } - } - }); + abstract disconnect(): Promise; - /** - * 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; @@ -526,14 +135,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 { @@ -552,14 +161,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; } @@ -578,10 +187,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 } } @@ -593,7 +200,7 @@ export default class SlackAdapter { } } - onChannelLeft(channelLeftMsg) { + onChannelLeft(channelLeftMsg: ChannelLeftEvent) { this.removeSlackChannel(channelLeftMsg.channel); } @@ -601,8 +208,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; @@ -628,7 +235,7 @@ export default class SlackAdapter { } } - async postFindChannel(rocketChannelName) { + async postFindChannel(rocketChannelName: string) { slackLogger.debug('Searching for Slack channel or group', rocketChannelName); const channels = await this.slackAPI.getChannels(); if (channels && channels.length > 0) { @@ -654,7 +261,7 @@ 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-'); @@ -677,7 +284,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('Added channel', { rocketChID, slackChID }); @@ -688,13 +295,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; @@ -702,7 +308,7 @@ export default class SlackAdapter { } } - getSlackChannel(rocketChID) { + getSlackChannel(rocketChID: string) { return this.slackChannelRocketBotMembershipMap.get(rocketChID); } @@ -713,11 +319,11 @@ export default class SlackAdapter { } for await (const slackChannel of channels) { - const rocketchat_room = + const rcRoom = (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 (rcRoom && slackChannel.is_member) { + this.addSlackChannel(rcRoom._id, slackChannel.id); } } } @@ -729,11 +335,11 @@ export default class SlackAdapter { } for await (const slackGroup of groups) { - const rocketchat_room = + const rcRoom = (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 && slackGroup.is_member) { + this.addSlackChannel(rcRoom._id, slackGroup.id); } } } @@ -782,38 +388,41 @@ 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 data = { + ts: this.getTimeStamp(rocketMessage), + channel: slackChannel.id, + as_user: true, + }; + + slackLogger.debug('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; } @@ -831,71 +440,80 @@ 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('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('Post Message To Slack', data); - const postResult = await this.slackAPI.sendMessage(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); + } - if (!this.slackBotId && this.rocket.slackAdapters && this.rocket.slackAdapters.length >= 2) { - this.removeMessageBeingSent(data); - } + const postResult = await this.slackAPI.sendMessage(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(`RocketMsgID=${rocketMessage._id} SlackMsgID=${postResult.message.ts} SlackBotID=${postResult.message.bot_id}`); - } + if (!this.slackBotId && this.rocket.slackAdapters?.length >= 2) { + this.removeMessageBeingSent(data); + } + + 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(`RocketMsgID=${rocketMessage._id} SlackMsgID=${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('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: SlackUpdateMessage = { + ts: this.getTimeStamp(rocketMessage) as SlackTS, + channel: slackChannel.id, + text: rocketMessage.msg, + as_user: true, + }; + slackLogger.debug('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('Member join channel', event.channel); const rocketCh = await this.rocket.getChannel({ channel: event.channel }); if (rocketCh != null) { @@ -907,7 +525,7 @@ export default class SlackAdapter { } } - async processChannelJoin(slackMessage) { + async processChannelJoin(slackMessage: ChannelJoinMessageEvent) { slackLogger.debug('Channel join', slackMessage.channel.id); const rocketCh = await this.rocket.addChannel(slackMessage.channel); if (rocketCh != null) { @@ -915,41 +533,47 @@ 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 (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); } /* 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 } }); @@ -978,7 +602,7 @@ export default class SlackAdapter { /* https://api.slack.com/events/message/message_changed */ - async processMessageChanged(slackMessage) { + async processMessageChanged(slackMessage: MessageChangedEvent) { if (slackMessage.previous_message) { const currentMsg = await Messages.findOneById(this.rocket.createRocketID(slackMessage.channel, slackMessage.message.ts)); @@ -1007,7 +631,10 @@ export default class SlackAdapter { /* This method will get refactored and broken down into single responsibilities */ - async processNewMessage(slackMessage, isImporting) { + async processNewMessage( + slackMessage: Exclude, + isImporting?: boolean, + ) { const rocketChannel = await this.rocket.getChannel(slackMessage); let rocketUser = null; if (slackMessage.subtype === 'bot_message') { @@ -1039,8 +666,8 @@ export default class SlackAdapter { } } - async processBotMessage(rocketChannel, slackMessage) { - const excludeBotNames = settings.get('SlackBridge_ExcludeBotnames'); + async processBotMessage(rocketChannel: IRoom, slackMessage: BotMessageEvent): Promise { + const excludeBotNames = settings.get('SlackBridge_ExcludeBotnames'); if (slackMessage.username !== undefined && excludeBotNames && slackMessage.username.match(excludeBotNames)) { return; } @@ -1070,13 +697,18 @@ export default class SlackAdapter { return rocketMsgObj; } - async processMeMessage(rocketUser, slackMessage) { + async processMeMessage(rocketUser: RocketChatUserIdentification, 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: RocketChatUserIdentification, + 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), @@ -1087,23 +719,35 @@ 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: RocketChatUserIdentification, + slackMessage: ChannelJoinMessageEvent | GroupJoinMessageEvent, + isImporting: boolean, + ): Promise { + if (!slackMessage.inviter) { + return; + } + + 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 processLeaveMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processLeaveMessage( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + 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), @@ -1114,7 +758,12 @@ export default class SlackAdapter { } } - async processTopicMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processTopicMessage( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + 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), @@ -1125,7 +774,12 @@ export default class SlackAdapter { } } - async processPurposeMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processPurposeMessage( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + 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), @@ -1136,7 +790,12 @@ export default class SlackAdapter { } } - async processNameMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + async processNameMessage( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + 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), @@ -1147,8 +806,13 @@ export default class SlackAdapter { } } - async processShareMessage(rocketChannel, rocketUser, slackMessage, isImporting) { - if (slackMessage.file && slackMessage.file.url_private_download !== undefined) { + async processShareMessage( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + slackMessage: FileShareMessageEvent, + isImporting: boolean, + ): Promise { + if (slackMessage.file?.url_private_download !== undefined) { const details = { message_id: this.createSlackMessageId(slackMessage.ts), name: slackMessage.file.name, @@ -1167,41 +831,55 @@ export default class SlackAdapter { } } - 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: RocketChatUserIdentification, + 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), + 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); - 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); - } + if (!isImporting && slackMessage.attachments[0].channel_id && slackMessage.attachments[0].ts) { + const messageId = this.createSlackMessageId(slackMessage.attachments[0].ts, slackMessage.attachments[0].channel_id); + 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: RocketChatUserIdentification, + slackMessage: SlackMessageEvent, + isImporting: boolean, + ): Promise { switch (slackMessage.subtype) { case 'bot_message': return this.processBotMessage(rocketChannel, slackMessage); 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/IRocketChatAdapter.ts b/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts new file mode 100644 index 0000000000000..a7b5025b90e9c --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts @@ -0,0 +1,26 @@ +import type { IMessage, IRegisterUser, IRoom, IUser } from '@rocket.chat/core-typings'; + +import type { ISlackAdapter, SlackTS } from './ISlackAdapter'; + +export type RocketChatUserIdentification = Pick; + +export interface IRocketChatAdapter { + slackAdapters: ISlackAdapter[]; + + connect(): void; + disconnect(): void; + + clearSlackAdapters(): void; + addSlack(slack: ISlackAdapter): void; + getChannel(slackMessage: { channel?: string }): Promise; + getUser(slackUser: string): Promise; + createAndSaveMessage( + rocketChannel: IRoom, + rocketUser: RocketChatUserIdentification, + slackMessage: unknown, + rocketMsgDataDefaults: Partial, + isImporting: boolean, + slack: ISlackAdapter, + ): Promise; + createRocketID(slackChannel: string, ts: SlackTS): string; +} 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..bf0600b4058ae --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts @@ -0,0 +1,151 @@ +import type { MessageEvent } from '@slack/types'; + +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 = { + // Token is mandatory, but may be passed on the header instead of the params + token?: string; + + channel: string; + // JSON serialized array + attachments?: string; + // JSON serialized array + blocks?: string; + text?: string; + + as_user?: boolean; + icon_emoji?: string; + icon_url?: string; + link_names?: boolean; + markdown_text?: string; + metadata?: string; + mrkdwn?: boolean; + parse?: string; + reply_broadcast?: boolean; + thread_ts?: string; + unfurl_links?: boolean; + unfurl_media?: boolean; + username?: string; +} & ({ attachments: string } | { blocks: string } | { text: string }); + +export type SlackMessageResponse = + | { + ok: false; + error: string; + } + | ({ + ok: true; + } & SuccessType); + +export type SlackPostMessageResponse = SlackMessageResponse<{ + channel: string; + ts: string; + message: MessageEvent; +}>; + +export type SlackUpdateMessage = Pick< + SlackPostMessage, + | 'token' + | 'channel' + | 'attachments' + | 'blocks' + | 'text' + | 'as_user' + | 'link_names' + | 'markdown_text' + | 'metadata' + | 'parse' + | 'reply_broadcast' +> & { + ts: string; + // accepts an array of strings or a CSV + file_ids?: string[] | string; +} & ({ attachments: string } | { blocks: string } | { text: string }); + +export type SlackUpdateMessageResponse = SlackMessageResponse<{ + channel: string; + ts: string; + text: string; + message: Partial; +}>; + +export interface ISlackAPI { + getRoomInfo(roomId: string): Promise; + getMembers(channelId: string): Promise; + getUser(userId: string): Promise; + sendMessage(data: SlackPostMessage): Promise; + updateMessage(data: SlackUpdateMessage): Promise; +} 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..7dcb71c9ea3ba --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts @@ -0,0 +1,51 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; + +import type { IRocketChatAdapter } 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 type SlackTS = string; + +export interface ISlackAdapter { + slackAPI: ISlackAPI; + + connect(params: { apiToken?: string; appCredential?: SlackAppCredentials }): Promise; + connectApp(appCredential: SlackAppCredentials): Promise; + connectLegacy(apiToken: string): Promise; + disconnect(): Promise; + + setRocketAdapter(adapter: IRocketChatAdapter): void; + + registerForEvents(): void; + + registerForEventsLegacy(): 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: IUser, + 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..35c297312b1a6 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts @@ -0,0 +1,9 @@ +export interface ISlackbridge { + isReactionsEnabled: boolean; + + reactionsMap: Map; + + 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.js b/apps/meteor/app/slackbridge/server/slackbridge.ts similarity index 69% rename from apps/meteor/app/slackbridge/server/slackbridge.js rename to apps/meteor/app/slackbridge/server/slackbridge.ts index 89ff66a13397e..a96cf6e850e57 100644 --- a/apps/meteor/app/slackbridge/server/slackbridge.js +++ b/apps/meteor/app/slackbridge/server/slackbridge.ts @@ -1,29 +1,62 @@ import { debounce } from 'lodash'; -import RocketAdapter from './RocketAdapter.js'; -import SlackAdapter from './SlackAdapter.js'; +import RocketAdapter from './RocketAdapter'; +import SlackAdapter from './SlackAdapter'; +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; + } + 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.apiTokens = ''; + this.botTokens = ''; + this.appTokens = ''; + this.signingSecrets = ''; this.aliasFormat = ''; this.excludeBotnames = ''; this.isReactionsEnabled = true; @@ -40,8 +73,8 @@ class SlackBridgeClass { const tokenList = this.apiTokens.split('\n'); tokenList.forEach((apiToken) => { - const slack = new SlackAdapter(this); - slack.setRocket(this.rocket); + const slack: ISlackAdapter = new SlackAdapter(this); + slack.setRocketAdapter(this.rocket); this.rocket.addSlack(slack); this.slackAdapters.push(slack); @@ -58,15 +91,15 @@ 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 SlackAdapter(this); + slack.setRocketAdapter(this.rocket); this.rocket.addSlack(slack); this.slackAdapters.push(slack); @@ -95,14 +128,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; @@ -115,7 +149,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(); @@ -124,7 +158,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(); @@ -132,7 +166,7 @@ class SlackBridgeClass { classLogger.debug('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(); @@ -140,7 +174,7 @@ class SlackBridgeClass { classLogger.debug('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(); @@ -149,7 +183,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(); @@ -159,31 +193,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) => { + settings.watch('SlackBridge_AliasFormat', (value) => { this.aliasFormat = value; classLogger.debug('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) => { + settings.watch('SlackBridge_ExcludeBotnames', (value) => { this.excludeBotnames = value; classLogger.debug('Setting: SlackBridge_ExcludeBotnames', value); }); // Reactions - settings.watch('SlackBridge_Reactions_Enabled', (value) => { + settings.watch('SlackBridge_Reactions_Enabled', (value) => { this.isReactionsEnabled = value; classLogger.debug('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('Setting: SlackBridge_Enabled', value); 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/model-typings/src/models/IMessagesModel.ts b/packages/model-typings/src/models/IMessagesModel.ts index c5957a113ee3c..b7b6188fad816 100644 --- a/packages/model-typings/src/models/IMessagesModel.ts +++ b/packages/model-typings/src/models/IMessagesModel.ts @@ -204,7 +204,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; @@ -213,7 +213,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>; @@ -238,7 +238,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 305556d777187..8ecd49441982f 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -1043,7 +1043,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, @@ -1052,7 +1052,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); @@ -1311,7 +1311,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 410bd711d24a4..91623de2b209c 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -8,3 +8,4 @@ export * from './timezone'; export * from './wrapExceptions'; export * from './getLoginExpiration'; export * from './converter'; +export * from './promiseTimeout'; 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); + }); +} From df67c1ca9ca70609690cef7e4fc1c0c13705c238 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 18 Feb 2025 17:01:18 -0300 Subject: [PATCH 2/4] some more work --- .../app/slackbridge/server/RocketAdapter.ts | 23 +- .../meteor/app/slackbridge/server/SlackAPI.ts | 84 +++--- .../app/slackbridge/server/SlackAdapter.ts | 239 +++++++++++------- .../definition/IMessageSyncedWithSlack.ts | 11 + .../server/definition/IRocketChatAdapter.ts | 14 +- .../server/definition/ISlackAPI.ts | 158 +++++++----- .../server/definition/ISlackAdapter.ts | 11 +- .../core-typings/src/IMessage/IMessage.ts | 2 +- 8 files changed, 321 insertions(+), 221 deletions(-) create mode 100644 apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.ts b/apps/meteor/app/slackbridge/server/RocketAdapter.ts index 21d83ed20b8fa..fb2be88fafacd 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.ts +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -1,6 +1,6 @@ import util from 'util'; -import type { DeepWritable, IMessage, IRoom, IUser, MessageAttachment, RequiredField } from '@rocket.chat/core-typings'; +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'; @@ -11,9 +11,10 @@ import { Meteor } from 'meteor/meteor'; import type { MatchKeysAndValues } from 'mongodb'; import _ from 'underscore'; +import type { SlackTS } from './definition/IMessageSyncedWithSlack'; import type { IRocketChatAdapter, RocketChatUserIdentification } from './definition/IRocketChatAdapter'; import type { SlackConversation } from './definition/ISlackAPI'; -import type { ISlackAdapter, SlackTS } from './definition/ISlackAdapter'; +import type { ISlackAdapter } from './definition/ISlackAdapter'; import type { ISlackbridge } from './definition/ISlackbridge'; import type { RocketChatMessageData } from './definition/RocketChatMessageData'; import type { SlackMessageEvent } from './definition/SlackMessageEvent'; @@ -269,7 +270,7 @@ export default class RocketAdapter implements IRocketChatAdapter { return this.addChannel(slackMessage.channel); } - async getUser(slackUser: string): Promise { + async getUser(slackUser: string): Promise { if (!slackUser) { return null; } @@ -379,7 +380,7 @@ export default class RocketAdapter implements IRocketChatAdapter { return addedRoom; } - async findUser(slackUserID: string): Promise { + async findUser(slackUserID: string): Promise { const rocketUser = await Users.findOneByImportId(slackUserID); if (rocketUser && !this.userTags[slackUserID]) { this.userTags[slackUserID] = { @@ -387,10 +388,10 @@ export default class RocketAdapter implements IRocketChatAdapter { rocket: `@${rocketUser.username}`, }; } - return rocketUser; + return rocketUser as IRegisterUser | null; } - async addUser(slackUserID: string): Promise { + async addUser(slackUserID: string): Promise { rocketLogger.debug('Adding Rocket.Chat user from Slack', slackUserID); let addedUser; for await (const slack of this._slackAdapters) { @@ -476,7 +477,7 @@ export default class RocketAdapter implements IRocketChatAdapter { rocket: `@${rocketUserData.name}`, }; } - addedUser = await Users.findOneById(rocketUserData.rocketId); + addedUser = await Users.findOneById(rocketUserData.rocketId); } } @@ -487,7 +488,7 @@ export default class RocketAdapter implements IRocketChatAdapter { return addedUser || null; } - addAliasToMsg(rocketUserName: string, rocketMsgObj: Partial) { + addAliasToMsg(rocketUserName: string, rocketMsgObj: Partial): Partial { const aliasFormat = settings.get('SlackBridge_AliasFormat'); if (aliasFormat) { const alias = this.util.format(aliasFormat, rocketUserName); @@ -502,11 +503,11 @@ export default class RocketAdapter implements IRocketChatAdapter { async buildMessageObjectFor( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: SlackMessageEvent, isImporting: boolean, slack: ISlackAdapter, - ): Promise { + ): Promise { if (slackMessage.subtype) { return slack.processSubtypedMessage(rocketChannel, rocketUser, slackMessage, isImporting); } @@ -556,7 +557,7 @@ export default class RocketAdapter implements IRocketChatAdapter { async createAndSaveMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: MessageEvent, rocketMsgDataDefaults: Partial, isImporting: boolean, diff --git a/apps/meteor/app/slackbridge/server/SlackAPI.ts b/apps/meteor/app/slackbridge/server/SlackAPI.ts index b1f5539cc9c24..251a242c03860 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.ts +++ b/apps/meteor/app/slackbridge/server/SlackAPI.ts @@ -1,12 +1,21 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; - import type { - ISlackAPI, - SlackPostMessage, - SlackPostMessageResponse, - SlackUpdateMessage, - SlackUpdateMessageResponse, -} from './definition/ISlackAPI'; + ChatDeleteArguments, + ChatPostMessageArguments, + ChatPostMessageResponse, + ChatUpdateArguments, + ConversationsHistoryArguments, + ConversationsHistoryResponse, + ConversationsInfoResponse, + ConversationsListResponse, + ConversationsMembersResponse, + PinsListResponse, + ReactionsAddArguments, + ReactionsRemoveArguments, + UsersInfoResponse, +} from '@slack/web-api'; + +import type { ISlackAPI } from './definition/ISlackAPI'; export class SlackAPI implements ISlackAPI { private token: string; @@ -15,8 +24,8 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -28,7 +37,7 @@ export class SlackAPI implements ISlackAPI { ...(cursor && { cursor }), }, }); - const response = await request.json(); + const response: ConversationsListResponse = await request.json(); if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { channels = channels.concat(response.channels); @@ -41,8 +50,8 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -54,7 +63,7 @@ export class SlackAPI implements ISlackAPI { ...(cursor && { cursor }), }, }); - const response = await request.json(); + const response: ConversationsListResponse = await request.json(); if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { groups = groups.concat(response.channels); @@ -67,7 +76,7 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -77,14 +86,19 @@ export class SlackAPI implements ISlackAPI { 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 @@ -111,7 +125,7 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -120,10 +134,10 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -132,10 +146,10 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -144,10 +158,10 @@ export class SlackAPI implements ISlackAPI { 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: SlackPostMessage): Promise { + async sendMessage(data: ChatPostMessageArguments): Promise { const request = await fetch('https://slack.com/api/chat.postMessage', { headers: { Authorization: `Bearer ${this.token}`, @@ -158,7 +172,7 @@ export class SlackAPI implements ISlackAPI { return request.json(); } - async updateMessage(data: SlackUpdateMessage): Promise { + async updateMessage(data: ChatUpdateArguments): Promise { const request = await fetch('https://slack.com/api/chat.update', { headers: { Authorization: `Bearer ${this.token}`, @@ -167,10 +181,10 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -181,7 +195,7 @@ export class SlackAPI implements ISlackAPI { 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}`, @@ -191,10 +205,10 @@ export class SlackAPI implements ISlackAPI { }, }); const response = await request.json(); - return response && response && request.status === 200 && request.ok && response.items; + return (response && 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}`, @@ -204,10 +218,10 @@ export class SlackAPI implements ISlackAPI { }, }); const response = await request.json(); - return response && response && request.status === 200 && request.ok && response.user; + return (response && response && request.status === 200 && request.ok && response.user) || undefined; } - static async verifyToken(token) { + static async verifyToken(token: string): Promise { const request = await fetch('https://slack.com/api/auth.test', { headers: { Authorization: `Bearer ${token}`, @@ -218,7 +232,7 @@ export class SlackAPI implements ISlackAPI { return response && 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}`, diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.ts b/apps/meteor/app/slackbridge/server/SlackAdapter.ts index 55f9054b03100..bc0576f3182fd 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -3,7 +3,7 @@ import https from 'https'; import url from 'url'; import { Message } from '@rocket.chat/core-services'; -import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { IMessage, IRegisterUser, IRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, ReadReceipts } from '@rocket.chat/models'; import type { BotMessageEvent, @@ -22,11 +22,14 @@ import type { ReactionRemovedEvent, MemberJoinedChannelEvent, } from '@slack/types'; +import type { ChatUpdateArguments, ConversationsListResponse } from '@slack/web-api'; import { Meteor } from 'meteor/meteor'; +import type { IMessageSyncedWithSlack, SlackTS } from './definition/IMessageSyncedWithSlack'; +import { isMessageImportedFromSlack } from './definition/IMessageSyncedWithSlack'; import type { IRocketChatAdapter, RocketChatUserIdentification } from './definition/IRocketChatAdapter'; -import type { ISlackAPI, SlackPostMessage, SlackUpdateMessage } from './definition/ISlackAPI'; -import type { ISlackAdapter, SlackAppCredentials, SlackChannel, SlackTS } from './definition/ISlackAdapter'; +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 { @@ -263,20 +266,16 @@ export default abstract class SlackAdapter implements ISlackAdapter { */ 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; } /** @@ -312,48 +311,37 @@ export default abstract class SlackAdapter implements ISlackAdapter { 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 rcRoom = - (await Rooms.findOneByName(slackChannel.name, { projection: { _id: 1 } })) || - (await Rooms.findOneByImportId(slackChannel.id, { projection: { _id: 1 } })); - if (rcRoom && slackChannel.is_member) { - this.addSlackChannel(rcRoom._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; - } - for await (const slackGroup of groups) { const rcRoom = - (await Rooms.findOneByName(slackGroup.name, { projection: { _id: 1 } })) || - (await Rooms.findOneByImportId(slackGroup.id, { projection: { _id: 1 } })); - if (rcRoom && slackGroup.is_member) { - this.addSlackChannel(rcRoom._id, slackGroup.id); + (slackChannel.name && (await Rooms.findOneByName(slackChannel.name, { projection: { _id: 1 } }))) || + (slackChannel.id && (await Rooms.findOneByImportId(slackChannel.id, { projection: { _id: 1 } }))) || + undefined; + + 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, @@ -372,7 +360,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { /* 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, @@ -398,8 +386,14 @@ export default abstract class SlackAdapter implements ISlackAdapter { return; } + 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: this.getTimeStamp(rocketMessage), + ts, channel: slackChannel.id, as_user: true, }; @@ -462,7 +456,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { }; if (rocketMessage.tmid) { - const tmessage = await Messages.findOneById(rocketMessage.tmid); + const tmessage = await Messages.findOneById(rocketMessage.tmid); if (tmessage?.slackTs) { data.thread_ts = tmessage.slackTs; } @@ -500,7 +494,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { return; } - const data: SlackUpdateMessage = { + const data: ChatUpdateArguments = { ts: this.getTimeStamp(rocketMessage) as SlackTS, channel: slackChannel.id, text: rocketMessage.msg, @@ -520,13 +514,13 @@ export default abstract class SlackAdapter implements ISlackAdapter { 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: ChannelJoinMessageEvent) { - slackLogger.debug('Channel join', slackMessage.channel.id); + slackLogger.debug('Channel join', slackMessage.channel); const rocketCh = await this.rocket.addChannel(slackMessage.channel); if (rocketCh != null) { this.addSlackChannel(rocketCh._id, slackMessage.channel); @@ -549,6 +543,10 @@ export default abstract class SlackAdapter implements ISlackAdapter { } const rocketUser = await this.rocket.getUser(slackMessage.user); + if (!rocketUser) { + slackLogger.debug('Unable to processFileShare: RC user not found.'); + return; + } // Hack to notify that a file was attempted to be uploaded delete slackMessage.subtype; @@ -567,7 +565,14 @@ export default abstract class SlackAdapter implements ISlackAdapter { updatedBySlack: true, }; - await this.rocket.createAndSaveMessage(rocketChannel, rocketUser, slackMessage, msgDataDefaults, false, this); + await this.rocket.createAndSaveMessage( + rocketChannel, + rocketUser, + slackMessage, + msgDataDefaults, + false, + this as unknown as ISlackAdapter, + ); } /* @@ -579,11 +584,9 @@ export default abstract class SlackAdapter implements ISlackAdapter { 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 @@ -603,29 +606,65 @@ export default abstract class SlackAdapter implements ISlackAdapter { https://api.slack.com/events/message/message_changed */ async processMessageChanged(slackMessage: MessageChangedEvent) { - 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 - }; - - await updateMessage(rocketMsgObj, rocketUser); - slackLogger.debug('Rocket message updated by Slack'); - } + if (!slackMessage.previous_message) { + return; + } + + 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); } /* @@ -635,17 +674,11 @@ export default abstract class SlackAdapter implements ISlackAdapter { slackMessage: Exclude, isImporting?: boolean, ) { - 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; - } + 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), }; @@ -653,8 +686,15 @@ export default abstract class SlackAdapter implements ISlackAdapter { 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) { @@ -678,18 +718,18 @@ export default abstract class SlackAdapter implements ISlackAdapter { } } 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; @@ -697,7 +737,10 @@ export default abstract class SlackAdapter implements ISlackAdapter { return rocketMsgObj; } - async processMeMessage(rocketUser: RocketChatUserIdentification, slackMessage: MeMessageEvent): Promise { + async processMeMessage( + rocketUser: RocketChatUserIdentification, + slackMessage: MeMessageEvent, + ): Promise { return this.rocket.addAliasToMsg(rocketUser.username, { msg: `_${await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text)}_`, }); @@ -708,7 +751,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { rocketUser: RocketChatUserIdentification, slackMessage: ChannelJoinMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { if (isImporting) { await Message.saveSystemMessage('uj', rocketChannel._id, rocketUser.username, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -724,7 +767,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { rocketUser: RocketChatUserIdentification, slackMessage: ChannelJoinMessageEvent | GroupJoinMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { if (!slackMessage.inviter) { return; } @@ -747,7 +790,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { rocketUser: RocketChatUserIdentification, slackMessage: ChannelLeaveMessageEvent | GroupLeaveMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { if (isImporting) { await Message.saveSystemMessage('ul', rocketChannel._id, rocketUser.username, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -763,7 +806,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { rocketUser: RocketChatUserIdentification, slackMessage: ChannelTopicMessageEvent | GroupTopicMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { if (isImporting) { await Message.saveSystemMessage('room_changed_topic', rocketChannel._id, slackMessage.topic, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -779,7 +822,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { rocketUser: RocketChatUserIdentification, slackMessage: ChannelPurposeMessageEvent | GroupPurposeMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { if (isImporting) { await Message.saveSystemMessage('room_changed_topic', rocketChannel._id, slackMessage.purpose, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -795,7 +838,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { rocketUser: RocketChatUserIdentification, slackMessage: ChannelNameMessageEvent | GroupNameMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { if (isImporting) { await Message.saveSystemMessage('r', rocketChannel._id, slackMessage.name, rocketUser, { ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), @@ -879,7 +922,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { rocketUser: RocketChatUserIdentification, slackMessage: SlackMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { switch (slackMessage.subtype) { case 'bot_message': return this.processBotMessage(rocketChannel, slackMessage); @@ -956,7 +999,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { attachment.image_url = url; attachment.image_type = file.type; attachment.image_size = file.size; - attachment.image_dimensions = file.identify && file.identify.size; + attachment.image_dimensions = file.identify?.size; } if (/^audio\/.+/.test(file.type)) { attachment.audio_url = url; @@ -1090,8 +1133,8 @@ export default abstract class SlackAdapter implements ISlackAdapter { async importMessages(rid, callback) { slackLogger.info('importMessages: ', rid); - const rocketchat_room = await Rooms.findOneById(rid); - if (rocketchat_room) { + const rcRoom = await Rooms.findOneById(rid); + if (rcRoom) { if (this.getSlackChannel(rid)) { await this.copyChannelInfo(rid, this.getSlackChannel(rid)); @@ -1114,12 +1157,12 @@ export default abstract class SlackAdapter implements ISlackAdapter { return callback(); } - const slack_room = await this.postFindChannel(rocketchat_room.name); + const slack_room = await this.postFindChannel(rcRoom.name); if (slack_room) { this.addSlackChannel(rid, slack_room.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/definition/IMessageSyncedWithSlack.ts b/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts new file mode 100644 index 0000000000000..e138401418451 --- /dev/null +++ b/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts @@ -0,0 +1,11 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +export type SlackTS = string; + +export type IMessageSyncedWithSlack = IMessage & { slackTs?: SlackTS }; + +export type IMessageImportedFromSlack = IMessage & { _id: `slack-${string}-${string}-${string}` }; + +export const isMessageSyncedWithSlack = (message: IMessage): message is IMessageSyncedWithSlack => 'slackTs' 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 index a7b5025b90e9c..55c452d0ea529 100644 --- a/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts +++ b/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts @@ -1,6 +1,7 @@ -import type { IMessage, IRegisterUser, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRegisterUser, IRoom } from '@rocket.chat/core-typings'; -import type { ISlackAdapter, SlackTS } from './ISlackAdapter'; +import type { SlackTS } from './IMessageSyncedWithSlack'; +import type { ISlackAdapter } from './ISlackAdapter'; export type RocketChatUserIdentification = Pick; @@ -12,15 +13,20 @@ export interface IRocketChatAdapter { clearSlackAdapters(): void; addSlack(slack: ISlackAdapter): void; + addUser(slackUserID: string): Promise; getChannel(slackMessage: { channel?: string }): Promise; - getUser(slackUser: string): Promise; + findUser(slackUserID: string): Promise; + getUser(slackUser: string): Promise; createAndSaveMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + 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 index bf0600b4058ae..a2eebbcab02f8 100644 --- a/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts @@ -1,4 +1,19 @@ -import type { MessageEvent } from '@slack/types'; +// import type { MessageEvent } from '@slack/types'; +import type { + ConversationsListResponse, + ConversationsInfoResponse, + ConversationsMembersResponse, + ChatPostMessageArguments, + ChatPostMessageResponse, + ChatUpdateArguments, + UsersInfoResponse, + ReactionsAddArguments, + ReactionsRemoveArguments, + ChatDeleteArguments, + ConversationsHistoryArguments, + ConversationsHistoryResponse, + PinsListResponse, +} from '@slack/web-api'; export type SlackConversation = { id: string; @@ -75,77 +90,88 @@ export type SlackUser = { enterprise_user: Record; }; -export type SlackPostMessage = { - // Token is mandatory, but may be passed on the header instead of the params - token?: string; +export type SlackPostMessage = ChatPostMessageArguments & { + username?: string; +}; - channel: string; - // JSON serialized array - attachments?: string; - // JSON serialized array - blocks?: string; - text?: string; +// export type SlackPostMessage = { +// // Token is mandatory, but may be passed on the header instead of the params +// token?: string; - as_user?: boolean; - icon_emoji?: string; - icon_url?: string; - link_names?: boolean; - markdown_text?: string; - metadata?: string; - mrkdwn?: boolean; - parse?: string; - reply_broadcast?: boolean; - thread_ts?: string; - unfurl_links?: boolean; - unfurl_media?: boolean; - username?: string; -} & ({ attachments: string } | { blocks: string } | { text: string }); +// channel: string; +// // JSON serialized array +// attachments?: string; +// // JSON serialized array +// blocks?: string; +// text?: string; + +// as_user?: boolean; +// icon_emoji?: string; +// icon_url?: string; +// link_names?: boolean; +// markdown_text?: string; +// metadata?: string; +// mrkdwn?: boolean; +// parse?: string; +// reply_broadcast?: boolean; +// thread_ts?: string; +// unfurl_links?: boolean; +// unfurl_media?: boolean; +// username?: string; +// } & ({ attachments: string } | { blocks: string } | { text: string }); -export type SlackMessageResponse = - | { - ok: false; - error: string; - } - | ({ - ok: true; - } & SuccessType); +// export type SlackMessageResponse = +// | { +// ok: false; +// error: string; +// } +// | ({ +// ok: true; +// } & SuccessType); -export type SlackPostMessageResponse = SlackMessageResponse<{ - channel: string; - ts: string; - message: MessageEvent; -}>; +// export type SlackPostMessageResponse = SlackMessageResponse<{ +// channel: string; +// ts: string; +// message: MessageEvent; +// }>; -export type SlackUpdateMessage = Pick< - SlackPostMessage, - | 'token' - | 'channel' - | 'attachments' - | 'blocks' - | 'text' - | 'as_user' - | 'link_names' - | 'markdown_text' - | 'metadata' - | 'parse' - | 'reply_broadcast' -> & { - ts: string; - // accepts an array of strings or a CSV - file_ids?: string[] | string; -} & ({ attachments: string } | { blocks: string } | { text: string }); +// export type SlackUpdateMessage = Pick< +// SlackPostMessage, +// | 'token' +// | 'channel' +// | 'attachments' +// | 'blocks' +// | 'text' +// | 'as_user' +// | 'link_names' +// | 'markdown_text' +// | 'metadata' +// | 'parse' +// | 'reply_broadcast' +// > & { +// ts: string; +// // accepts an array of strings or a CSV +// file_ids?: string[] | string; +// } & ({ attachments: string } | { blocks: string } | { text: string }); -export type SlackUpdateMessageResponse = SlackMessageResponse<{ - channel: string; - ts: string; - text: string; - message: Partial; -}>; +// export type SlackUpdateMessageResponse = SlackMessageResponse<{ +// channel: string; +// ts: string; +// text: string; +// message: Partial; +// }>; export interface ISlackAPI { - getRoomInfo(roomId: string): Promise; - getMembers(channelId: string): Promise; - getUser(userId: string): Promise; - sendMessage(data: SlackPostMessage): Promise; - updateMessage(data: SlackUpdateMessage): Promise; + 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; } diff --git a/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts b/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts index 7dcb71c9ea3ba..b94c103049cb1 100644 --- a/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts @@ -1,6 +1,7 @@ -import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; -import type { IRocketChatAdapter } from './IRocketChatAdapter'; +import type { SlackTS } from './IMessageSyncedWithSlack'; +import type { IRocketChatAdapter, RocketChatUserIdentification } from './IRocketChatAdapter'; import type { ISlackAPI } from './ISlackAPI'; import type { RocketChatMessageData } from './RocketChatMessageData'; import type { SlackMessageEvent } from './SlackMessageEvent'; @@ -16,8 +17,6 @@ export type SlackChannel = { family: 'channels' | 'groups'; }; -export type SlackTS = string; - export interface ISlackAdapter { slackAPI: ISlackAPI; @@ -44,8 +43,8 @@ export interface ISlackAdapter { processSubtypedMessage( rocketChannel: IRoom, - rocketUser: IUser, + rocketUser: RocketChatUserIdentification, slackMessage: SlackMessageEvent, isImporting: boolean, - ): Promise; + ): Promise; } diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index d5a2ee518d324..d6e9abd5ad62f 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -151,7 +151,7 @@ export interface IMessage extends IRocketChatRecord { md?: Root; _hidden?: boolean; - imported?: boolean; + imported?: boolean | 'slackbridge'; replies?: IUser['_id'][]; location?: { type: 'Point'; From 78e0f95ab89ddffc640034a31ed4cb1a15e89f64 Mon Sep 17 00:00:00 2001 From: Pierre Date: Thu, 20 Feb 2025 15:19:05 -0300 Subject: [PATCH 3/4] almost there? --- .../app/slackbridge/server/RocketAdapter.ts | 42 +- .../meteor/app/slackbridge/server/SlackAPI.ts | 29 +- .../app/slackbridge/server/SlackAdapter.ts | 366 ++++++++++-------- .../definition/IMessageSyncedWithSlack.ts | 5 +- .../server/definition/IRocketChatAdapter.ts | 3 + .../server/definition/ISlackAPI.ts | 69 +--- .../server/definition/ISlackAdapter.ts | 5 +- .../server/definition/ISlackbridge.ts | 2 + .../app/slackbridge/server/slackbridge.ts | 29 +- 9 files changed, 279 insertions(+), 271 deletions(-) diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.ts b/apps/meteor/app/slackbridge/server/RocketAdapter.ts index fb2be88fafacd..5516e2ee0237d 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.ts +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -11,9 +11,8 @@ import { Meteor } from 'meteor/meteor'; import type { MatchKeysAndValues } from 'mongodb'; import _ from 'underscore'; -import type { SlackTS } from './definition/IMessageSyncedWithSlack'; -import type { IRocketChatAdapter, RocketChatUserIdentification } from './definition/IRocketChatAdapter'; -import type { SlackConversation } from './definition/ISlackAPI'; +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'; @@ -159,7 +158,7 @@ export default class RocketAdapter implements IRocketChatAdapter { } } - async onMessage(rocketMessage: IMessage, _params: unknown) { + async onMessage(rocketMessage: IMessageSyncedWithSlack, _params: unknown) { for await (const slack of this._slackAdapters) { try { if (!slack.getSlackChannel(rocketMessage.rid)) { @@ -241,7 +240,7 @@ export default class RocketAdapter implements IRocketChatAdapter { } } - async processMessageChanged(rocketMessage: IMessage, slack: ISlackAdapter) { + async processMessageChanged(rocketMessage: IMessageSyncedWithSlack, slack: ISlackAdapter) { if (!rocketMessage) { return; } @@ -291,7 +290,7 @@ export default class RocketAdapter implements IRocketChatAdapter { return Rooms.findOneByImportId(slackChannelId); } - async getRocketUsers(members: string[], slackChannel: SlackConversation) { + async getRocketUsers(members: string[], slackChannel: SlackConversationSyncedWithRocketChat) { const rocketUsers = []; for await (const member of members) { if (member !== slackChannel.creator) { @@ -304,7 +303,7 @@ export default class RocketAdapter implements IRocketChatAdapter { return rocketUsers; } - async getRocketUserCreator(slackChannel: SlackConversation) { + async getRocketUserCreator(slackChannel: SlackConversationSyncedWithRocketChat) { return slackChannel.creator ? (await this.findUser(slackChannel.creator)) || this.addUser(slackChannel.creator) : null; } @@ -317,7 +316,7 @@ export default class RocketAdapter implements IRocketChatAdapter { continue; } - const slackChannel: SlackConversation & { rocketId?: string } = 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) { @@ -325,11 +324,18 @@ export default class RocketAdapter implements IRocketChatAdapter { 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 as IRoom)._id; - await Rooms.addImportIds(slackChannel.rocketId, [slackChannel.id]); + await Rooms.addImportIds(slackChannel.rocketId, [slackChannel.id || slackChannelID]); } else { const rocketUsers = await this.getRocketUsers(members, slackChannel); const rocketUserCreator = await this.getRocketUserCreator(slackChannel); @@ -341,7 +347,7 @@ export default class RocketAdapter implements IRocketChatAdapter { 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: any) { if (!hasRetried) { @@ -354,19 +360,23 @@ export default class RocketAdapter implements IRocketChatAdapter { } const roomUpdate: Record = { - ts: new Date(slackChannel.created * 1000), + ...(slackChannel.created && { + ts: new Date(slackChannel.created * 1000), + }), }; let lastSetTopic = 0; 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?.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 as string, [slackChannel.id]); + await Rooms.addImportIds(slackChannel.rocketId as string, [slackChannel.id || slackChannelID]); slack.addSlackChannel(slackChannel.rocketId as string, slackChannelID); } @@ -489,7 +499,7 @@ export default class RocketAdapter implements IRocketChatAdapter { } addAliasToMsg(rocketUserName: string, rocketMsgObj: Partial): Partial { - const aliasFormat = settings.get('SlackBridge_AliasFormat'); + const { aliasFormat } = this.slackBridge; if (aliasFormat) { const alias = this.util.format(aliasFormat, rocketUserName); diff --git a/apps/meteor/app/slackbridge/server/SlackAPI.ts b/apps/meteor/app/slackbridge/server/SlackAPI.ts index 251a242c03860..204ed10392515 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.ts +++ b/apps/meteor/app/slackbridge/server/SlackAPI.ts @@ -39,7 +39,7 @@ export class SlackAPI implements ISlackAPI { }); 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?.next_cursor) { const nextChannels = await this.getChannels(response.response_metadata.next_cursor); @@ -65,7 +65,7 @@ export class SlackAPI implements ISlackAPI { }); 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?.next_cursor) { const nextGroups = await this.getGroups(response.response_metadata.next_cursor); @@ -114,7 +114,7 @@ export class SlackAPI implements ISlackAPI { }); // 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?.next_cursor; if (hasMoreItems) { @@ -205,7 +205,7 @@ export class SlackAPI implements ISlackAPI { }, }); const response = await request.json(); - return (response && response && request.status === 200 && request.ok && response.items) || undefined; + return (response && request.status === 200 && request.ok && response.items) || undefined; } async getUser(userId: string): Promise { @@ -218,7 +218,22 @@ export class SlackAPI implements ISlackAPI { }, }); const response = await request.json(); - return (response && response && request.status === 200 && request.ok && response.user) || undefined; + 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: string): Promise { @@ -229,7 +244,7 @@ export class SlackAPI implements ISlackAPI { 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 }: { botToken: string; appToken: string }): Promise { @@ -240,7 +255,7 @@ export class SlackAPI implements ISlackAPI { 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 bc0576f3182fd..c888dd859dcfa 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -1,9 +1,5 @@ -import http from 'http'; -import https from 'https'; -import url from 'url'; - import { Message } from '@rocket.chat/core-services'; -import type { IMessage, IRegisterUser, IRoom } from '@rocket.chat/core-typings'; +import type { FileAttachmentProps, IMessage, IRegisterUser, IRoom, IUpload, FileProp } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, ReadReceipts } from '@rocket.chat/models'; import type { BotMessageEvent, @@ -22,17 +18,19 @@ import type { ReactionRemovedEvent, MemberJoinedChannelEvent, } from '@slack/types'; -import type { ChatUpdateArguments, ConversationsListResponse } from '@slack/web-api'; +import type { ChatUpdateArguments, ConversationsHistoryArguments, ConversationsListResponse } from '@slack/web-api'; import { Meteor } from 'meteor/meteor'; import type { IMessageSyncedWithSlack, SlackTS } from './definition/IMessageSyncedWithSlack'; import { isMessageImportedFromSlack } from './definition/IMessageSyncedWithSlack'; -import type { IRocketChatAdapter, RocketChatUserIdentification } from './definition/IRocketChatAdapter'; +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, @@ -434,7 +432,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { }); } - createSlackMessageId(ts: string, channelId: string): string { + createSlackMessageId(ts: string, channelId?: string): string { return `slack${channelId ? `-${channelId}` : ''}-${ts.replace(/\./g, '-')}`; } @@ -707,7 +705,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { } async processBotMessage(rocketChannel: IRoom, slackMessage: BotMessageEvent): Promise { - const excludeBotNames = settings.get('SlackBridge_ExcludeBotnames'); + const { excludeBotNames } = this.slackBridge; if (slackMessage.username !== undefined && excludeBotNames && slackMessage.username.match(excludeBotNames)) { return; } @@ -737,10 +735,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { return rocketMsgObj; } - async processMeMessage( - rocketUser: RocketChatUserIdentification, - slackMessage: MeMessageEvent, - ): Promise { + async processMeMessage(rocketUser: IRegisterUser, slackMessage: MeMessageEvent): Promise { return this.rocket.addAliasToMsg(rocketUser.username, { msg: `_${await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text)}_`, }); @@ -748,7 +743,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processChannelJoinMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: ChannelJoinMessageEvent, isImporting: boolean, ): Promise { @@ -764,7 +759,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processGroupJoinMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: ChannelJoinMessageEvent | GroupJoinMessageEvent, isImporting: boolean, ): Promise { @@ -772,9 +767,11 @@ export default abstract class SlackAdapter implements ISlackAdapter { return; } - const inviter = slackMessage.inviter - ? (await this.rocket.findUser(slackMessage.inviter)) || (await this.rocket.addUser(slackMessage.inviter)) - : null; + 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), @@ -787,7 +784,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processLeaveMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: ChannelLeaveMessageEvent | GroupLeaveMessageEvent, isImporting: boolean, ): Promise { @@ -803,7 +800,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processTopicMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: ChannelTopicMessageEvent | GroupTopicMessageEvent, isImporting: boolean, ): Promise { @@ -819,7 +816,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processPurposeMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: ChannelPurposeMessageEvent | GroupPurposeMessageEvent, isImporting: boolean, ): Promise { @@ -835,7 +832,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processNameMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: ChannelNameMessageEvent | GroupNameMessageEvent, isImporting: boolean, ): Promise { @@ -851,35 +848,40 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processShareMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: FileShareMessageEvent, isImporting: boolean, - ): Promise { - if (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, - ); + ): 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: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: PinnedItemMessageEvent, isImporting: boolean, - ): Promise { + ): Promise { const attachment = slackMessage.attachments?.[0]; if (!attachment?.text) { @@ -900,17 +902,24 @@ export default abstract class SlackAdapter implements ISlackAdapter { { text: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(attachment.text), author_name: attachment.author_subname, - author_icon: getUserAvatarURL(attachment.author_subname), - ts: new Date(parseInt(attachment.ts.split('.')[0]) * 1000), + author_icon: getUserAvatarURL(attachment.author_subname as string), + ...(attachment.ts && { + ts: new Date(parseInt(attachment.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); - 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); + 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); + } } } @@ -919,7 +928,7 @@ export default abstract class SlackAdapter implements ISlackAdapter { async processSubtypedMessage( rocketChannel: IRoom, - rocketUser: RocketChatUserIdentification, + rocketUser: IRegisterUser, slackMessage: SlackMessageEvent, isImporting: boolean, ): Promise { @@ -947,13 +956,13 @@ export default abstract class SlackAdapter implements ISlackAdapter { 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': @@ -979,80 +988,96 @@ export default abstract class SlackAdapter implements ISlackAdapter { @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?.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('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('Copying users from Slack channel to Rocket.Chat', channelMap.id, rid); const channel = await this.slackAPI.getRoomInfo(channelMap.id); if (channel) { @@ -1062,104 +1087,119 @@ export default abstract class SlackAdapter implements ISlackAdapter { const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member)); if (user) { slackLogger.debug('Adding user to room', 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('Setting room topic', rid, topic, creator.username); - await saveRoomTopic(rid, topic, creator, false); + const creator = topicCreator && (await this.rocket.getUser(topicCreator)); + if (creator) { + slackLogger.debug('Setting room topic', rid, topic, 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('importMessages: ', rid); const rcRoom = await Rooms.findOneById(rid); if (rcRoom) { - if (this.getSlackChannel(rid)) { - await this.copyChannelInfo(rid, this.getSlackChannel(rid)); + const slackChannel = this.getSlackChannel(rid); + + if (slackChannel) { + await this.copyChannelInfo(rid, slackChannel); - slackLogger.debug('Importing messages from Slack to Rocket.Chat', this.getSlackChannel(rid), rid); + slackLogger.debug('Importing messages from Slack to Rocket.Chat', slackChannel, 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('Pinning Slack channel messages to Rocket.Chat', this.getSlackChannel(rid), rid); - await this.copyPins(rid, this.getSlackChannel(rid)); + slackLogger.debug('Pinning Slack channel messages to Rocket.Chat', slackChannel, rid); + await this.copyPins(rid, slackChannel); return callback(); } - const slack_room = await this.postFindChannel(rcRoom.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: rcRoom.name }); diff --git a/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts b/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts index e138401418451..9525e9a457eba 100644 --- a/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts +++ b/apps/meteor/app/slackbridge/server/definition/IMessageSyncedWithSlack.ts @@ -2,10 +2,11 @@ import type { IMessage } from '@rocket.chat/core-typings'; export type SlackTS = string; -export type IMessageSyncedWithSlack = IMessage & { slackTs?: SlackTS }; +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; +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 index 55c452d0ea529..f71476b2ed1d2 100644 --- a/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts +++ b/apps/meteor/app/slackbridge/server/definition/IRocketChatAdapter.ts @@ -1,10 +1,13 @@ 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[]; diff --git a/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts b/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts index a2eebbcab02f8..6b3473dc719c2 100644 --- a/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAPI.ts @@ -1,4 +1,3 @@ -// import type { MessageEvent } from '@slack/types'; import type { ConversationsListResponse, ConversationsInfoResponse, @@ -94,73 +93,6 @@ export type SlackPostMessage = ChatPostMessageArguments & { username?: string; }; -// export type SlackPostMessage = { -// // Token is mandatory, but may be passed on the header instead of the params -// token?: string; - -// channel: string; -// // JSON serialized array -// attachments?: string; -// // JSON serialized array -// blocks?: string; -// text?: string; - -// as_user?: boolean; -// icon_emoji?: string; -// icon_url?: string; -// link_names?: boolean; -// markdown_text?: string; -// metadata?: string; -// mrkdwn?: boolean; -// parse?: string; -// reply_broadcast?: boolean; -// thread_ts?: string; -// unfurl_links?: boolean; -// unfurl_media?: boolean; -// username?: string; -// } & ({ attachments: string } | { blocks: string } | { text: string }); - -// export type SlackMessageResponse = -// | { -// ok: false; -// error: string; -// } -// | ({ -// ok: true; -// } & SuccessType); - -// export type SlackPostMessageResponse = SlackMessageResponse<{ -// channel: string; -// ts: string; -// message: MessageEvent; -// }>; - -// export type SlackUpdateMessage = Pick< -// SlackPostMessage, -// | 'token' -// | 'channel' -// | 'attachments' -// | 'blocks' -// | 'text' -// | 'as_user' -// | 'link_names' -// | 'markdown_text' -// | 'metadata' -// | 'parse' -// | 'reply_broadcast' -// > & { -// ts: string; -// // accepts an array of strings or a CSV -// file_ids?: string[] | string; -// } & ({ attachments: string } | { blocks: string } | { text: string }); - -// export type SlackUpdateMessageResponse = SlackMessageResponse<{ -// channel: string; -// ts: string; -// text: string; -// message: Partial; -// }>; - export interface ISlackAPI { getChannels(cursor?: string | null): Promise['channels']>; getGroups(cursor?: string | null): Promise['channels']>; @@ -174,4 +106,5 @@ export interface ISlackAPI { 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 index b94c103049cb1..293ca2b74e078 100644 --- a/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/definition/ISlackAdapter.ts @@ -1,7 +1,7 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import type { SlackTS } from './IMessageSyncedWithSlack'; -import type { IRocketChatAdapter, RocketChatUserIdentification } from './IRocketChatAdapter'; +import type { RocketChatUserIdentification } from './IRocketChatAdapter'; import type { ISlackAPI } from './ISlackAPI'; import type { RocketChatMessageData } from './RocketChatMessageData'; import type { SlackMessageEvent } from './SlackMessageEvent'; @@ -25,11 +25,8 @@ export interface ISlackAdapter { connectLegacy(apiToken: string): Promise; disconnect(): Promise; - setRocketAdapter(adapter: IRocketChatAdapter): void; - registerForEvents(): void; - registerForEventsLegacy(): void; getSlackChannel(rocketChatChannelId: string): SlackChannel | undefined; postDeleteMessage(message: IMessage): Promise; diff --git a/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts b/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts index 35c297312b1a6..1b0f4a5a79bfe 100644 --- a/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts +++ b/apps/meteor/app/slackbridge/server/definition/ISlackbridge.ts @@ -2,6 +2,8 @@ export interface ISlackbridge { isReactionsEnabled: boolean; reactionsMap: Map; + aliasFormat: string; + excludeBotNames: string; connect(): void; reconnect(): Promise; diff --git a/apps/meteor/app/slackbridge/server/slackbridge.ts b/apps/meteor/app/slackbridge/server/slackbridge.ts index a96cf6e850e57..d037fdaee1b62 100644 --- a/apps/meteor/app/slackbridge/server/slackbridge.ts +++ b/apps/meteor/app/slackbridge/server/slackbridge.ts @@ -1,7 +1,8 @@ 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'; @@ -32,9 +33,9 @@ class SlackBridgeClass implements ISlackbridge { private signingSecrets = ''; - private aliasFormat = ''; + private _aliasFormat = ''; - private excludeBotnames = ''; + private _excludeBotNames = ''; public isReactionsEnabled = true; @@ -42,6 +43,14 @@ class SlackBridgeClass implements ISlackbridge { return this._reactionsMap; } + public get aliasFormat(): string { + return this._aliasFormat; + } + + public get excludeBotNames(): string { + return this._excludeBotNames; + } + constructor() { this.isEnabled = false; this.isLegacyRTM = true; @@ -57,8 +66,8 @@ class SlackBridgeClass implements ISlackbridge { this.botTokens = ''; this.appTokens = ''; this.signingSecrets = ''; - this.aliasFormat = ''; - this.excludeBotnames = ''; + this._aliasFormat = ''; + this._excludeBotNames = ''; this.isReactionsEnabled = true; this.processSettings(); @@ -73,8 +82,7 @@ class SlackBridgeClass implements ISlackbridge { const tokenList = this.apiTokens.split('\n'); tokenList.forEach((apiToken) => { - const slack: ISlackAdapter = new SlackAdapter(this); - slack.setRocketAdapter(this.rocket); + const slack: ISlackAdapter = new SlackAdapterLegacy(this, this.rocket); this.rocket.addSlack(slack); this.slackAdapters.push(slack); @@ -98,8 +106,7 @@ class SlackBridgeClass implements ISlackbridge { })); appCredentials.forEach((appCredential) => { - const slack: ISlackAdapter = new SlackAdapter(this); - slack.setRocketAdapter(this.rocket); + const slack: ISlackAdapter = new SlackAdapterApp(this, this.rocket); this.rocket.addSlack(slack); this.slackAdapters.push(slack); @@ -194,13 +201,13 @@ class SlackBridgeClass implements ISlackbridge { // 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; + this._aliasFormat = value; classLogger.debug('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; + this._excludeBotNames = value; classLogger.debug('Setting: SlackBridge_ExcludeBotnames', value); }); From d36fc68bb51805aebbfd6363679934920f76d9c9 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 23 Jan 2026 13:23:44 -0300 Subject: [PATCH 4/4] outdated param --- apps/meteor/app/slackbridge/server/RocketAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.ts b/apps/meteor/app/slackbridge/server/RocketAdapter.ts index 2ce5cdd19e300..5029e643555f4 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.ts +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -85,7 +85,7 @@ export default class RocketAdapter implements IRocketChatAdapter { callbacks.remove('afterUnsetReaction', 'SlackBridge_UnSetReaction'); } - async onMessageDelete(rocketMessageDeleted: IMessage, _room: IRoom) { + async onMessageDelete(rocketMessageDeleted: IMessage) { for await (const slack of this._slackAdapters) { try { if (!slack.getSlackChannel(rocketMessageDeleted.rid)) {