snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
## Rocket.Chat Slack Bridge
This package creates a bi-directional bridge between a single Slack installation and your Rocket.Chat installation.
* Configure Rocket.Chat with your Slack API token
* Invite 'rocketbot' to your Slack channel
### Settings
The following can be configured in your Rocket.Chat Administration SlackBridge panel.
#### Enabled
#### API Token
#### Alias format
#### Exclude bots
#### SlackBridge Out Enabled
#### SlackBridge Out All
#### SlackBridge Out Channels
### Features
#### Group Chat Messages
* Send and receive basic messages
* Delete messages (Can't delete slack message from rocket)
* Edit messages (Slack doesn't allow editing of BOT messages, so can't edit a Rocket msg in Slack)
* React to messages (as BOT in Slack)
#### Files
#### Imports
### TODO
* Support multiple slack installations
* Private IM's

View File

@@ -0,0 +1,551 @@
// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS
// TODO: Remove the following lint/ts instructions when the file gets properly converted
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import util from 'node:util';
import { Messages, Rooms, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import { rocketLogger } from './logger';
import { sleep } from '../../../lib/utils/sleep';
import { callbacks } from '../../lib/callbacks';
import { sendMessage } from '../../lib/messages/sendMessage';
import { createRoom } from '../../lib/rooms/createRoom';
import { setUserAvatar } from '../../lib/users/setUserAvatar';
import { settings } from '../../settings';
export default class RocketAdapter {
constructor(slackBridge) {
rocketLogger.debug('constructor');
this.slackBridge = slackBridge;
this.util = util;
this.userTags = {};
this.slackAdapters = [];
}
connect() {
this.registerForEvents();
}
disconnect() {
this.unregisterForEvents();
}
addSlack(slack) {
if (this.slackAdapters.indexOf(slack) < 0) {
this.slackAdapters.push(slack);
}
}
clearSlackAdapters() {
this.slackAdapters = [];
}
registerForEvents() {
rocketLogger.debug('Register for events');
callbacks.add('afterSaveMessage', this.onMessage.bind(this), callbacks.priority.LOW, 'SlackBridge_Out');
callbacks.add('afterDeleteMessage', this.onMessageDelete.bind(this), callbacks.priority.LOW, 'SlackBridge_Delete');
callbacks.add('afterSetReaction', this.onSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_SetReaction');
callbacks.add('afterUnsetReaction', this.onUnSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_UnSetReaction');
}
unregisterForEvents() {
rocketLogger.debug('Unregister for events');
callbacks.remove('afterSaveMessage', 'SlackBridge_Out');
callbacks.remove('afterDeleteMessage', 'SlackBridge_Delete');
callbacks.remove('afterSetReaction', 'SlackBridge_SetReaction');
callbacks.remove('afterUnsetReaction', 'SlackBridge_UnSetReaction');
}
async onMessageDelete(rocketMessageDeleted) {
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
continue;
}
rocketLogger.debug({ msg: 'onRocketMessageDelete', rocketMessageDeleted });
await slack.postDeleteMessage(rocketMessageDeleted);
} catch (err) {
rocketLogger.error({ msg: 'Unhandled error onMessageDelete', err });
}
}
}
async onSetReaction(rocketMsg, { reaction }) {
try {
if (!this.slackBridge.isReactionsEnabled) {
return;
}
rocketLogger.debug('onRocketSetReaction');
if (rocketMsg._id && reaction) {
if (this.slackBridge.reactionsMap.delete(`set${rocketMsg._id}${reaction}`)) {
// This was a Slack reaction, we don't need to tell Slack about it
return;
}
if (rocketMsg) {
for await (const slack of this.slackAdapters) {
const slackChannel = slack.getSlackChannel(rocketMsg.rid);
if (slackChannel != null) {
const slackTS = slack.getTimeStamp(rocketMsg);
await slack.postReactionAdded(reaction.replace(/:/g, ''), slackChannel.id, slackTS);
}
}
}
}
} catch (err) {
rocketLogger.error({ msg: 'Unhandled error onSetReaction', err });
}
}
async onUnSetReaction(rocketMsg, { reaction }) {
try {
if (!this.slackBridge.isReactionsEnabled) {
return;
}
rocketLogger.debug('onRocketUnSetReaction');
if (rocketMsg._id && reaction) {
if (this.slackBridge.reactionsMap.delete(`unset${rocketMsg._id}${reaction}`)) {
// This was a Slack unset reaction, we don't need to tell Slack about it
return;
}
if (rocketMsg) {
for await (const slack of this.slackAdapters) {
const slackChannel = slack.getSlackChannel(rocketMsg.rid);
if (slackChannel != null) {
const slackTS = slack.getTimeStamp(rocketMsg);
await slack.postReactionRemove(reaction.replace(/:/g, ''), slackChannel.id, slackTS);
}
}
}
}
} catch (err) {
rocketLogger.error({ msg: 'Unhandled error onUnSetReaction', err });
}
}
async onMessage(rocketMessage) {
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
continue;
}
rocketLogger.debug({ msg: 'onRocketMessage', rocketMessage });
if (rocketMessage.editedAt) {
// This is an Edit Event
await this.processMessageChanged(rocketMessage, slack);
continue;
}
// Ignore messages originating from Slack
if (rocketMessage._id.indexOf('slack-') === 0) {
continue;
}
if (rocketMessage.file) {
await this.processFileShare(rocketMessage, slack);
continue;
}
// A new message from Rocket.Chat
await this.processSendMessage(rocketMessage, slack);
} catch (err) {
rocketLogger.error({ msg: 'Unhandled error onMessage', err });
}
}
return rocketMessage;
}
async processSendMessage(rocketMessage, slack) {
// 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') || [];
// rocketLogger.debug('Out SlackChannels: ', outSlackChannels);
if (outSlackChannels.indexOf(rocketMessage.rid) !== -1) {
await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), rocketMessage);
}
}
}
getMessageAttachment(rocketMessage) {
if (!rocketMessage.file) {
return;
}
if (!rocketMessage.attachments || !rocketMessage.attachments.length) {
return;
}
const fileId = rocketMessage.file._id;
return rocketMessage.attachments.find((attachment) => attachment.title_link && attachment.title_link.indexOf(`/${fileId}/`) >= 0);
}
async processFileShare(rocketMessage, slack) {
if (!settings.get('SlackBridge_FileUpload_Enabled')) {
return;
}
if (rocketMessage.file.name) {
let fileName = rocketMessage.file.name;
let text = rocketMessage.msg;
const attachment = this.getMessageAttachment(rocketMessage);
if (attachment) {
fileName = Meteor.absoluteUrl(attachment.title_link);
if (!text) {
text = attachment.description;
}
}
await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), { ...rocketMessage, msg: `${text} ${fileName}` });
}
}
async processMessageChanged(rocketMessage, slack) {
if (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 getUser(slackUser) {
return slackUser ? (await this.findUser(slackUser)) || this.addUser(slackUser) : null;
}
createRocketID(slackChannel, ts) {
return `slack-${slackChannel}-${ts.replace(/\./g, '-')}`;
}
async findChannel(slackChannelId) {
return Rooms.findOneByImportId(slackChannelId);
}
async getRocketUsers(members, slackChannel) {
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) {
rocketUsers.push(rocketUser.username);
}
}
}
return rocketUsers;
}
async getRocketUserCreator(slackChannel) {
return slackChannel.creator ? (await this.findUser(slackChannel.creator)) || this.addUser(slackChannel.creator) : null;
}
async addChannel(slackChannelID, hasRetried = false) {
rocketLogger.debug({ msg: 'Adding Rocket.Chat channel from Slack', slackChannelID });
let addedRoom;
for await (const slack of this.slackAdapters) {
if (addedRoom) {
continue;
}
const slackChannel = await slack.slackAPI.getRoomInfo(slackChannelID);
if (slackChannel) {
const members = await slack.slackAPI.getMembers(slackChannelID);
if (!members) {
rocketLogger.error('Could not fetch room members');
continue;
}
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);
} else {
const rocketUsers = await this.getRocketUsers(members, slackChannel);
const rocketUserCreator = await this.getRocketUserCreator(slackChannel);
if (!rocketUserCreator) {
rocketLogger.error({ msg: 'Could not fetch room creator information', creator: slackChannel.creator });
continue;
}
try {
const isPrivate = slackChannel.is_private;
const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator, rocketUsers);
slackChannel.rocketId = rocketChannel.rid;
} catch (e) {
if (!hasRetried) {
rocketLogger.debug({ msg: 'Error adding channel from Slack. Will retry in 1s.', err: e });
// If first time trying to create channel fails, could be because of multiple messages received at the same time. Try again once after 1s.
await sleep(1000);
return (await this.findChannel(slackChannelID)) || this.addChannel(slackChannelID, true);
}
rocketLogger.error({ msg: 'Error adding channel from Slack', err: e });
}
const roomUpdate = {
ts: new Date(slackChannel.created * 1000),
};
let lastSetTopic = 0;
if (slackChannel.topic && slackChannel.topic.value) {
roomUpdate.topic = slackChannel.topic.value;
lastSetTopic = slackChannel.topic.last_set;
}
if (slackChannel.purpose && 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);
}
addedRoom = await Rooms.findOneById(slackChannel.rocketId);
}
}
if (!addedRoom) {
rocketLogger.debug('Channel not added');
}
return addedRoom;
}
async findUser(slackUserID) {
const rocketUser = await Users.findOneByImportId(slackUserID);
if (rocketUser && !this.userTags[slackUserID]) {
this.userTags[slackUserID] = {
slack: `<@${slackUserID}>`,
rocket: `@${rocketUser.username}`,
};
}
return rocketUser;
}
async addUser(slackUserID) {
rocketLogger.debug({ msg: 'Adding Rocket.Chat user from Slack', slackUserID });
let addedUser;
for await (const slack of this.slackAdapters) {
if (addedUser) {
return;
}
const user = await slack.slackAPI.getUser(slackUserID);
if (user) {
const rocketUserData = user;
const isBot = rocketUserData.is_bot === true;
const email = (rocketUserData.profile && rocketUserData.profile.email) || '';
let existingRocketUser;
if (!isBot) {
existingRocketUser =
(await Users.findOneByEmailAddress(email)) || (await Users.findOneByUsernameIgnoringCase(rocketUserData.name));
} else {
existingRocketUser = await Users.findOneByUsernameIgnoringCase(rocketUserData.name);
}
if (existingRocketUser) {
rocketUserData.rocketId = existingRocketUser._id;
rocketUserData.name = existingRocketUser.username;
} else {
const newUser = {
password: Random.id(),
username: rocketUserData.name,
};
if (!isBot && email) {
newUser.email = email;
}
if (isBot) {
newUser.joinDefaultChannels = false;
}
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,
roles: isBot ? ['bot'] : ['user'],
};
if (rocketUserData.profile && rocketUserData.profile.real_name) {
userUpdate.name = rocketUserData.profile.real_name;
}
if (rocketUserData.deleted) {
userUpdate.active = false;
userUpdate['services.resume.loginTokens'] = [];
}
await Users.updateOne({ _id: rocketUserData.rocketId }, { $set: userUpdate });
const user = await Users.findOneById(rocketUserData.rocketId);
let url = null;
if (rocketUserData.profile) {
if (rocketUserData.profile.image_original) {
url = rocketUserData.profile.image_original;
} else if (rocketUserData.profile.image_512) {
url = rocketUserData.profile.image_512;
}
}
if (url) {
try {
await setUserAvatar(user, url, null, 'url');
} catch (err) {
rocketLogger.debug({ msg: 'Error setting user avatar from Slack', err });
}
}
}
const importIds = [rocketUserData.id];
if (isBot && rocketUserData.profile && rocketUserData.profile.bot_id) {
importIds.push(rocketUserData.profile.bot_id);
}
await Users.addImportIds(rocketUserData.rocketId, importIds);
if (!this.userTags[slackUserID]) {
this.userTags[slackUserID] = {
slack: `<@${slackUserID}>`,
rocket: `@${rocketUserData.name}`,
};
}
addedUser = await Users.findOneById(rocketUserData.rocketId);
}
}
if (!addedUser) {
rocketLogger.debug('User not added');
}
return addedUser;
}
addAliasToMsg(rocketUserName, rocketMsgObj) {
const aliasFormat = settings.get('SlackBridge_AliasFormat');
if (aliasFormat) {
const alias = this.util.format(aliasFormat, rocketUserName);
if (alias !== rocketUserName) {
rocketMsgObj.alias = alias;
}
}
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 } });
}
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, { upsert: true });
}
}
}, 500);
} else {
rocketLogger.debug('Send message to Rocket.Chat');
await sendMessage(rocketUser, rocketMsgObj, rocketChannel, { upsert: true });
}
}
}
async convertSlackMsgTxtToRocketTxtFormat(slackMsgTxt) {
const regex = /(?:<@)([a-zA-Z0-9]+)(?:\|.+)?(?:>)/g;
if (!_.isEmpty(slackMsgTxt)) {
slackMsgTxt = slackMsgTxt.replace(/<!everyone>/g, '@all');
slackMsgTxt = slackMsgTxt.replace(/<!channel>/g, '@all');
slackMsgTxt = slackMsgTxt.replace(/<!here>/g, '@here');
slackMsgTxt = slackMsgTxt.replace(/&gt;/g, '>');
slackMsgTxt = slackMsgTxt.replace(/&lt;/g, '<');
slackMsgTxt = slackMsgTxt.replace(/&amp;/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));
}
});
const result = await Promise.all(promises);
slackMsgTxt = slackMsgTxt.replace(regex, () => result.shift());
} else {
slackMsgTxt = '';
}
return slackMsgTxt;
}
}

