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,69 @@
##IRC Package for Rocket.Chat
### Overview
Internet Relay Chat (IRC) is a text-based group communication tool. Users join uniquely named channels, or rooms, for open discussion. IRC also supports private messages between individual users and file sharing capabilities. This package integrates these layers of functionality with Rocket.Chat.
This package does not attempt to replace Rocket.Chat functionality nor operate as an IRC server for third-party client support. Rather, it utilizes Slack's similarities to IRC by allowing users to transparently access external IRC servers (e.g. Freenode) through Rocket.Chat's desktop and mobile app frontends.
### Development
Core functionality is still in its infancy and needs improvement! If your objectives are broader than the stated scope of this project, please consider creating a separate package. Bugs and feature requests should be reported at [github.com/RocketChat/Rocket.Chat/issues](https://github.com/RocketChat/Rocket.Chat/issues)
The most active conversation on this package can be found in [Issue 216](https://github.com/RocketChat/Rocket.Chat/issues/216).
### Build Notes
It is strongly encouraged to not enable this package in a production environment. There is currently no strict user account oversight, meaning problematic interoperation with an IRC server may interfere with Rocket.Chat's authentication and room behavior.
These *nix build notes are provided for development purposes only and do not include instructions on using SSL for encrypted authentication. Additionally, the following must be installed:
* [Node.js](http://nodejs.org)
* [Meteor](http://www.meteor.com)
* [mongoDB](http://www.mongodb.com)
#### Clone the Rocket.Chat repo via Git
git clone https://github.com/RocketChat/Rocket.Chat.git
#### Edit the package list
Change to the top-level directory of your Rocket.Chat repo. Edit .meteor/packages and uncomment the following:
#rocketchat:irc
#### Export your environment variables
Modify as necessary.
export ROOT_URL=http://localhost
export MONGO_URL=mongodb://localhost:27017/rocketchat
export PORT=3000
#### Compile the development environment
meteor
Once all dependencies are met and the compile has completed successfully, you can access your Rocket.Chat build via http://localhost:3000
You will then need to create a new account, go into the admin settings, choose IRC, enable it, and restart Rocket.Chat. This complexity will decrease once the original codebase is retooled.
As you use Rocket.Chat, be sure to monitor the console terminal (or Rocket.Chat's web admin log) for IRC errors and activity.
### Changelog
Please see the [commit history](https://github.com/RocketChat/Rocket.Chat/commits/develop/packages/rocketchat-irc) on GitHub for a full list of changes.
Major functional changes:
#### 0.0.2
* Remapped IRC username and real name to match Rocket.Chat's user accounts
* Added base support for config via web
* Performed package housecleaning for future growth
#### 0.0.1
* Core concept, design, and functionality

View File

@@ -0,0 +1 @@
import './methods/resetIrcConnection';

View File

@@ -0,0 +1,239 @@
import { Logger } from '@rocket.chat/logger';
import { Settings } from '@rocket.chat/models';
import moment from 'moment';
import Queue from 'queue-fifo';
import * as localCommandHandlers from './localHandlers';
import * as peerCommandHandlers from './peerHandlers';
import { withThrottling } from '../../../../lib/utils/highOrderFunctions';
import { callbacks } from '../../../lib/callbacks';
import { afterLeaveRoomCallback } from '../../../lib/callbacks/afterLeaveRoomCallback';
import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCleanUpCallback';
import { notifyOnSettingChangedById } from '../../../lib/notifyListener';
import { updateAuditedBySystem } from '../../../settings/lib/auditedSettingUpdates';
import * as servers from '../servers';
const logger = new Logger('IRC Bridge');
const queueLogger = logger.section('Queue');
let removed = false;
const updateLastPing = withThrottling({ wait: 10_000 })(() => {
if (removed) {
return;
}
void (async () => {
const updatedValue = await updateAuditedBySystem({
reason: 'updateLastPing',
})(Settings.updateValueById, 'IRC_Bridge_Last_Ping', new Date(), { upsert: true });
if (updatedValue.modifiedCount || updatedValue.upsertedCount) {
void notifyOnSettingChangedById('IRC_Bridge_Last_Ping');
}
})();
});
class Bridge {
constructor(config) {
// General
this.config = config;
// Workaround for Rocket.Chat callbacks being called multiple times
this.loggedInUsers = [];
// Server
const Server = servers[this.config.server.protocol];
this.server = new Server(this.config);
this.setupPeerHandlers();
this.setupLocalHandlers();
// Command queue
this.queue = new Queue();
this.queueTimeout = 5;
}
async init() {
this.initTime = new Date();
removed = false;
this.loggedInUsers = [];
const lastPing = await Settings.findOneById('IRC_Bridge_Last_Ping');
if (lastPing) {
if (Math.abs(moment(lastPing.value).diff()) < 1000 * 30) {
this.log('Not trying to connect.');
this.remove();
return;
}
}
this.log('Connecting.');
updateLastPing();
this.server.register();
this.server.on('registered', () => {
this.logQueue('Starting...');
this.runQueue();
});
}
stop() {
this.server.disconnect();
}
remove() {
this.log('Removing current connection.');
removed = true;
this.server = null;
this.removeLocalHandlers();
}
/**
* Log helper
*/
log(message) {
// TODO logger: debug?
logger.info(message);
}
logQueue(message) {
// TODO logger: debug?
queueLogger.info(message);
}
/**
*
*
* Queue
*
*
*/
onMessageReceived(from, command, ...parameters) {
this.queue.enqueue({ from, command, parameters });
}
async runQueue() {
if (!this.server) {
return;
}
const lastResetTime = Settings.findOneById('IRC_Bridge_Reset_Time');
if (lastResetTime && lastResetTime.value > this.initTime) {
this.stop();
this.remove();
return;
}
updateLastPing();
// If it is empty, skip and keep the queue going
if (this.queue.isEmpty()) {
return setTimeout(this.runQueue.bind(this), this.queueTimeout);
}
// Get the command
const item = this.queue.dequeue();
this.logQueue({ msg: 'Processing command from source', command: item.command, from: item.from });
// Handle the command accordingly
try {
// Handle the command accordingly
switch (item.from) {
case 'local':
if (!localCommandHandlers[item.command]) {
throw new Error(`Could not find handler for local:${item.command}`);
}
await localCommandHandlers[item.command].apply(this, item.parameters);
break;
case 'peer':
if (!peerCommandHandlers[item.command]) {
throw new Error(`Could not find handler for peer:${item.command}`);
}
await peerCommandHandlers[item.command].apply(this, item.parameters);
break;
}
} catch (e) {
this.logQueue(e);
}
// Keep the queue going
setTimeout(this.runQueue.bind(this), this.queueTimeout);
}
/**
*
*
* Peer
*
*
*/
setupPeerHandlers() {
this.server.on('peerCommand', (cmd) => {
this.onMessageReceived('peer', cmd.identifier, cmd.args);
});
}
/**
*
*
* Local
*
*
*/
setupLocalHandlers() {
// Auth
callbacks.add('afterValidateLogin', this.onMessageReceived.bind(this, 'local', 'onLogin'), callbacks.priority.LOW, 'irc-on-login');
callbacks.add(
'afterCreateUser',
this.onMessageReceived.bind(this, 'local', 'onCreateUser'),
callbacks.priority.LOW,
'irc-on-create-user',
);
// Joining rooms or channels
callbacks.add(
'afterCreateChannel',
this.onMessageReceived.bind(this, 'local', 'onCreateRoom'),
callbacks.priority.LOW,
'irc-on-create-channel',
);
callbacks.add(
'afterCreateRoom',
this.onMessageReceived.bind(this, 'local', 'onCreateRoom'),
callbacks.priority.LOW,
'irc-on-create-room',
);
callbacks.add('afterJoinRoom', this.onMessageReceived.bind(this, 'local', 'onJoinRoom'), callbacks.priority.LOW, 'irc-on-join-room');
// Leaving rooms or channels
afterLeaveRoomCallback.add(this.onMessageReceived.bind(this, 'local', 'onLeaveRoom'), callbacks.priority.LOW, 'irc-on-leave-room');
// Chatting
callbacks.add(
'afterSaveMessage',
(message, { room }) => this.onMessageReceived('local', 'onSaveMessage', message, room),
callbacks.priority.LOW,
'irc-on-save-message',
);
// Leaving
afterLogoutCleanUpCallback.add(this.onMessageReceived.bind(this, 'local', 'onLogout'), callbacks.priority.LOW, 'irc-on-logout');
}
removeLocalHandlers() {
callbacks.remove('afterValidateLogin', 'irc-on-login');
callbacks.remove('afterCreateUser', 'irc-on-create-user');
callbacks.remove('afterCreateChannel', 'irc-on-create-channel');
callbacks.remove('afterCreateRoom', 'irc-on-create-room');
callbacks.remove('afterJoinRoom', 'irc-on-join-room');
afterLeaveRoomCallback.remove('irc-on-leave-room');
callbacks.remove('afterSaveMessage', 'irc-on-save-message');
afterLogoutCleanUpCallback.remove('irc-on-logout');
}
sendCommand(command, parameters) {
this.server.emit('onReceiveFromLocal', command, parameters);
}
}
export default Bridge;

View File

@@ -0,0 +1,9 @@
import onCreateRoom from './onCreateRoom';
import onCreateUser from './onCreateUser';
import onJoinRoom from './onJoinRoom';
import onLeaveRoom from './onLeaveRoom';
import onLogin from './onLogin';
import onLogout from './onLogout';
import onSaveMessage from './onSaveMessage';
export { onCreateRoom, onJoinRoom, onLeaveRoom, onLogin, onLogout, onSaveMessage, onCreateUser };

View File

@@ -0,0 +1,13 @@
import { Users } from '@rocket.chat/models';
export default async function handleOnCreateRoom(user, room) {
const users = await Users.findByRoomId(room._id);
users.forEach((user) => {
if (user.profile?.irc?.fromIRC) {
this.sendCommand('joinChannel', { room, user });
} else {
this.sendCommand('joinedChannel', { room, user });
}
});
}

View File

@@ -0,0 +1,37 @@
import { Rooms, Users } from '@rocket.chat/models';
export default async function handleOnCreateUser(newUser) {
if (!newUser) {
return this.log('Invalid handleOnCreateUser call');
}
if (!newUser.username) {
return this.log('Invalid handleOnCreateUser call (Missing username)');
}
if (this.loggedInUsers.indexOf(newUser._id) !== -1) {
return this.log('Duplicate handleOnCreateUser call');
}
this.loggedInUsers.push(newUser._id);
await Users.updateOne(
{ _id: newUser._id },
{
$set: {
'profile.irc.fromIRC': false,
'profile.irc.username': `${newUser.username}-rkt`,
'profile.irc.nick': `${newUser.username}-rkt`,
'profile.irc.hostname': 'rocket.chat',
},
},
);
const user = await Users.findOne({
_id: newUser._id,
});
this.sendCommand('registerUser', user);
const rooms = await Rooms.findBySubscriptionUserId(user._id).toArray();
rooms.forEach((room) => this.sendCommand('joinedChannel', { room, user }));
}

View File

@@ -0,0 +1,3 @@
export default async function handleOnJoinRoom(user, room) {
this.sendCommand('joinedChannel', { room, user });
}

View File

@@ -0,0 +1,3 @@
export default async function handleOnLeaveRoom(user, room) {
this.sendCommand('leftChannel', { room, user });
}

View File

@@ -0,0 +1,42 @@
import { Rooms, Users } from '@rocket.chat/models';
export default async function handleOnLogin(login) {
if (login.user === null) {
return this.log('Invalid handleOnLogin call');
}
if (!login.user.username) {
return this.log('Invalid handleOnLogin call (Missing username)');
}
if (this.loggedInUsers.indexOf(login.user._id) !== -1) {
return this.log('Duplicate handleOnLogin call');
}
this.loggedInUsers.push(login.user._id);
await Users.updateOne(
{ _id: login.user._id },
{
$set: {
'profile.irc.fromIRC': false,
'profile.irc.username': `${login.user.username}-rkt`,
'profile.irc.nick': `${login.user.username}-rkt`,
'profile.irc.hostname': 'rocket.chat',
},
},
);
const user = await Users.findOne({
_id: login.user._id,
});
this.sendCommand('registerUser', user);
const rooms = await Rooms.findBySubscriptionUserId(user._id).toArray();
rooms.forEach((room) => {
if (room.t === 'd') {
return;
}
this.sendCommand('joinedChannel', { room, user });
});
}

View File

@@ -0,0 +1,7 @@
import _ from 'underscore';
export default async function handleOnLogout(user) {
this.loggedInUsers = _.without(this.loggedInUsers, user._id);
this.sendCommand('disconnected', { user });
}

View File

@@ -0,0 +1,36 @@
import { Subscriptions, Users } from '@rocket.chat/models';
import { SystemLogger } from '../../../../lib/logger/system';
export default async function handleOnSaveMessage(message, to) {
let toIdentification = '';
// Direct message
if (to.t === 'd') {
const subscriptions = Subscriptions.findByRoomId(to._id).toArray();
for await (const subscription of subscriptions) {
if (subscription.u._id !== message.u._id) {
const userData = await Users.findOne({ username: subscription.u.username });
if (userData) {
if (userData.profile && userData.profile.irc && userData.profile.irc.nick) {
toIdentification = userData.profile.irc.nick;
} else {
toIdentification = userData.username;
}
} else {
toIdentification = subscription.u.username;
}
}
}
if (!toIdentification) {
SystemLogger.error('[irc][server] Target user not found');
return;
}
} else {
toIdentification = `#${to.name}`;
}
const user = await Users.findOne({ _id: message.u._id });
this.sendCommand('sentMessage', { to: toIdentification, user, message: message.msg });
}

View File

@@ -0,0 +1,20 @@
import { Users } from '@rocket.chat/models';
import { notifyOnUserChange } from '../../../../lib/notifyListener';
export default async function handleQUIT(args) {
const user = await Users.findOne({
'profile.irc.nick': args.nick,
});
await Users.updateOne(
{ _id: user._id },
{
$set: {
status: 'offline',
},
},
);
void notifyOnUserChange({ id: user._id, clientAction: 'updated', diff: { status: 'offline' } });
}

View File

@@ -0,0 +1,8 @@
import disconnected from './disconnected';
import joinedChannel from './joinedChannel';
import leftChannel from './leftChannel';
import nickChanged from './nickChanged';
import sentMessage from './sentMessage';
import userRegistered from './userRegistered';
export { disconnected, joinedChannel, leftChannel, nickChanged, sentMessage, userRegistered };

View File

@@ -0,0 +1,28 @@
import { Users, Rooms } from '@rocket.chat/models';
import { addUserToRoom } from '../../../../lib/rooms/addUserToRoom';
import { createRoom } from '../../../../lib/rooms/createRoom';
// TODO doesn't seem to be used anywhere, remove
export default async function handleJoinedChannel(args) {
const user = await Users.findOne({
'profile.irc.nick': args.nick,
});
if (!user) {
throw new Error(`Could not find a user with nick ${args.nick}`);
}
let room = await Rooms.findOneByName(args.roomName);
if (!room) {
const createdRoom = await createRoom('c', args.roomName, user, []);
room = await Rooms.findOne({ _id: createdRoom.rid });
this.log(`${user.username} created room ${args.roomName}`);
} else {
await addUserToRoom(room._id, user);
this.log(`${user.username} joined room ${room.name}`);
}
}

View File

@@ -0,0 +1,22 @@
import { Users, Rooms } from '@rocket.chat/models';
import { removeUserFromRoom } from '../../../../lib/rooms/removeUserFromRoom';
export default async function handleLeftChannel(args) {
const user = await Users.findOne({
'profile.irc.nick': args.nick,
});
if (!user) {
throw new Error(`Could not find a user with nick ${args.nick}`);
}
const room = await Rooms.findOneByName(args.roomName);
if (!room) {
throw new Error(`Could not find a room with name ${args.roomName}`);
}
this.log(`${user.username} left room ${room.name}`);
await removeUserFromRoom(room._id, user);
}

View File

@@ -0,0 +1,28 @@
import { Users } from '@rocket.chat/models';
import { notifyOnUserChange } from '../../../../lib/notifyListener';
export default async function handleNickChanged(args) {
const user = await Users.findOne({
'profile.irc.nick': args.nick,
});
if (!user) {
throw new Error(`Could not find an user with nick ${args.nick}`);
}
this.log(`${user.username} changed nick: ${args.nick} -> ${args.newNick}`);
// Update on the database
await Users.updateOne(
{ _id: user._id },
{
$set: {
'name': args.newNick,
'profile.irc.nick': args.newNick,
},
},
);
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { name: args.newNick } });
}

View File

@@ -0,0 +1,58 @@
import { Users, Rooms } from '@rocket.chat/models';
import { sendMessage } from '../../../../lib/messages/sendMessage';
import { createDirectRoom } from '../../../../lib/rooms/createDirectRoom';
/*
*
* Get direct chat room helper
*
*
*/
const getDirectRoom = async (source, target) => {
const uids = [source._id, target._id];
const { _id, ...extraData } = await createDirectRoom([source, target]);
const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uids);
if (room) {
return {
t: 'd',
...room,
};
}
return {
_id,
t: 'd',
...extraData,
};
};
export default async function handleSentMessage(args) {
const user = await Users.findOne({
'profile.irc.nick': args.nick,
});
if (!user) {
throw new Error(`Could not find a user with nick ${args.nick}`);
}
let room;
if (args.roomName) {
room = await Rooms.findOneByName(args.roomName);
} else {
const recipientUser = await Users.findOne({
'profile.irc.nick': args.recipientNick,
});
room = await getDirectRoom(user, recipientUser);
}
const message = {
msg: args.message,
ts: new Date(),
};
await sendMessage(user, message, room);
}

View File

@@ -0,0 +1,53 @@
import { Users } from '@rocket.chat/models';
import { notifyOnUserChange } from '../../../../lib/notifyListener';
export default async function handleUserRegistered(args) {
// Check if there is an user with the given username
let user = await Users.findOne({
'profile.irc.username': args.username,
});
// If there is no user, create one...
if (!user) {
this.log(`Registering ${args.username} with nick: ${args.nick}`);
const userToInsert = {
name: args.nick,
username: `${args.username}-irc`,
status: 'online',
utcOffset: 0,
active: true,
type: 'user',
profile: {
irc: {
fromIRC: true,
nick: args.nick,
username: args.username,
hostname: args.hostname,
},
},
};
user = await Users.create(userToInsert);
void notifyOnUserChange({ id: user._id, clientAction: 'inserted', data: user });
} else {
// ...otherwise, log the user in and update the information
this.log(`Logging in ${args.username} with nick: ${args.nick}`);
await Users.updateOne(
{ _id: user._id },
{
$set: {
'status': 'online',
'profile.irc.nick': args.nick,
'profile.irc.username': args.username,
'profile.irc.hostname': args.hostname,
},
},
);
void notifyOnUserChange({ id: user._id, clientAction: 'updated', diff: { status: 'online' } });
}
}

View File

@@ -0,0 +1,91 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Settings } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { notifyOnSettingChangedById } from '../../../lib/notifyListener';
import { settings } from '../../../settings';
import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates';
import Bridge from '../irc-bridge';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
resetIrcConnection(): { message: string; params: unknown[] };
}
}
Meteor.methods<ServerMethods>({
async resetIrcConnection() {
const ircEnabled = Boolean(settings.get('IRC_Enabled'));
const uid = Meteor.userId();
if (!uid) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'resetIrcConnection' });
}
if (!(await hasPermissionAsync(uid, 'edit-privileged-setting'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'resetIrcConnection' });
}
const auditSettingOperation = updateAuditedByUser({
_id: uid,
username: (await Meteor.userAsync())!.username!,
ip: this.connection?.clientAddress || '',
useragent: this.connection?.httpHeaders['user-agent'] || '',
});
const updatedLastPingValue = await auditSettingOperation(Settings.updateValueById, 'IRC_Bridge_Last_Ping', new Date(0), {
upsert: true,
});
if (updatedLastPingValue.modifiedCount || updatedLastPingValue.upsertedCount) {
void notifyOnSettingChangedById('IRC_Bridge_Last_Ping');
}
const updatedResetTimeValue = await auditSettingOperation(Settings.updateValueById, 'IRC_Bridge_Reset_Time', new Date(), {
upsert: true,
});
if (updatedResetTimeValue.modifiedCount || updatedResetTimeValue.upsertedCount) {
void notifyOnSettingChangedById('IRC_Bridge_Last_Ping');
}
if (!ircEnabled) {
return {
message: 'Connection_Closed',
params: [],
};
}
setTimeout(async () => {
// Normalize the config values
const config = {
server: {
protocol: settings.get('IRC_Protocol'),
host: settings.get('IRC_Host'),
port: settings.get('IRC_Port'),
name: settings.get('IRC_Name'),
description: settings.get('IRC_Description'),
},
passwords: {
local: settings.get('IRC_Local_Password'),
peer: settings.get('IRC_Peer_Password'),
},
};
// TODO: is this the best way to do this? is this really necessary?
Meteor.ircBridge = new Bridge(config);
await Meteor.ircBridge.init();
}, 300);
return {
message: 'Connection_Reset',
params: [],
};
},
});
declare module 'meteor/meteor' {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Meteor {
let ircBridge: Bridge;
}
}