View File

@@ -0,0 +1,253 @@
// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS
// TODO: Remove the following lint/ts instructions when the file gets properly converted
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
export class SlackAPI {
constructor(apiOrBotToken) {
this.token = apiOrBotToken;
}
async getChannels(cursor = null) {
let channels = [];
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/conversations.list', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
types: 'public_channel',
exclude_archived: true,
limit: 1000,
...(cursor && { cursor }),
},
});
const response = await request.json();
if (response && 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);
channels = channels.concat(nextChannels);
}
}
return channels;
}
async getGroups(cursor = null) {
let groups = [];
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/conversations.list', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
types: 'private_channel',
exclude_archived: true,
limit: 1000,
...(cursor && { cursor }),
},
});
const response = await request.json();
if (response && 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);
groups = groups.concat(nextGroups);
}
}
return groups;
}
async getRoomInfo(roomId) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(`https://slack.com/api/conversations.info`, {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
channel: roomId,
include_num_members: true,
},
});
const response = await request.json();
return response && response && request.status === 200 && request.ok && response.channel;
}
async getMembers(channelId) {
const { num_members } = this.getRoomInfo(channelId);
const MAX_MEMBERS_PER_CALL = 100;
let members = [];
let currentCursor = '';
for (let index = 0; index < num_members; index += MAX_MEMBERS_PER_CALL) {
const request = await fetch('https://slack.com/api/conversations.members', {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
channel: channelId,
limit: MAX_MEMBERS_PER_CALL,
...(currentCursor && { cursor: currentCursor }),
},
});
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?.next_cursor;
if (hasMoreItems) {
currentCursor = response.response_metadata.next_cursor;
}
}
}
return members;
}
async react(data) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/reactions.add', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
method: 'POST',
params: data,
});
const response = await request.json();
return response && request.status === 200 && response && request.ok;
}
async removeReaction(data) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/reactions.remove', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
method: 'POST',
params: data,
});
const response = await request.json();
return response && request.status === 200 && response && request.ok;
}
async removeMessage(data) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/chat.delete', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
method: 'POST',
params: data,
});
const response = await request.json();
return response && request.status === 200 && response && request.ok;
}
async sendMessage(data) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/chat.postMessage', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
method: 'POST',
params: data,
});
return request.json();
}
async updateMessage(data) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/chat.update', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
method: 'POST',
params: data,
});
const response = await request.json();
return response && request.status === 200 && response && request.ok;
}
async getHistory(options) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch(`https://slack.com/api/conversations.history`, {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
params: options,
});
const response = await request.json();
return response;
}
async getPins(channelId) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/pins.list', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
channel: channelId,
},
});
const response = await request.json();
return response && response && request.status === 200 && request.ok && response.items;
}
async getUser(userId) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/users.info', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${this.token}`,
},
params: {
user: userId,
},
});
const response = await request.json();
return response && response && request.status === 200 && request.ok && response.user;
}
static async verifyToken(token) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/auth.test', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${token}`,
},
method: 'POST',
});
const response = await request.json();
return response && response && request.status === 200 && request.ok && response.ok;
}
static async verifyAppCredentials({ botToken, appToken }) {
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
const request = await fetch('https://slack.com/api/apps.connections.open', {
ignoreSsrfValidation: true,
headers: {
Authorization: `Bearer ${appToken}`,
},
method: 'POST',
});
const response = await request.json();
const isAppTokenOk = response && response && request.status === 200 && request.ok && response.ok;
const isBotTokenOk = await this.verifyToken(botToken);
return isAppTokenOk && isBotTokenOk;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
import './slackbridge';
import './slackbridge_import.server';
import './removeChannelLinks';

View File

@@ -0,0 +1,8 @@
import { Logger } from '@rocket.chat/logger';
const logger = new Logger('SlackBridge');
export const connLogger = logger.section('Connection');
export const classLogger = logger.section('Class');
export const slackLogger = logger.section('Slack');
export const rocketLogger = logger.section('Rocket');

View File

@@ -0,0 +1,41 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { settings } from '../../settings';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
removeSlackBridgeChannelLinks(): { message: string; params: unknown[] };
}
}
Meteor.methods<ServerMethods>({
async removeSlackBridgeChannelLinks() {
const user = await Meteor.userAsync();
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'removeSlackBridgeChannelLinks',
});
}
if (!(await hasPermissionAsync(user, 'remove-slackbridge-links'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'removeSlackBridgeChannelLinks',
});
}
if (settings.get('SlackBridge_Enabled') !== true) {
throw new Meteor.Error('SlackBridge_disabled');
}
await Rooms.unsetAllImportIds();
return {
message: 'Slackbridge_channel_links_removed_successfully',
params: [],
};
},
});

View File

@@ -0,0 +1,199 @@
// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS
// TODO: Remove the following lint/ts instructions when the file gets properly converted
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { debounce } from 'lodash';
import RocketAdapter from './RocketAdapter';
import SlackAdapter from './SlackAdapter';
import { classLogger, connLogger } from './logger';
import { settings } from '../../settings';
/**
* SlackBridge interfaces between this Rocket installation and a remote Slack installation.
*/
class SlackBridgeClass {
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.connected = false;
this.rocket.clearSlackAdapters();
// Settings that we cache versus looking up at runtime
this.apiTokens = false;
this.botTokens = false;
this.appTokens = false;
this.signingSecrets = false;
this.aliasFormat = '';
this.excludeBotnames = '';
this.isReactionsEnabled = true;
this.processSettings();
}
connect() {
if (this.connected === false) {
this.slackAdapters = [];
this.rocket.clearSlackAdapters();
if (this.isLegacyRTM) {
const tokenList = this.apiTokens.split('\n');
tokenList.forEach((apiToken) => {
const slack = new SlackAdapter(this);
slack.setRocket(this.rocket);
this.rocket.addSlack(slack);
this.slackAdapters.push(slack);
slack.connect({ apiToken }).catch((err) => connLogger.error({ msg: 'error connecting to slack', err }));
});
} else {
const botTokenList = this.botTokens.split('\n'); // Bot token list
const appTokenList = this.appTokens.split('\n'); // App token list
const signingSecretList = this.signingSecrets.split('\n'); // Signing secret list
// Check if the number of tokens are the same
if (botTokenList.length !== appTokenList.length || botTokenList.length !== signingSecretList.length) {
connLogger.error('error connecting to slack: number of tokens are not the same');
return;
}
const appCredentials = botTokenList.map((botToken, i) => ({
botToken,
appToken: appTokenList[i],
signingSecret: signingSecretList[i],
}));
appCredentials.forEach((appCredential) => {
const slack = new SlackAdapter(this);
slack.setRocket(this.rocket);
this.rocket.addSlack(slack);
this.slackAdapters.push(slack);
slack.connect({ appCredential }).catch((err) => connLogger.error({ msg: 'error connecting to slack', err }));
});
}
if (settings.get('SlackBridge_Out_Enabled')) {
this.rocket.connect();
}
this.connected = true;
connLogger.info('Enabled');
}
}
async reconnect() {
await this.disconnect();
// connect if either apiTokens or appCredentials are set
if (this.isLegacyRTM && this.apiTokens) {
this.connect();
} else if (!this.isLegacyRTM && this.botTokens && this.appTokens && this.signingSecrets) {
this.connect();
}
}
debouncedReconnectIfEnabled = debounce(() => {
if (this.isEnabled) {
this.reconnect();
}
}, 500);
async disconnect() {
try {
if (this.connected === true) {
await this.rocket.disconnect();
await Promise.all(this.slackAdapters.map((slack) => slack.disconnect()));
this.slackAdapters = [];
this.connected = false;
connLogger.info('Slack Bridge Disconnected');
}
} catch (error) {
connLogger.error({ msg: 'An error occurred during disconnection', err: error });
}
}
processSettings() {
// Check if legacy realtime api is enabled
settings.watch('SlackBridge_UseLegacy', (value) => {
if (value !== this.isLegacyRTM) {
this.isLegacyRTM = value;
this.debouncedReconnectIfEnabled();
}
classLogger.debug({ msg: 'Setting: SlackBridge_UseLegacy', value });
});
// Slack installtion Bot token
settings.watch('SlackBridge_BotToken', (value) => {
if (value !== this.botTokens) {
this.botTokens = value;
this.debouncedReconnectIfEnabled();
}
classLogger.debug({ msg: 'Setting: SlackBridge_BotToken', value });
});
// Slack installtion App token
settings.watch('SlackBridge_AppToken', (value) => {
if (value !== this.appTokens) {
this.appTokens = value;
this.debouncedReconnectIfEnabled();
}
classLogger.debug({ msg: 'Setting: SlackBridge_AppToken', value });
});
// Slack installtion Signing token
settings.watch('SlackBridge_SigningSecret', (value) => {
if (value !== this.signingSecrets) {
this.signingSecrets = value;
this.debouncedReconnectIfEnabled();
}
classLogger.debug({ msg: 'Setting: SlackBridge_SigningSecret', value });
});
// Slack installation API token
settings.watch('SlackBridge_APIToken', (value) => {
if (value !== this.apiTokens) {
this.apiTokens = value;
this.debouncedReconnectIfEnabled();
}
classLogger.debug({ msg: 'Setting: SlackBridge_APIToken', value });
});
// 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;
classLogger.debug({ msg: 'Setting: SlackBridge_AliasFormat', value });
});
// Do not propagate messages from bots whose name matches the regular expression above. If left empty, all messages from bots will be propagated.
settings.watch('SlackBridge_ExcludeBotnames', (value) => {
this.excludeBotnames = value;
classLogger.debug({ msg: 'Setting: SlackBridge_ExcludeBotnames', value });
});
// Reactions
settings.watch('SlackBridge_Reactions_Enabled', (value) => {
this.isReactionsEnabled = value;
classLogger.debug({ msg: 'Setting: SlackBridge_Reactions_Enabled', value });
});
// Is this entire SlackBridge enabled
settings.watch('SlackBridge_Enabled', (value) => {
if (this.isEnabled !== value) {
this.isEnabled = value;
if (this.isEnabled) {
this.debouncedReconnectIfEnabled();
} else {
this.disconnect();
}
}
classLogger.debug({ msg: 'Setting: SlackBridge_Enabled', value });
});
}
}
export const SlackBridge = new SlackBridgeClass();