View File

@@ -0,0 +1,519 @@
/**
* This file is part of https://github.com/martynsmith/node-irc
* by https://github.com/martynsmith
*/
module.exports = {
'001': {
name: 'rpl_welcome',
type: 'reply',
},
'002': {
name: 'rpl_yourhost',
type: 'reply',
},
'003': {
name: 'rpl_created',
type: 'reply',
},
'004': {
name: 'rpl_myinfo',
type: 'reply',
},
'005': {
name: 'rpl_isupport',
type: 'reply',
},
'200': {
name: 'rpl_tracelink',
type: 'reply',
},
'201': {
name: 'rpl_traceconnecting',
type: 'reply',
},
'202': {
name: 'rpl_tracehandshake',
type: 'reply',
},
'203': {
name: 'rpl_traceunknown',
type: 'reply',
},
'204': {
name: 'rpl_traceoperator',
type: 'reply',
},
'205': {
name: 'rpl_traceuser',
type: 'reply',
},
'206': {
name: 'rpl_traceserver',
type: 'reply',
},
'208': {
name: 'rpl_tracenewtype',
type: 'reply',
},
'211': {
name: 'rpl_statslinkinfo',
type: 'reply',
},
'212': {
name: 'rpl_statscommands',
type: 'reply',
},
'213': {
name: 'rpl_statscline',
type: 'reply',
},
'214': {
name: 'rpl_statsnline',
type: 'reply',
},
'215': {
name: 'rpl_statsiline',
type: 'reply',
},
'216': {
name: 'rpl_statskline',
type: 'reply',
},
'218': {
name: 'rpl_statsyline',
type: 'reply',
},
'219': {
name: 'rpl_endofstats',
type: 'reply',
},
'221': {
name: 'rpl_umodeis',
type: 'reply',
},
'241': {
name: 'rpl_statslline',
type: 'reply',
},
'242': {
name: 'rpl_statsuptime',
type: 'reply',
},
'243': {
name: 'rpl_statsoline',
type: 'reply',
},
'244': {
name: 'rpl_statshline',
type: 'reply',
},
'250': {
name: 'rpl_statsconn',
type: 'reply',
},
'251': {
name: 'rpl_luserclient',
type: 'reply',
},
'252': {
name: 'rpl_luserop',
type: 'reply',
},
'253': {
name: 'rpl_luserunknown',
type: 'reply',
},
'254': {
name: 'rpl_luserchannels',
type: 'reply',
},
'255': {
name: 'rpl_luserme',
type: 'reply',
},
'256': {
name: 'rpl_adminme',
type: 'reply',
},
'257': {
name: 'rpl_adminloc1',
type: 'reply',
},
'258': {
name: 'rpl_adminloc2',
type: 'reply',
},
'259': {
name: 'rpl_adminemail',
type: 'reply',
},
'261': {
name: 'rpl_tracelog',
type: 'reply',
},
'265': {
name: 'rpl_localusers',
type: 'reply',
},
'266': {
name: 'rpl_globalusers',
type: 'reply',
},
'300': {
name: 'rpl_none',
type: 'reply',
},
'301': {
name: 'rpl_away',
type: 'reply',
},
'302': {
name: 'rpl_userhost',
type: 'reply',
},
'303': {
name: 'rpl_ison',
type: 'reply',
},
'305': {
name: 'rpl_unaway',
type: 'reply',
},
'306': {
name: 'rpl_nowaway',
type: 'reply',
},
'311': {
name: 'rpl_whoisuser',
type: 'reply',
},
'312': {
name: 'rpl_whoisserver',
type: 'reply',
},
'313': {
name: 'rpl_whoisoperator',
type: 'reply',
},
'314': {
name: 'rpl_whowasuser',
type: 'reply',
},
'315': {
name: 'rpl_endofwho',
type: 'reply',
},
'317': {
name: 'rpl_whoisidle',
type: 'reply',
},
'318': {
name: 'rpl_endofwhois',
type: 'reply',
},
'319': {
name: 'rpl_whoischannels',
type: 'reply',
},
'321': {
name: 'rpl_liststart',
type: 'reply',
},
'322': {
name: 'rpl_list',
type: 'reply',
},
'323': {
name: 'rpl_listend',
type: 'reply',
},
'324': {
name: 'rpl_channelmodeis',
type: 'reply',
},
'329': {
name: 'rpl_creationtime',
type: 'reply',
},
'331': {
name: 'rpl_notopic',
type: 'reply',
},
'332': {
name: 'rpl_topic',
type: 'reply',
},
'333': {
name: 'rpl_topicwhotime',
type: 'reply',
},
'341': {
name: 'rpl_inviting',
type: 'reply',
},
'342': {
name: 'rpl_summoning',
type: 'reply',
},
'351': {
name: 'rpl_version',
type: 'reply',
},
'352': {
name: 'rpl_whoreply',
type: 'reply',
},
'353': {
name: 'rpl_namreply',
type: 'reply',
},
'364': {
name: 'rpl_links',
type: 'reply',
},
'365': {
name: 'rpl_endoflinks',
type: 'reply',
},
'366': {
name: 'rpl_endofnames',
type: 'reply',
},
'367': {
name: 'rpl_banlist',
type: 'reply',
},
'368': {
name: 'rpl_endofbanlist',
type: 'reply',
},
'369': {
name: 'rpl_endofwhowas',
type: 'reply',
},
'371': {
name: 'rpl_info',
type: 'reply',
},
'372': {
name: 'rpl_motd',
type: 'reply',
},
'374': {
name: 'rpl_endofinfo',
type: 'reply',
},
'375': {
name: 'rpl_motdstart',
type: 'reply',
},
'376': {
name: 'rpl_endofmotd',
type: 'reply',
},
'381': {
name: 'rpl_youreoper',
type: 'reply',
},
'382': {
name: 'rpl_rehashing',
type: 'reply',
},
'391': {
name: 'rpl_time',
type: 'reply',
},
'392': {
name: 'rpl_usersstart',
type: 'reply',
},
'393': {
name: 'rpl_users',
type: 'reply',
},
'394': {
name: 'rpl_endofusers',
type: 'reply',
},
'395': {
name: 'rpl_nousers',
type: 'reply',
},
'401': {
name: 'err_nosuchnick',
type: 'error',
},
'402': {
name: 'err_nosuchserver',
type: 'error',
},
'403': {
name: 'err_nosuchchannel',
type: 'error',
},
'404': {
name: 'err_cannotsendtochan',
type: 'error',
},
'405': {
name: 'err_toomanychannels',
type: 'error',
},
'406': {
name: 'err_wasnosuchnick',
type: 'error',
},
'407': {
name: 'err_toomanytargets',
type: 'error',
},
'409': {
name: 'err_noorigin',
type: 'error',
},
'411': {
name: 'err_norecipient',
type: 'error',
},
'412': {
name: 'err_notexttosend',
type: 'error',
},
'413': {
name: 'err_notoplevel',
type: 'error',
},
'414': {
name: 'err_wildtoplevel',
type: 'error',
},
'421': {
name: 'err_unknowncommand',
type: 'error',
},
'422': {
name: 'err_nomotd',
type: 'error',
},
'423': {
name: 'err_noadmininfo',
type: 'error',
},
'424': {
name: 'err_fileerror',
type: 'error',
},
'431': {
name: 'err_nonicknamegiven',
type: 'error',
},
'432': {
name: 'err_erroneusnickname',
type: 'error',
},
'433': {
name: 'err_nicknameinuse',
type: 'error',
},
'436': {
name: 'err_nickcollision',
type: 'error',
},
'441': {
name: 'err_usernotinchannel',
type: 'error',
},
'442': {
name: 'err_notonchannel',
type: 'error',
},
'443': {
name: 'err_useronchannel',
type: 'error',
},
'444': {
name: 'err_nologin',
type: 'error',
},
'445': {
name: 'err_summondisabled',
type: 'error',
},
'446': {
name: 'err_usersdisabled',
type: 'error',
},
'451': {
name: 'err_notregistered',
type: 'error',
},
'461': {
name: 'err_needmoreparams',
type: 'error',
},
'462': {
name: 'err_alreadyregistred',
type: 'error',
},
'463': {
name: 'err_nopermforhost',
type: 'error',
},
'464': {
name: 'err_passwdmismatch',
type: 'error',
},
'465': {
name: 'err_yourebannedcreep',
type: 'error',
},
'467': {
name: 'err_keyset',
type: 'error',
},
'471': {
name: 'err_channelisfull',
type: 'error',
},
'472': {
name: 'err_unknownmode',
type: 'error',
},
'473': {
name: 'err_inviteonlychan',
type: 'error',
},
'474': {
name: 'err_bannedfromchan',
type: 'error',
},
'475': {
name: 'err_badchannelkey',
type: 'error',
},
'481': {
name: 'err_noprivileges',
type: 'error',
},
'482': {
name: 'err_chanoprivsneeded',
type: 'error',
},
'483': {
name: 'err_cantkillserver',
type: 'error',
},
'491': {
name: 'err_nooperhost',
type: 'error',
},
'501': {
name: 'err_umodeunknownflag',
type: 'error',
},
'502': {
name: 'err_usersdontmatch',
type: 'error',
},
};

View File

@@ -0,0 +1,195 @@
import { EventEmitter } from 'node:events';
import net from 'node:net';
import util from 'node:util';
import { Logger } from '@rocket.chat/logger';
import localCommandHandlers from './localCommandHandlers';
import parseMessage from './parseMessage';
import peerCommandHandlers from './peerCommandHandlers';
const logger = new Logger('IRC Server');
class RFC2813 {
constructor(config) {
this.config = config;
// Hold registered state
this.registerSteps = [];
this.isRegistered = false;
// Hold peer server information
this.serverPrefix = null;
// Hold the buffer while receiving
this.receiveBuffer = Buffer.from('');
}
/**
* Setup socket
*/
setupSocket() {
// Setup socket
this.socket = new net.Socket();
this.socket.setNoDelay();
this.socket.setEncoding('utf-8');
this.socket.setKeepAlive(true);
this.socket.setTimeout(90000);
this.socket.on('data', this.onReceiveFromPeer.bind(this));
this.socket.on('connect', this.onConnect.bind(this));
this.socket.on('error', (err) => logger.error({ msg: 'Socket error', err }));
this.socket.on('timeout', () => this.log({ msg: 'Timeout' }));
this.socket.on('close', () => this.log({ msg: 'Connection Closed' }));
// Setup local
this.on('onReceiveFromLocal', this.onReceiveFromLocal.bind(this));
}
/**
* Log helper
*/
log(message) {
// TODO logger: debug?
if (typeof message === 'string') {
logger.info({ msg: message });
return;
}
logger.info(message);
}
/**
* Connect
*/
register() {
this.log({
msg: 'Connecting to IRC server',
host: this.config.server.host,
port: this.config.server.port,
});
if (!this.socket) {
this.setupSocket();
}
this.socket.connect(this.config.server.port, this.config.server.host);
}
/**
* Disconnect
*/
disconnect() {
this.log('Disconnecting from server.');
if (this.socket) {
this.socket.destroy();
this.socket = undefined;
}
this.isRegistered = false;
this.registerSteps = [];
}
/**
* Setup the server connection
*/
onConnect() {
this.log('Connected! Registering as server...');
this.write({
command: 'PASS',
parameters: [this.config.passwords.local, '0210', 'ngircd'],
});
this.write({
command: 'SERVER',
parameters: [this.config.server.name],
trailer: this.config.server.description,
});
}
/**
* Sends a command message through the socket
*/
write(command) {
let buffer = command.prefix ? `:${command.prefix} ` : '';
buffer += command.command;
if (command.parameters && command.parameters.length > 0) {
buffer += ` ${command.parameters.join(' ')}`;
}
if (command.trailer) {
buffer += ` :${command.trailer}`;
}
this.log({ msg: 'Sending Command', buffer });
return this.socket.write(`${buffer}\r\n`);
}
/**
*
*
* Peer message handling
*
*
*/
onReceiveFromPeer(chunk) {
if (typeof chunk === 'string') {
this.receiveBuffer += chunk;
} else {
this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]);
}
const lines = this.receiveBuffer.toString().split(/\r\n|\r|\n|\u0007/); // eslint-disable-line no-control-regex
// If the buffer does not end with \r\n, more chunks are coming
if (lines.pop()) {
return;
}
// Reset the buffer
this.receiveBuffer = Buffer.from('');
lines.forEach((line) => {
if (line.length && !line.startsWith('a')) {
const parsedMessage = parseMessage(line);
if (peerCommandHandlers[parsedMessage.command]) {
this.log({ msg: 'Handling peer message', line });
const command = peerCommandHandlers[parsedMessage.command].call(this, parsedMessage);
if (command) {
this.log({ msg: 'Emitting peer command to local', command });
this.emit('peerCommand', command);
}
} else {
this.log({ msg: 'Unhandled peer message', parsedMessage });
}
}
});
}
/**
*
*
* Local message handling
*
*
*/
onReceiveFromLocal(command, parameters) {
if (localCommandHandlers[command]) {
this.log({ msg: 'Handling local command', command });
localCommandHandlers[command].call(this, parameters, this);
} else {
this.log({ msg: 'Unhandled local command', command });
}
}
}
util.inherits(RFC2813, EventEmitter);
export default RFC2813;