View File

@@ -0,0 +1,84 @@
// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS
// TODO: Remove the following lint/ts instructions when the file gets properly converted
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { Rooms, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Match } from 'meteor/check';
import { SlackBridge } from './slackbridge';
import { i18n } from '../../lib/i18n';
import { msgStream } from '../../lib/messaging/msgStream';
import { slashCommands } from '../../lib/utils/slashCommand';
async function SlackBridgeImport({ command, params, message, userId }) {
if (command !== 'slackbridge-import' || !Match.test(params, String)) {
return;
}
const room = await Rooms.findOneById(message.rid);
const channel = room.name;
const user = await Users.findOneById(userId);
msgStream.emit(message.rid, {
_id: Random.id(),
rid: message.rid,
u: { username: 'rocket.cat' },
ts: new Date(),
msg: i18n.t('SlackBridge_start', {
username: user.username,
channelName: channel,
interpolation: { escapeValue: false },
lng: user.language,
}),
});
try {
for await (const slack of SlackBridge.slackAdapters) {
await slack.importMessages(message.rid, (error) => {
if (error) {
msgStream.emit(message.rid, {
_id: Random.id(),
rid: message.rid,
u: { username: 'rocket.cat' },
ts: new Date(),
msg: i18n.t('SlackBridge_error', {
channelName: channel,
errorMessage: error.message,
interpolation: { escapeValue: false },
lng: user.language,
}),
});
} else {
msgStream.emit(message.rid, {
_id: Random.id(),
rid: message.rid,
u: { username: 'rocket.cat' },
ts: new Date(),
msg: i18n.t('SlackBridge_finish', {
channelName: channel,
interpolation: { escapeValue: false },
lng: user.language,
}),
});
}
});
}
} catch (error) {
msgStream.emit(message.rid, {
_id: Random.id(),
rid: message.rid,
u: { username: 'rocket.cat' },
ts: new Date(),
msg: i18n.t('SlackBridge_error', {
channelName: channel,
errorMessage: error.message,
interpolation: { escapeValue: false },
lng: user.language,
}),
});
throw error;
}
}
slashCommands.add({ command: 'slackbridge-import', callback: SlackBridgeImport });