View File

@@ -0,0 +1,111 @@
function registerUser(parameters) {
const {
name,
profile: {
irc: { nick, username },
},
} = parameters;
this.write({
prefix: this.config.server.name,
command: 'NICK',
parameters: [nick, 1, username, 'irc.rocket.chat', 1, '+i'],
trailer: name,
});
}
function joinChannel(parameters) {
const {
room: { name: roomName },
user: {
profile: {
irc: { nick },
},
},
} = parameters;
this.write({
prefix: this.config.server.name,
command: 'NJOIN',
parameters: [`#${roomName}`],
trailer: nick,
});
}
function joinedChannel(parameters, handler) {
const roomName = parameters.room?.name;
const nick = parameters.user?.profile?.irc?.nick;
if (!roomName) {
handler.log('Skipping room with no name.');
return;
}
if (!nick) {
handler.log('Skipping user with no irc nick.');
return;
}
this.write({
prefix: nick,
command: 'JOIN',
parameters: [`#${roomName}`],
});
}
function leftChannel(parameters) {
const {
room: { name: roomName },
user: {
profile: {
irc: { nick },
},
},
} = parameters;
this.write({
prefix: nick,
command: 'PART',
parameters: [`#${roomName}`],
});
}
function sentMessage(parameters) {
const {
user: {
profile: {
irc: { nick },
},
},
to,
message,
} = parameters;
// eslint-disable-next-line no-control-regex
const lines = message.toString().split(/\r\n|\r|\n|\u0007/);
for (const line of lines) {
this.write({
prefix: nick,
command: 'PRIVMSG',
parameters: [to],
trailer: line,
});
}
}
function disconnected(parameters) {
const {
user: {
profile: {
irc: { nick },
},
},
} = parameters;
this.write({
prefix: nick,
command: 'QUIT',
});
}
export default { registerUser, joinChannel, joinedChannel, leftChannel, sentMessage, disconnected };

View File

@@ -0,0 +1,69 @@
/**
* This file is part of https://github.com/martynsmith/node-irc
* by https://github.com/martynsmith
*/
const replyFor = require('./codes');
/**
* parseMessage(line, stripColors)
*
* takes a raw "line" from the IRC server and turns it into an object with
* useful keys
* @param {String} line Raw message from IRC server.
* @return {Object} A parsed message object.
*/
module.exports = function parseMessage(line) {
const message = {};
let match;
// Parse prefix
match = line.match(/^:([^ ]+) +/);
if (match) {
message.prefix = match[1];
line = line.replace(/^:[^ ]+ +/, '');
match = message.prefix.match(/^([_a-zA-Z0-9\~\[\]\\`^{}|-]*)(!([^@]+)@(.*))?$/);
if (match) {
message.nick = match[1];
message.user = match[3];
message.host = match[4];
} else {
message.server = message.prefix;
}
}
// Parse command
match = line.match(/^([^ ]+) */);
message.command = match[1];
message.rawCommand = match[1];
message.commandType = 'normal';
line = line.replace(/^[^ ]+ +/, '');
if (replyFor[message.rawCommand]) {
message.command = replyFor[message.rawCommand].name;
message.commandType = replyFor[message.rawCommand].type;
}
message.args = [];
let middle;
let trailing;
// Parse parameters
if (line.search(/^:|\s+:/) !== -1) {
match = line.match(/(.*?)(?:^:|\s+:)(.*)/);
middle = match[1].trimRight();
trailing = match[2];
} else {
middle = line;
}
if (middle.length) {
message.args = middle.split(/ +/);
}
if (typeof trailing !== 'undefined' && trailing.length) {
message.args.push(trailing);
}
return message;
};

View File

@@ -0,0 +1,113 @@
function PASS() {
this.log('Received PASS command, continue registering...');
this.registerSteps.push('PASS');
}
function SERVER(parsedMessage) {
this.log('Received SERVER command, waiting for first PING...');
this.serverPrefix = parsedMessage.prefix;
this.registerSteps.push('SERVER');
}
function PING() {
if (!this.isRegistered && this.registerSteps.length === 2) {
this.log('Received first PING command, server is registered!');
this.isRegistered = true;
this.emit('registered');
}
this.write({
prefix: this.config.server.name,
command: 'PONG',
parameters: [this.config.server.name],
});
}
function NICK(parsedMessage) {
let command;
// Check if the message comes from the server,
// which means it is a new user
if (parsedMessage.prefix === this.serverPrefix) {
command = {
identifier: 'userRegistered',
args: {
nick: parsedMessage.args[0],
username: parsedMessage.args[2],
host: parsedMessage.args[3],
name: parsedMessage.args[6],
},
};
} else {
// Otherwise, it is a nick change
command = {
identifier: 'nickChanged',
args: {
nick: parsedMessage.nick,
newNick: parsedMessage.args[0],
},
};
}
return command;
}
function JOIN(parsedMessage) {
const command = {
identifier: 'joinedChannel',
args: {
roomName: parsedMessage.args[0].substring(1),
nick: parsedMessage.prefix,
},
};
return command;
}
function PART(parsedMessage) {
const command = {
identifier: 'leftChannel',
args: {
roomName: parsedMessage.args[0].substring(1),
nick: parsedMessage.prefix,
},
};
return command;
}
function PRIVMSG(parsedMessage) {
const command = {
identifier: 'sentMessage',
args: {
nick: parsedMessage.prefix,
message: parsedMessage.args[1],
},
};
if (parsedMessage.args[0][0] === '#') {
command.args.roomName = parsedMessage.args[0].substring(1);
} else {
command.args.recipientNick = parsedMessage.args[0];
}
return command;
}
function QUIT(parsedMessage) {
const command = {
identifier: 'disconnected',
args: {
nick: parsedMessage.prefix,
},
};
return command;
}
export default { PASS, SERVER, PING, NICK, JOIN, PART, PRIVMSG, QUIT };

View File

@@ -0,0 +1,3 @@
import RFC2813 from './RFC2813';
export { RFC2813 };

View File

@@ -0,0 +1,41 @@
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../lib/callbacks';
import { SystemLogger } from '../../lib/logger/system';
import { settings } from '../../settings';
import { addWebdavAccountByToken } from '../webdav/methods/addWebdavAccount';
Meteor.startup(() => {
settings.watch('Webdav_Integration_Enabled', (value) => {
if (value) {
return callbacks.add(
'afterValidateLogin',
async (login) => {
const { user } = login;
const { services } = user;
if (!services?.nextcloud) {
return;
}
const token = {
token_type: 'Bearer',
access_token: services.nextcloud.accessToken,
refresh_token: services.nextcloud.refreshToken,
};
const data = {
name: 'Nextcloud',
serverURL: `${services.nextcloud.serverURL}/remote.php/webdav/`,
token,
};
try {
await addWebdavAccountByToken(user._id, data);
} catch (err) {
SystemLogger.error({ err });
}
},
callbacks.priority.MEDIUM,
'add-webdav-server',
);
}
callbacks.remove('afterValidateLogin', 'add-webdav-server');
});
});

View File

@@ -0,0 +1,2 @@
import './lib';
import './addWebdavServer';

View File

@@ -0,0 +1,60 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import { CustomOAuth } from '../../lib/auth-providers/custom-oauth/custom_oauth_server';
import { addPassportCustomOAuth } from '../../lib/oauth/addPassportCustomOAuth';
import { settings } from '../../settings';
const NEXTCLOUD_PATHS = {
serverURL: '',
tokenPath: '/index.php/apps/oauth2/api/v1/token',
tokenSentVia: 'header' as OAuthConfiguration['tokenSentVia'],
authorizePath: '/index.php/apps/oauth2/authorize',
identityPath: '/ocs/v2.php/cloud/user?format=json',
scope: 'openid',
addAutopublishFields: {
forLoggedInUser: ['services.nextcloud'],
forOtherUsers: ['services.nextcloud.name'],
},
};
const Nextcloud = new CustomOAuth('nextcloud', NEXTCLOUD_PATHS);
function configureNextcloudOAuth(): void {
passport.unuse('nextcloud');
const enabled = settings.get<boolean>('Accounts_OAuth_Nextcloud');
if (!enabled) {
return;
}
const serverURL = settings.get<string>('Accounts_OAuth_Nextcloud_URL')?.trim().replace(/\/*$/, '');
const clientId = settings.get<string>('Accounts_OAuth_Nextcloud_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Nextcloud_secret');
if (!serverURL || !clientId || !clientSecret) {
return;
}
const config = { ...NEXTCLOUD_PATHS, serverURL, clientId, clientSecret };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth('nextcloud', config);
return;
}
Nextcloud.configure(config);
}
Meteor.startup(() => {
settings.watchMultiple(
[
'Accounts_OAuth_Nextcloud',
'Accounts_OAuth_Nextcloud_URL',
'Accounts_OAuth_Nextcloud_id',
'Accounts_OAuth_Nextcloud_secret',
'Accounts_OAuth_Use_Modern_Flow',
],
configureNextcloudOAuth,
);
});

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 });

View File

@@ -0,0 +1,141 @@
import { MessageTypes } from '@rocket.chat/message-types';
import { Messages, SmarshHistory, Users, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import moment from 'moment-timezone';
import { sendEmail } from './sendEmail';
import { i18n } from '../../../lib/i18n';
import { settings } from '../../../settings';
const start =
'<table style="width: 100%; border: 1px solid; border-collapse: collapse; table-layout: fixed; margin-top: 10px; font-size: 12px; word-break: break-word;"><tbody>';
const end = '</tbody></table>';
const opentr = '<tr style="border: 1px solid;">';
const closetr = '</tr>';
const open20td = '<td style="border: 1px solid; text-align: center; width: 20%;">';
const open60td = '<td style="border: 1px solid; text-align: left; width: 60%; padding: 0 5px;">';
const closetd = '</td>';
function _getLink(attachment: { title_link: string }): string {
const url = attachment.title_link.replace(/ /g, '%20');
if (url.match(/^(https?:)?\/\//i)) {
return url;
}
return Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX + url;
}
export const generateEml = async (): Promise<void> => {
setImmediate(async () => {
const smarshMissingEmail = settings.get('Smarsh_MissingEmail_Email');
const timeZone = settings.get<string>('Smarsh_Timezone');
// TODO: revisit with more time => This appears to be a super expensive operation, going through all rooms
for await (const room of Rooms.find()) {
const smarshHistory = await SmarshHistory.findOne({ _id: room._id });
const query: Record<string, any> = { rid: room._id };
if (smarshHistory) {
query.ts = { $gt: smarshHistory.lastRan };
}
const date = new Date();
const rows: string[] = [];
const data: {
users: string[];
msgs: number;
files: string[];
time: number;
room: string;
} = {
users: [],
msgs: 0,
files: [],
time: smarshHistory ? moment(date).diff(moment(smarshHistory.lastRan), 'minutes') : moment(date).diff(moment(room.ts), 'minutes'),
room: room.name ? `#${room.name}` : `Direct Message Between: ${room?.usernames?.join(' & ')}`,
};
const cursor = Messages.find(query);
for await (const message of cursor) {
rows.push(opentr);
// The timestamp
rows.push(open20td);
rows.push(moment(message.ts).tz(timeZone).format('YYYY-MM-DD HH-mm-ss z'));
rows.push(closetd);
// The sender
rows.push(open20td);
const sender = await Users.findOne({ _id: message.u._id });
if (!sender) {
return;
}
if (data.users.indexOf(sender?._id) === -1) {
data.users.push(sender._id);
}
// Get the user's email, can be nothing if it is an unconfigured bot account (like rocket.cat)
if (sender.emails?.[0]?.address) {
rows.push(`${sender.name} &lt;${sender.emails[0].address}&gt;`);
} else {
rows.push(`${sender.name} &lt;${smarshMissingEmail}&gt;`);
}
rows.push(closetd);
// The message
rows.push(open60td);
data.msgs++;
if (message.t) {
const messageType = MessageTypes.getType(message);
if (messageType) {
rows.push(messageType.text(i18n.getFixedT('en'), message));
} else {
rows.push(`${message.msg} (${message.t})`);
}
} else if (message.file) {
data.files.push(message.file._id);
rows.push(`${message?.attachments?.[0].title} (${_getLink({ title_link: message?.attachments?.[0].title_link || '' })})})`);
} else if (message.attachments) {
const attaches: string[] = [];
message.attachments.forEach((a) => {
if ('image_url' in a && a.image_url !== undefined) {
attaches.push(a.image_url);
}
// TODO: Verify other type of attachments which need to be handled that aren't file uploads and image urls
// } else {
// console.log(a);
// }
});
rows.push(`${message.msg} (${attaches.join(', ')})`);
} else {
rows.push(message.msg);
}
rows.push(closetd);
rows.push(closetr);
}
if (rows.length !== 0) {
const result = start + rows.join('') + end;
await SmarshHistory.updateOne(
{ _id: room._id },
{
_id: room._id,
lastRan: date,
lastResult: result,
},
{ upsert: true },
);
await sendEmail({
body: result,
subject: `Rocket.Chat, ${data.users.length} Users, ${data.msgs} Messages, ${data.files.length} Files, ${data.time} Minutes, in ${data.room}`,
files: data.files,
});
}
}
});
};

View File

@@ -0,0 +1,33 @@
// Expects the following details:
// {
// body: '<table>',
// subject: 'Rocket.Chat, 17 Users, 24 Messages, 1 File, 799504 Minutes, in #random',
// files: ['i3nc9l3mn']
// }
import { Uploads } from '@rocket.chat/models';
import * as Mailer from '../../../lib/notifications/email/api';
import { settings } from '../../../settings';
import { UploadFS } from '../../../ufs';
export const sendEmail = async (data: { files: string[]; subject: string; body: string }) => {
const attachments = [];
for await (const fileId of data.files) {
const file = await Uploads.findOneById(fileId);
if (file?.store === 'rocketchat_uploads' || file?.store === 'fileSystem') {
const rs = await UploadFS.getStore(file.store).getReadStream(fileId, file);
attachments.push({
filename: file.name,
streamSource: rs,
});
}
}
await Mailer.sendNoWrap({
to: settings.get('Smarsh_Email'),
from: settings.get('From_Email'),
subject: data.subject,
html: data.body,
});
};

View File

@@ -0,0 +1,3 @@
import './functions/sendEmail';
import './functions/generateEml';
import './startup';

View File

@@ -0,0 +1,21 @@
import { cronJobs } from '@rocket.chat/cron';
import { generateEml } from './functions/generateEml';
import { settings } from '../../settings';
import { smarshIntervalValuesToCronMap } from '../../settings/smarsh';
const smarshJobName = 'Smarsh EML Connector';
settings.watchMultiple(
['Smarsh_Enabled', 'Smarsh_Email', 'From_Email', 'Smarsh_Interval'],
async function __addSmarshSyncedCronJobDebounced() {
if (await cronJobs.has(smarshJobName)) {
await cronJobs.remove(smarshJobName);
}
if (settings.get('Smarsh_Enabled') && settings.get('Smarsh_Email') !== '' && settings.get('From_Email') !== '') {
const cronInterval = smarshIntervalValuesToCronMap[settings.get<string>('Smarsh_Interval')];
await cronJobs.add(smarshJobName, cronInterval, async () => generateEml());
}
},
);

View File

@@ -0,0 +1,3 @@
# RocketChat WebDAV
Package for RocketChat users to interact with WebDAV servers (Tested with ownCloud and Nextcloud).

View File

@@ -0,0 +1,5 @@
import './methods/addWebdavAccount';
import './methods/getWebdavFileList';
import './methods/getWebdavFilePreview';
import './methods/getFileFromWebdav';
import './methods/uploadFileToWebdav';

View File

@@ -0,0 +1,11 @@
import type { WebDAVClientOptions } from 'webdav';
export function getWebdavCredentials(account: WebDAVClientOptions): WebDAVClientOptions {
const cred = account.token
? { token: account.token }
: {
username: account.username,
password: account.password,
};
return cred;
}

View File

@@ -0,0 +1,21 @@
import type { IWebdavAccount } from '@rocket.chat/core-typings';
import { WebdavAccounts } from '@rocket.chat/models';
import { getWebdavCredentials } from './getWebdavCredentials';
import { WebdavClientAdapter } from './webdavClientAdapter';
export const uploadFileToWebdav = async (accountId: IWebdavAccount['_id'], fileData: string | Buffer, name: string): Promise<void> => {
const account = await WebdavAccounts.findOneById(accountId);
if (!account) {
throw new Error('error-invalid-account');
}
const uploadFolder = 'Rocket.Chat Uploads/';
const buffer = Buffer.isBuffer(fileData) ? fileData : Buffer.from(fileData);
const cred = getWebdavCredentials(account);
const client = new WebdavClientAdapter(account.serverURL, cred);
// eslint-disable-next-line @typescript-eslint/no-empty-function
await client.createDirectory(uploadFolder).catch(() => {});
await client.putFileContents(`${uploadFolder}/${name}`, buffer, { overwrite: false });
};

View File

@@ -0,0 +1,91 @@
import stream from 'node:stream';
import type { Readable, Writable } from 'node:stream';
import type { WebDAVClient, FileStat, ResponseDataDetailed, WebDAVClientOptions } from 'webdav';
import { createClient } from 'webdav';
export class WebdavClientAdapter {
_client: WebDAVClient;
constructor(serverConfig: string, cred: WebDAVClientOptions) {
if (cred.token) {
this._client = createClient(serverConfig, { token: cred.token });
} else {
this._client = createClient(serverConfig, {
username: cred.username,
password: cred.password,
});
}
}
async stat(path: string): Promise<FileStat | ResponseDataDetailed<FileStat>> {
try {
return await this._client.stat(path);
} catch (error: any) {
throw new Error(error.response?.statusText ? error.response.statusText : 'Error checking if directory exists on webdav');
}
}
async createDirectory(path: string): Promise<void> {
try {
return await this._client.createDirectory(path);
} catch (error: any) {
throw new Error(error.response?.statusText ? error.response.statusText : 'Error creating directory on webdav');
}
}
async deleteFile(path: string): Promise<void> {
try {
return await this._client.deleteFile(path);
} catch (error: any) {
throw new Error(error.response?.statusText ? error.response.statusText : 'Error deleting file on webdav');
}
}
async getFileContents(filename: string): Promise<Buffer> {
try {
return (await this._client.getFileContents(filename)) as Buffer;
} catch (error: any) {
throw new Error(error.response?.statusText ? error.response.statusText : 'Error getting file contents webdav');
}
}
async getDirectoryContents(path: string): Promise<FileStat[] | ResponseDataDetailed<FileStat[]>> {
try {
return await this._client.getDirectoryContents(path);
} catch (error: any) {
throw new Error(error.response?.statusText ? error.response.statusText : 'Error getting directory contents webdav');
}
}
async putFileContents(path: string, data: Buffer, options: Record<string, any> = {}): Promise<any> {
try {
return await this._client.putFileContents(path, data, options);
} catch (error: any) {
throw new Error(error.response?.statusText ?? 'Error updating file contents.');
}
}
createReadStream(path: string, options?: Record<string, any>): Readable {
return this._client.createReadStream(path, options);
}
createWriteStream(path: string, fileSize: number): Writable {
const ws = new stream.PassThrough();
this._client
.customRequest(path, {
method: 'PUT',
headers: {
...(fileSize ? { 'Content-Length': String(fileSize) } : {}),
},
data: ws,
maxRedirects: 0,
})
.catch((err) => {
ws.emit('error', err);
});
return ws;
}
}

View File

@@ -0,0 +1,153 @@
import { api } from '@rocket.chat/core-services';
import type { IWebdavAccountPayload } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { WebdavAccounts } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { WebdavClientAdapter } from '../lib/webdavClientAdapter';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
addWebdavAccount(formData: IWebdavAccountPayload): boolean;
addWebdavAccountByToken(data: IWebdavAccountPayload): boolean;
}
}
export const addWebdavAccountByToken = async (userId: string, data: IWebdavAccountPayload): Promise<boolean> => {
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'addWebdavAccount' });
}
if (!settings.get('Webdav_Integration_Enabled')) {
throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', {
method: 'addWebdavAccount',
});
}
check(
data,
Match.ObjectIncluding({
serverURL: String,
token: Match.ObjectIncluding({
access_token: String,
token_type: String,
refresh_token: Match.Optional(String),
}),
name: Match.Maybe(String),
}),
);
try {
const client = new WebdavClientAdapter(data.serverURL, { token: data.token });
const accountData = {
userId,
serverURL: data.serverURL,
token: data.token,
name: data.name ?? '',
};
await client.stat('/');
await WebdavAccounts.updateOne(
{
userId,
serverURL: data.serverURL,
name: data.name ?? '',
},
{
$set: accountData,
},
{
upsert: true,
},
);
void api.broadcast('notify.webdav', userId, {
type: 'changed',
account: accountData,
});
} catch (error) {
throw new Meteor.Error('could-not-access-webdav', 'Could not access webdav', {
method: 'addWebdavAccount',
});
}
return true;
};
Meteor.methods<ServerMethods>({
async addWebdavAccount(formData) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'addWebdavAccount' });
}
if (!settings.get('Webdav_Integration_Enabled')) {
throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', {
method: 'addWebdavAccount',
});
}
check(
formData,
Match.ObjectIncluding({
serverURL: String,
username: String,
password: String,
name: Match.Maybe(String),
}),
);
const duplicateAccount = await WebdavAccounts.findOneByUserIdServerUrlAndUsername(
{ userId, serverURL: formData.serverURL, username: formData.username },
{},
);
if (duplicateAccount !== null) {
throw new Meteor.Error('duplicated-account', 'Account not found', {
method: 'addWebdavAccount',
});
}
try {
const client = new WebdavClientAdapter(formData.serverURL, {
username: formData.username,
password: formData.password,
});
const accountData = {
userId,
serverURL: formData.serverURL,
username: formData.username,
password: formData.password,
name: formData.name ?? '',
};
await client.stat('/');
await WebdavAccounts.insertOne(accountData);
void api.broadcast('notify.webdav', userId, {
type: 'changed',
account: accountData,
});
} catch (error) {
throw new Meteor.Error('could-not-access-webdav', 'Could not access webdav', {
method: 'addWebdavAccount',
});
}
return true;
},
async addWebdavAccountByToken(data) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'addWebdavAccount' });
}
return addWebdavAccountByToken(userId, data);
},
});

View File

@@ -0,0 +1,49 @@
import type { IWebdavAccount, IWebdavNode } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { WebdavAccounts } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { getWebdavCredentials } from '../lib/getWebdavCredentials';
import { WebdavClientAdapter } from '../lib/webdavClientAdapter';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getFileFromWebdav(accountId: IWebdavAccount['_id'], file: IWebdavNode): Promise<{ success: boolean; data: Uint8Array<ArrayBuffer> }>;
}
}
Meteor.methods<ServerMethods>({
async getFileFromWebdav(accountId, file) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'getFileFromWebdav' });
}
if (!settings.get('Webdav_Integration_Enabled')) {
throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', {
method: 'getFileFromWebdav',
});
}
const account = await WebdavAccounts.findOneByIdAndUserId(accountId, userId, {});
if (!account) {
throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', {
method: 'getFileFromWebdav',
});
}
try {
const cred = getWebdavCredentials(account);
const client = new WebdavClientAdapter(account.serverURL, cred);
const fileContent = await client.getFileContents(file.filename);
const data = new Uint8Array(fileContent);
return { success: true, data };
} catch (error) {
throw new Meteor.Error('unable-to-get-file', 'Unable to get file', {
method: 'getFileFromWebdav',
});
}
},
});

View File

@@ -0,0 +1,49 @@
import type { IWebdavAccount, IWebdavNode } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { WebdavAccounts } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { getWebdavCredentials } from '../lib/getWebdavCredentials';
import { WebdavClientAdapter } from '../lib/webdavClientAdapter';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getWebdavFileList(accountId: IWebdavAccount['_id'], path: string): { success: boolean; data: IWebdavNode[] };
}
}
Meteor.methods<ServerMethods>({
async getWebdavFileList(accountId, path) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'getWebdavFileList' });
}
if (!settings.get('Webdav_Integration_Enabled')) {
throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', {
method: 'getWebdavFileList',
});
}
const account = await WebdavAccounts.findOneByIdAndUserId(accountId, userId, {});
if (!account) {
throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', {
method: 'getWebdavFileList',
});
}
try {
const cred = getWebdavCredentials(account);
const client = new WebdavClientAdapter(account.serverURL, cred);
const data = (await client.getDirectoryContents(path)) as IWebdavNode[];
return { success: true, data };
} catch (error) {
throw new Meteor.Error('could-not-access-webdav', 'Could not access webdav', {
method: 'getWebdavFileList',
});
}
},
});

View File

@@ -0,0 +1,53 @@
import type { IWebdavAccount } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { WebdavAccounts } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { createClient } from 'webdav';
import { settings } from '../../../settings';
import { getWebdavCredentials } from '../lib/getWebdavCredentials';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
getWebdavFilePreview(accountId: IWebdavAccount['_id'], path: string): { success: true; data: ArrayBuffer } | undefined;
}
}
Meteor.methods<ServerMethods>({
async getWebdavFilePreview(accountId, path) {
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid User', {
method: 'getWebdavFilePreview',
});
}
if (!settings.get('Webdav_Integration_Enabled') || !settings.get('Accounts_OAuth_Nextcloud_URL')) {
throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', {
method: 'getWebdavFilePreview',
});
}
const account = await WebdavAccounts.findOneByIdAndUserId(accountId, userId, {});
if (!account) {
throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', {
method: 'getWebdavFilePreview',
});
}
try {
const cred = getWebdavCredentials(account);
const client = createClient(account.serverURL, cred);
const serverURL = settings.get('Accounts_OAuth_Nextcloud_URL');
const res = await client.customRequest(`${serverURL}/index.php/core/preview.png?file=${path}&x=64&y=64`, {
method: 'GET',
responseType: 'arraybuffer',
});
return { success: true, data: res.data as ArrayBuffer };
} catch (error) {
// ignore error
}
},
});

View File

@@ -0,0 +1,65 @@
import { MeteorError } from '@rocket.chat/core-services';
import type { IWebdavAccount } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Logger } from '@rocket.chat/logger';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { uploadFileToWebdav } from '../lib/uploadFileToWebdav';
const logger = new Logger('WebDAV_Upload');
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
uploadFileToWebdav(
accountId: IWebdavAccount['_id'],
fileData: string | Buffer | ArrayBuffer,
name: string,
): { success: boolean; message?: TranslationKey };
}
}
Meteor.methods<ServerMethods>({
async uploadFileToWebdav(accountId, fileData, name) {
if (!Meteor.userId()) {
throw new MeteorError('error-invalid-user', 'Invalid User', {
method: 'uploadFileToWebdav',
});
}
if (!settings.get('Webdav_Integration_Enabled')) {
throw new MeteorError('error-not-allowed', 'WebDAV Integration Not Allowed', {
method: 'uploadFileToWebdav',
});
}
try {
await uploadFileToWebdav(accountId, fileData instanceof ArrayBuffer ? Buffer.from(fileData) : fileData, name);
return { success: true };
} catch (err: any) {
if (typeof err === 'object' && err instanceof Error && err.name === 'error-invalid-account') {
throw new MeteorError(err.name, 'Invalid WebDAV Account', {
method: 'uploadFileToWebdav',
});
}
logger.error({ err });
if (err.response) {
const { status } = err.response;
if (status === 404) {
return { success: false, message: 'webdav-server-not-found' };
}
if (status === 401) {
return { success: false, message: 'error-invalid-account' };
}
if (status === 412) {
return { success: false, message: 'Duplicate_file_name_found' };
}
}
return { success: false, message: 'FileUpload_Error' };
}
},
});