Merge branch 'release/1.0.0'

This commit is contained in:
Will Hunt 2020-12-01 17:09:41 +00:00
commit 20a7ed9702
77 changed files with 9926 additions and 6691 deletions

6
.mocharc.yml Normal file
View file

@ -0,0 +1,6 @@
reporter: list
ui: bdd
require:
- "ts-node/register"
- "source-map-support/register"
recursive: true

View file

@ -1,6 +1,6 @@
dist: xenial
language: node_js
install: npm install
install: yarn
cache:
directories:
@ -8,19 +8,16 @@ cache:
jobs:
include:
# Lint doesn't need to build
- stage: lint
script: npm run lint
node_js: "12"
script: yarn lint
node_js: "14"
- stage: unit tests
# Test already builds
script: npm run test
node_js: "10"
- node_js: "12"
- stage: coverage
# Coverage does NOT build
script: npm run build && npm run coverage
script: yarn test
node_js: "12"
- node_js: "14"
- stage: coverage
script: yarn coverage
node_js: "14"
# NOTE: This is unused atm
# notifications:

View file

@ -1,4 +1,4 @@
FROM node:alpine AS BUILD
FROM node:14-alpine AS BUILD
COPY . /tmp/src
# install some dependencies needed for the build process
RUN apk add --no-cache -t build-deps make gcc g++ python ca-certificates libc-dev wget git
@ -6,7 +6,7 @@ RUN cd /tmp/src \
&& npm install \
&& npm run build
FROM node:alpine
FROM node:14-alpine
ENV NODE_ENV=production
COPY --from=BUILD /tmp/src/build /build
COPY --from=BUILD /tmp/src/config /config

View file

@ -27,7 +27,7 @@ Please also be aware that this is an unoffical project worked on in our spare ti
The bridge has been tested against the [Synapse](https://github.com/matrix-org/synapse) homeserver, although any homeserver
that implements the [AS API](https://matrix.org/docs/spec/application_service/r0.1.0.html) should work with this bridge.
The bridge supports any version of Node.js >= v10.X, including all [current releases](https://nodejs.org/en/about/releases/).
The bridge supports any version of Node.js >= v12.X, including all [current releases](https://nodejs.org/en/about/releases/).
### Setup the bridge

View file

@ -8,6 +8,8 @@ bridge:
# This should be your publically facing URL because Discord may use it to
# fetch media from the media store.
homeserverUrl: "http://localhost:8008"
# The TCP port on which the appservice runs on.
port: 9005
# Interval at which to process users in the 'presence queue'. If you have
# 5 users, one user will be processed every 500 milliseconds according to the
# value below. This has a minimum value of 250.
@ -29,10 +31,18 @@ bridge:
disableReadReceipts: false
# Disable Join Leave echos from matrix
disableJoinLeaveNotifications: false
# Disable Invite echos from matrix
disableInviteNotifications: false
# Auto-determine the language of code blocks (this can be CPU-intensive)
determineCodeLanguage: false
# Authentication configuration for the discord bot.
auth:
# This MUST be a string (wrapped in quotes)
clientID: "12345"
botToken: "foobar"
# You must enable "Privileged Gateway Intents" in your bot settings on discord.com (e.g.g https://discord.com/developers/applications/12345/bot)
# for this to work
usePrivilegedIntents: false
logging:
# What level should the logger output to the console at.
console: "warn" #silly, verbose, info, http, warn, error, silent
@ -48,8 +58,6 @@ logging:
enable:
- "DiscordBot"
database:
userStorePath: "user-store.db"
roomStorePath: "room-store.db"
# You may either use SQLite or Postgresql for the bridge database, which contains
# important mappings for events and user puppeting configurations.
# Use the filename option for SQLite, or connString for Postgresql.
@ -85,10 +93,12 @@ channel:
limits:
# Delay in milliseconds between discord users joining a room.
roomGhostJoinDelay: 6000
# Delay in milliseconds before sending messages to discord to avoid echos.
# (Copies of a sent message may arrive from discord before we've
# Lock timeout in milliseconds before seinding messages to discord to avoid
# echos. Default is rather high as the lock will most likely time out
# before anyways.
# echos = (Copies of a sent message may arrive from discord before we've
# fininished handling it, causing us to echo it back to the room)
discordSendDelay: 750
discordSendDelay: 1500
ghosts:
# Pattern for the ghosts nick, available is :nick, :username, :tag and :id
nickPattern: ":nick"

View file

@ -10,6 +10,8 @@ properties:
type: "string"
homeserverUrl:
type: "string"
port:
type: "number"
presenceInterval:
type: "number"
disablePresence:
@ -22,6 +24,10 @@ properties:
type: "boolean"
disableReadReceipts:
type: "boolean"
disableJoinLeaveNotifications:
type: "boolean"
disableInviteNotifications:
type: "boolean"
auth:
type: "object"
required: ["botToken", "clientID"]
@ -30,6 +36,8 @@ properties:
type: "string"
botToken:
type: "string"
usePrivilegedIntents:
type: "boolean"
logging:
type: "object"
properties:

11
docs/bridge-migrations.md Normal file
View file

@ -0,0 +1,11 @@
# 1.0 Migration (from 0.5.1 or lower)
If you have been linked here, there is an issue with your config on your bridge.
Please follow the following steps:
1. If you have just created a new install OR were previously running 0.5.X,
please remove `roomDataStore` and `userDataStore` from your config file.
2. If this is a existing install but you have not run 0.5.X (0.4.X or lower),
please downgrade to 0.5.X to migrate your database across and then run
this version of the bridge again.

View file

@ -15,7 +15,7 @@ is formatted as https://discordapp.com/channels/``guildid``/``channelid``
* The ``adminme`` script is provided to set Admin/Moderator or any other custom power level to a specific user.
* e.g. To set Alice to Admin on her ``example.com`` HS on default config. (``config.yaml``)
* ``npm run adminme -- -r '!AbcdefghijklmnopqR:example.com' -u '@Alice:example.com' -p '100'``
* ``npm run adminme -- -m '!AbcdefghijklmnopqR:example.com' -u '@Alice:example.com' -p '100'``
* Run ``npm run adminme -- -h`` for usage.
Please note that `!AbcdefghijklmnopqR:example.com` is the internal room id and will always begin with `!`.

View file

@ -13,8 +13,8 @@ has the benefits of (not all of these may be implemented):
Discord is currently __not__ offering any way to authenticate on behalf
of a user _and_ interact on their behalf. The OAuth system does not allow
remote access beyond reading information about the users. While [developers have
expressed a wish for this](https://feedback.discordapp.com/forums/326712-discord-dream-land/suggestions/16753837-support-custom-clients)
,it is my opinion that Discord are unlikely to support this any time soon. With
expressed a wish for this](https://feedback.discordapp.com/forums/326712-discord-dream-land/suggestions/16753837-support-custom-clients),
it is my opinion that Discord are unlikely to support this any time soon. With
all this said, Discord will not be banning users or the bridge itself for acting
on the behalf of the user.

5249
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,16 @@
{
"name": "matrix-appservice-discord",
"version": "0.5.2",
"version": "1.0.0",
"description": "A bridge between Matrix and Discord",
"main": "discordas.js",
"scripts": {
"test": "npm run-script build && mocha --opts test/mocha.opts",
"test": "mocha -r ts-node/register test/config.ts test/test_*.ts test/**/test_*.ts",
"lint": "tslint --project ./tsconfig.json -t stylish",
"coverage": "tsc && nyc mocha",
"coverage": "nyc mocha -r ts-node/register test/config.ts test/test_*.ts test/**/test_*.ts",
"build": "tsc",
"start": "npm run-script build && node ./build/src/discordas.js -p 9005 -c config.yaml",
"postinstall": "npm run build",
"start": "npm run-script build && node ./build/src/discordas.js -c config.yaml",
"debug": "npm run-script build && node --inspect ./build/src/discordas.js -c config.yaml",
"addbot": "node ./build/tools/addbot.js",
"adminme": "node ./build/tools/adminme.js",
"usertool": "node ./build/tools/userClientTools.js",
@ -34,39 +36,42 @@
},
"homepage": "https://github.com/Half-Shot/matrix-appservice-discord#readme",
"dependencies": {
"better-sqlite3": "^5.4.3",
"command-line-args": "^4.0.1",
"command-line-usage": "^4.1.0",
"discord-markdown": "^2.2.0",
"discord.js": "^11.5.1",
"better-discord.js": "git://github.com/Sorunome/better-discord.js.git#b5a28499899fe2d9e6aa1aa3b3c5d693ae672117",
"better-sqlite3": "^7.1.0",
"command-line-args": "^5.1.1",
"command-line-usage": "^6.1.0",
"escape-html": "^1.0.3",
"escape-string-regexp": "^1.0.5",
"js-yaml": "^3.13.1",
"matrix-appservice-bridge": "matrix-org/matrix-appservice-bridge#8a7288edf1d1d1d1395a83d330d836d9c9bf1e76",
"mime": "^1.6.0",
"node-html-parser": "^1.1.11",
"p-queue": "^6.0.1",
"pg-promise": "^8.5.1",
"prom-client": "^11.3.0",
"tslint": "^5.11.0",
"typescript": "^3.1.3",
"winston": "^3.0.0",
"winston-daily-rotate-file": "^3.3.0"
"escape-string-regexp": "^4.0.0",
"js-yaml": "^3.14.0",
"marked": "^1.2.2",
"matrix-bot-sdk": "0.5.4",
"matrix-discord-parser": "0.1.5",
"mime": "^2.4.6",
"node-html-parser": "^1.2.19",
"p-queue": "^6.4.0",
"pg-promise": "^10.5.6",
"prom-client": "^12.0.0",
"winston": "^3.2.1",
"winston-daily-rotate-file": "^4.5.0"
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^0.1.3",
"@types/chai": "^3.4.35",
"@types/mocha": "^5.2.5",
"@types/node": "^10.12.0",
"@types/sqlite3": "^3.1.3",
"chai": "^3.5.0",
"eslint": "^3.8.1",
"istanbul": "^0.4.5",
"mocha": "^5.2.0",
"nyc": "^14.1.1",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@types/chai": "^4.2.11",
"@types/command-line-args": "^5.0.0",
"@types/js-yaml": "^3.12.4",
"@types/marked": "^1.1.0",
"@types/mime": "^2.0.2",
"@types/mocha": "^7.0.2",
"@types/node": "^12",
"@types/better-sqlite3": "^5.4.1",
"chai": "^4.2.0",
"mocha": "^8.0.1",
"nyc": "^15.1.0",
"proxyquire": "^1.7.11",
"source-map-support": "^0.5.12",
"ts-node": "^8.1.0",
"why-is-node-running": "^2.0.3"
"source-map-support": "^0.5.19",
"ts-node": "^8.10.2",
"tslint": "^5.20.1",
"typescript": "^3.9.5",
"why-is-node-running": "^2.2.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Discord from "discord.js";
import * as Discord from "better-discord.js";
import { DiscordBot } from "./bot";
import { Util } from "./util";
import { DiscordBridgeConfig, DiscordBridgeConfigChannelDeleteOptions } from "./config";
import { Bridge } from "matrix-appservice-bridge";
import { Log } from "./log";
import { DbRoomStore, IRoomStoreEntry } from "./db/roomstore";
import { Appservice } from "matrix-bot-sdk";
const log = new Log("ChannelSync");
@ -58,7 +58,7 @@ export interface IChannelState {
export class ChannelSyncroniser {
constructor(
private bridge: Bridge,
private bridge: Appservice,
private config: DiscordBridgeConfig,
private bot: DiscordBot,
private roomStore: DbRoomStore,
@ -81,7 +81,7 @@ export class ChannelSyncroniser {
public async OnGuildUpdate(guild: Discord.Guild, force = false) {
log.verbose(`Got guild update for guild ${guild.id}`);
const channelStates: IChannelState[] = [];
for (const [_, channel] of guild.channels) {
for (const [_, channel] of guild.channels.cache) {
if (channel.type !== "text") {
continue; // not supported for now
}
@ -144,7 +144,7 @@ export class ChannelSyncroniser {
}
public async OnGuildDelete(guild: Discord.Guild) {
for (const [_, channel] of guild.channels) {
for (const [_, channel] of guild.channels.cache) {
try {
await this.OnDelete(channel);
} catch (e) {
@ -169,21 +169,32 @@ export class ChannelSyncroniser {
try {
rooms = await this.GetRoomIdsFromChannel(channel);
} catch (err) { } // do nothing, our rooms array will just be empty
let fallbackAlias = "";
for (const room of rooms) {
try {
const al = (await this.bridge.getIntent().getClient()
.getStateEvent(room, "m.room.canonical_alias")).alias;
const al = (await this.bridge.botIntent.underlyingClient.getRoomStateEvent(
room,
"m.room.canonical_alias",
"")
).alias;
if (al) {
return al; // we are done, we found an alias
if (this.bridge.isNamespacedAlias(al)) {
fallbackAlias = al;
} else {
return al; // we are done, we found an alias
}
}
} catch (err) { } // do nothing, as if we error we just roll over to the next entry
}
if (fallbackAlias) {
return fallbackAlias;
}
const guildChannel = channel as Discord.TextChannel;
if (!guildChannel.guild) {
return null; // we didn't pass a guild, so we have no way of bridging this room, thus no alias
}
// at last, no known canonical aliases and we are ag uild....so we know an alias!
return `#_discord_${guildChannel.guild.id}_${channel.id}:${this.config.bridge.domain}`;
// at last, no known canonical aliases and we are a guild....so we know an alias!
return this.bridge.getAliasForSuffix(`${guildChannel.guild.id}_${channel.id}`);
}
public async GetChannelUpdateState(channel: Discord.TextChannel, forceUpdate = false): Promise<IChannelState> {
@ -252,7 +263,7 @@ export class ChannelSyncroniser {
}
private async ApplyStateToChannel(channelsState: IChannelState) {
const intent = this.bridge.getIntent();
const intent = this.bridge.botIntent;
for (const channelState of channelsState.mxChannels) {
let roomUpdated = false;
const remoteRoom = (await this.roomStore.getEntriesByMatrixId(channelState.mxid))[0];
@ -262,14 +273,24 @@ export class ChannelSyncroniser {
}
if (channelState.name !== null) {
log.verbose(`Updating channelname for ${channelState.mxid} to "${channelState.name}"`);
await intent.setRoomName(channelState.mxid, channelState.name);
await intent.underlyingClient.sendStateEvent(
channelState.mxid,
"m.room.name",
"",
{ name: channelState.name },
);
remoteRoom.remote.set("discord_name", channelState.name);
roomUpdated = true;
}
if (channelState.topic !== null) {
log.verbose(`Updating channeltopic for ${channelState.mxid} to "${channelState.topic}"`);
await intent.setRoomTopic(channelState.mxid, channelState.topic);
await intent.underlyingClient.sendStateEvent(
channelState.mxid,
"m.room.topic",
"",
{ topic: channelState.topic },
);
remoteRoom.remote.set("discord_topic", channelState.topic);
roomUpdated = true;
}
@ -277,14 +298,21 @@ export class ChannelSyncroniser {
if (channelState.iconUrl !== null && channelState.iconId !== null) {
log.verbose(`Updating icon_url for ${channelState.mxid} to "${channelState.iconUrl}"`);
if (channelsState.iconMxcUrl === null) {
const iconMxc = await Util.UploadContentFromUrl(
channelState.iconUrl,
intent,
const file = await Util.DownloadFile(channelState.iconUrl);
const iconMxc = await this.bridge.botIntent.underlyingClient.uploadContent(
file.buffer,
file.mimeType,
channelState.iconId,
);
channelsState.iconMxcUrl = iconMxc.mxcUrl;
channelsState.iconMxcUrl = iconMxc;
}
await intent.setRoomAvatar(channelState.mxid, channelsState.iconMxcUrl);
await intent.underlyingClient.sendStateEvent(
channelState.mxid,
"m.room.avatar",
"",
// TODO: "info" object for avatar
{ url: channelsState.iconMxcUrl },
);
remoteRoom.remote.set("discord_iconurl", channelState.iconUrl);
remoteRoom.remote.set("discord_iconurl_mxc", channelsState.iconMxcUrl);
roomUpdated = true;
@ -292,7 +320,12 @@ export class ChannelSyncroniser {
if (channelState.removeIcon) {
log.verbose(`Clearing icon_url for ${channelState.mxid}`);
await intent.setRoomAvatar(channelState.mxid, null);
await intent.underlyingClient.sendStateEvent(
channelState.mxid,
"m.room.avatar",
"",
{ },
);
remoteRoom.remote.set("discord_iconurl", null);
remoteRoom.remote.set("discord_iconurl_mxc", null);
roomUpdated = true;
@ -310,36 +343,47 @@ export class ChannelSyncroniser {
entry: IRoomStoreEntry,
overrideOptions?: DiscordBridgeConfigChannelDeleteOptions): Promise<void> {
log.info(`Deleting ${channel.id} from ${roomId}.`);
const intent = await this.bridge.getIntent();
const intent = this.bridge.botIntent;
const client = this.bridge.botClient;
const options = overrideOptions || this.config.channel.deleteOptions;
const plumbed = entry.remote!.get("plumbed");
await this.roomStore.upsertEntry(entry);
if (options.ghostsLeave) {
for (const member of channel.members.array()) {
const mIntent = await this.bot.GetIntentFromDiscordMember(member);
// Not awaiting this because we want to do this in the background.
mIntent.leave(roomId).then(() => {
try {
const mIntent = this.bot.GetIntentFromDiscordMember(member);
await client.leaveRoom(roomId);
log.verbose(`${member.id} left ${roomId}.`);
}).catch(() => {
log.warn(`Failed to make ${member.id} leave.`);
});
} catch (e) {
log.warn(`Failed to make ${member.id} leave `);
}
}
}
if (options.namePrefix) {
try {
const name = await intent.getClient().getStateEvent(roomId, "m.room.name");
const name = await client.getRoomStateEvent(roomId, "m.room.name", "");
name.name = options.namePrefix + name.name;
await intent.getClient().setRoomName(roomId, name.name);
await client.sendStateEvent(
roomId,
"m.room.name",
"",
name,
);
} catch (e) {
log.error(`Failed to set name of room ${roomId} ${e}`);
}
}
if (options.topicPrefix) {
try {
const topic = await intent.getClient().getStateEvent(roomId, "m.room.topic");
const topic = await client.getRoomStateEvent(roomId, "m.room.topic", "");
topic.topic = options.topicPrefix + topic.topic;
await intent.getClient().setRoomTopic(roomId, topic.topic);
await client.sendStateEvent(
roomId,
"m.room.topic",
"",
topic,
);
} catch (e) {
log.error(`Failed to set topic of room ${roomId} ${e}`);
}
@ -349,11 +393,15 @@ export class ChannelSyncroniser {
if (options.unsetRoomAlias) {
try {
const alias = `#_${entry.remote!.roomId}:${this.config.bridge.domain}`;
const canonicalAlias = await intent.getClient().getStateEvent(roomId, "m.room.canonical_alias");
const canonicalAlias = await client.getRoomStateEvent(
roomId,
"m.room.canonical_alias",
"",
);
if (canonicalAlias.alias === alias) {
await intent.getClient().sendStateEvent(roomId, "m.room.canonical_alias", {});
await client.sendStateEvent(roomId, "m.room.canonical_alias", "", {});
}
await intent.getClient().deleteAlias(alias);
await client.deleteRoomAlias(alias);
} catch (e) {
log.error(`Couldn't remove alias of ${roomId} ${e}`);
}
@ -361,7 +409,7 @@ export class ChannelSyncroniser {
if (options.unlistFromDirectory) {
try {
await intent.getClient().setRoomDirectoryVisibility(roomId, "private");
await client.setDirectoryVisibility(roomId, "private");
} catch (e) {
log.error(`Couldn't remove ${roomId} from room directory ${e}`);
}
@ -370,7 +418,12 @@ export class ChannelSyncroniser {
if (options.setInviteOnly) {
try {
await intent.getClient().sendStateEvent(roomId, "m.room.join_rules", {join_role: "invite"});
await client.sendStateEvent(
roomId,
"m.room.join_rules",
"",
{join_role: "invite"},
);
} catch (e) {
log.error(`Couldn't set ${roomId} to private ${e}`);
}
@ -378,9 +431,9 @@ export class ChannelSyncroniser {
if (options.disableMessaging) {
try {
const state = await intent.getClient().getStateEvent(roomId, "m.room.power_levels");
const state = await client.getRoomStateEvent(roomId, "m.room.power_levels", "");
state.events_default = POWER_LEVEL_MESSAGE_TALK;
await intent.getClient().sendStateEvent(roomId, "m.room.power_levels", state);
await client.sendStateEvent(roomId, "m.room.power_levels", "", state);
} catch (e) {
log.error(`Couldn't disable messaging for ${roomId} ${e}`);
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import { DiscordBridgeConfigAuth } from "./config";
import { DiscordStore } from "./store";
import { Client as DiscordClient, TextChannel } from "discord.js";
import { Client as DiscordClient, Intents, TextChannel } from "better-discord.js";
import { Log } from "./log";
import { MetricPeg } from "./metrics";
@ -40,13 +40,23 @@ export class DiscordClientFactory {
// We just need to make sure we have a bearer token.
// Create a new Bot client.
this.botClient = new DiscordClient({
fetchAllMembers: true,
fetchAllMembers: this.config.usePrivilegedIntents,
messageCacheLifetime: 5,
sync: true,
ws: {
intents: this.config.usePrivilegedIntents ? Intents.PRIVILEGED : Intents.NON_PRIVILEGED,
},
});
const waitPromise = new Promise((resolve, reject) => {
this.botClient.once("shardReady", resolve);
this.botClient.once("shardError", reject);
});
try {
await this.botClient.login(this.config.botToken);
await this.botClient.login(this.config.botToken, true);
log.info("Waiting for shardReady signal");
await waitPromise;
log.info("Got shardReady signal");
} catch (err) {
log.error("Could not login as the bot user. This is bad!", err);
throw err;
@ -58,16 +68,17 @@ export class DiscordClientFactory {
const client = new DiscordClient({
fetchAllMembers: false,
messageCacheLifetime: 5,
sync: false,
ws: {
intents: Intents.NON_PRIVILEGED,
},
});
await client.login(token);
const id = client.user.id;
// This can be done asynchronously, because we don't need to block to return the id.
client.destroy().catch((err) => {
log.warn("Failed to destroy client ", id);
});
await client.login(token, false);
const id = client.user?.id;
client.destroy();
if (!id) {
throw Error("Client did not have a user object, cannot determine ID");
}
return id;
}
@ -88,9 +99,11 @@ export class DiscordClientFactory {
// TODO: Select a profile based on preference, not the first one.
const token = await this.store.getToken(discordIds[0]);
const client = new DiscordClient({
fetchAllMembers: true,
fetchAllMembers: false,
messageCacheLifetime: 5,
sync: true,
ws: {
intents: Intents.NON_PRIVILEGED,
},
});
const jsLog = new Log("discord.js-ppt");
@ -99,7 +112,7 @@ export class DiscordClientFactory {
client.on("warn", (msg) => { jsLog.warn(msg); });
try {
await client.login(token);
await client.login(token, false);
log.verbose("Logged in. Storing ", userId);
this.clients.set(userId, client);
return client;

View file

@ -28,6 +28,7 @@ export class DiscordBridgeConfig {
public channel: DiscordBridgeConfigChannel = new DiscordBridgeConfigChannel();
public limits: DiscordBridgeConfigLimits = new DiscordBridgeConfigLimits();
public ghosts: DiscordBridgeConfigGhosts = new DiscordBridgeConfigGhosts();
public metrics: DiscordBridgeConfigMetrics = new DiscordBridgeConfigMetrics();
/**
* Apply a set of keys and values over the default config.
@ -80,6 +81,8 @@ export class DiscordBridgeConfig {
class DiscordBridgeConfigBridge {
public domain: string;
public homeserverUrl: string;
public port: number;
public bindAddress: string;
public presenceInterval: number = 500;
public disablePresence: boolean;
public disableTypingNotifications: boolean;
@ -90,12 +93,14 @@ class DiscordBridgeConfigBridge {
public disableEveryoneMention: boolean = false;
public disableHereMention: boolean = false;
public disableJoinLeaveNotifications: boolean = false;
public enableMetrics: boolean = false;
public disableInviteNotifications: boolean = false;
public determineCodeLanguage: boolean = false;
}
export class DiscordBridgeConfigDatabase {
public connString: string;
public filename: string;
// These parameters are legacy, and will stop the bridge if defined.
public userStorePath: string;
public roomStorePath: string;
}
@ -103,6 +108,7 @@ export class DiscordBridgeConfigDatabase {
export class DiscordBridgeConfigAuth {
public clientID: string;
public botToken: string;
public usePrivilegedIntents: boolean;
}
export class DiscordBridgeConfigLogging {
@ -133,7 +139,7 @@ export class DiscordBridgeConfigChannelDeleteOptions {
class DiscordBridgeConfigLimits {
public roomGhostJoinDelay: number = 6000;
public discordSendDelay: number = 750;
public discordSendDelay: number = 1500;
}
export class LoggingFile {
@ -150,3 +156,9 @@ class DiscordBridgeConfigGhosts {
public nickPattern: string = ":nick";
public usernamePattern: string = ":username#:tag";
}
export class DiscordBridgeConfigMetrics {
public enable: boolean;
public port: number = 9001;
public host: string = "127.0.0.1";
}

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
import { DiscordStore } from "../../store";
import { DiscordBridgeConfigDatabase } from "../../config";
export interface IDbSchema {
description: string;
run(store: DiscordStore): Promise<null|void|Error|Error[]>;

48
src/db/schema/v11.ts Normal file
View file

@ -0,0 +1,48 @@
/*
Copyright 2019 matrix-appservice-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {IDbSchema} from "./dbschema";
import {DiscordStore} from "../../store";
import { Log } from "../../log";
const log = new Log("SchemaV11");
export class Schema implements IDbSchema {
public description = "create stores for bot sdk";
public async run(store: DiscordStore): Promise<void> {
try {
await store.createTable(
"CREATE TABLE registered_users (user_id TEXT UNIQUE NOT NULL);",
"registered_users",
);
await store.createTable(
"CREATE TABLE as_txns (txn_id TEXT UNIQUE NOT NULL);",
"as_txns",
);
} catch (ex) {
log.error("Failed to apply indexes:", ex);
}
}
public async rollBack(store: DiscordStore): Promise<void> {
await store.db.Exec(
`DROP TABLE IF EXISTS registered_users;
DROP TABLE IF EXISTS as_txns;`,
);
}
}

View file

@ -78,8 +78,8 @@ directory.`);
log.info("Moving ", row.userId);
try {
const client = await clientFactory.getClient(row.token);
const dId = client.user.id;
if (dId === null) {
const dId = client.user?.id;
if (!dId) {
continue;
}
log.verbose("INSERT INTO discord_id_token.");

View file

@ -17,16 +17,11 @@ limitations under the License.
import {IDbSchema} from "./dbschema";
import {DiscordStore} from "../../store";
import { Log } from "../../log";
import {
RoomStore,
} from "matrix-appservice-bridge";
import { RemoteStoreRoom, MatrixStoreRoom } from "../roomstore";
const log = new Log("SchemaV8");
export class Schema implements IDbSchema {
public description = "create room store tables";
constructor(private roomStore: RoomStore|null) {
constructor() {
}
@ -56,33 +51,9 @@ export class Schema implements IDbSchema {
PRIMARY KEY(id)
);`, "room_entries");
if (this.roomStore === null) {
log.warn("Not migrating rooms from room store, room store is null");
return;
}
log.warn("Migrating rooms from roomstore, this may take a while...");
const rooms = await this.roomStore.select({});
log.info(`Found ${rooms.length} rooms in the DB`);
// Matrix room only entrys are useless.
const entrys = rooms.filter((r) => r.remote);
log.info(`Filtered out rooms without remotes. Have ${entrys.length} entries`);
let migrated = 0;
for (const e of entrys) {
const matrix = new MatrixStoreRoom(e.matrix_id);
try {
const remote = new RemoteStoreRoom(e.remote_id, e.remote);
await store.roomStore.linkRooms(matrix, remote);
log.info(`Migrated ${matrix.roomId}`);
migrated++;
} catch (ex) {
log.error(`Failed to link ${matrix.roomId}: `, ex);
}
}
if (migrated !== entrys.length) {
log.error(`Didn't migrate all rooms, ${entrys.length - migrated} failed to be migrated.`);
} else {
log.info("Migrated all rooms successfully");
}
// XXX: This used to migrate rooms across from the old room store format but
// since we moved to the matrix-js-bot-sdk, we can no longer do this. Please
// use a 0.X release for this.
}
public async rollBack(store: DiscordStore): Promise<void> {

View file

@ -16,18 +16,11 @@ limitations under the License.
import { IDbSchema } from "./dbschema";
import { DiscordStore } from "../../store";
import { Log } from "../../log";
import {
UserStore,
} from "matrix-appservice-bridge";
import { RemoteUser } from "../userstore";
import PQueue from "p-queue";
const log = new Log("SchemaV9");
export class Schema implements IDbSchema {
public description = "create user store tables";
constructor(private userStore: UserStore|null) {
constructor() {
}
@ -56,54 +49,9 @@ export class Schema implements IDbSchema {
PRIMARY KEY(matrix_id, remote_id)
);`, "user_entries");
if (this.userStore === null) {
log.warn("Not migrating users from users store, users store is null");
return;
}
log.warn("Migrating users from userstore, this may take a while...");
const remoteUsers = await this.userStore.select({type: "remote"});
log.info(`Found ${remoteUsers.length} remote users in the DB`);
let migrated = 0;
const processQueue = new PQueue({
autoStart: true,
concurrency: 100,
});
for (const user of remoteUsers) {
const matrixIds = await this.userStore.getMatrixLinks(user.id);
if (!matrixIds || matrixIds.length === 0) {
log.warn(`Not migrating ${user.id}, has no linked matrix user`);
continue;
} else if (matrixIds.length > 1) {
log.warn(`Multiple matrix ids for ${user.id}, using first`);
}
const matrixId = matrixIds[0];
try {
const remote = new RemoteUser(user.id);
remote.avatarurl = user.data.avatarurl;
remote.avatarurlMxc = user.data.avatarurl_mxc;
remote.displayname = user.data.displayname;
Object.keys(user.data).filter((k) => k.startsWith("nick_")).forEach((k) => {
remote.guildNicks.set(k.substr("nick_".length), user.data[k]);
});
processQueue.add(async () => {
await store.userStore.linkUsers(matrixId, remote.id);
return store.userStore.setRemoteUser(remote);
}).then(() => {
log.info(`Migrated ${matrixId}, ${processQueue.pending} to go.`);
migrated++;
}).catch((err) => {
log.error(`Failed to migrate ${matrixId} ${err}`);
});
} catch (ex) {
log.error(`Failed to link ${matrixId}: `, ex);
}
}
await processQueue.onIdle();
if (migrated !== remoteUsers.length) {
log.error(`Didn't migrate all users, ${remoteUsers.length - migrated} failed to be migrated.`);
} else {
log.info("Migrated all users successfully");
}
// XXX: This used to migrate rooms across from the old room store format but
// since we moved to the matrix-js-bot-sdk, we can no longer do this. Please
// use a 0.X release for this.
}
public async rollBack(store: DiscordStore): Promise<void> {

View file

@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Database from "better-sqlite3";
import * as BetterSQLite3 from "better-sqlite3";
import { Log } from "../log";
import { IDatabaseConnector, ISqlCommandParameters, ISqlRow } from "./connector";
const log = new Log("SQLite3");
export class SQLite3 implements IDatabaseConnector {
private db: Database;
private db: BetterSQLite3.Database;
constructor(private filename: string) {
}
public async Open() {
log.info(`Opening ${this.filename}`);
this.db = new Database(this.filename);
this.db = new BetterSQLite3(this.filename);
}
public async Get(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow|null> {
@ -42,7 +42,7 @@ export class SQLite3 implements IDatabaseConnector {
public async Run(sql: string, parameters?: ISqlCommandParameters): Promise<void> {
log.silly("Run:", sql);
return this.db.prepare(sql).run(parameters || []);
this.db.prepare(sql).run(parameters || []);
}
public async Close(): Promise<void> {
@ -51,6 +51,6 @@ export class SQLite3 implements IDatabaseConnector {
public async Exec(sql: string): Promise<void> {
log.silly("Exec:", sql);
return this.db.exec(sql);
this.db.exec(sql);
}
}

View file

@ -13,8 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Cli, Bridge, AppServiceRegistration, ClientFactory, BridgeContext } from "matrix-appservice-bridge";
import { Appservice, IAppserviceRegistration, LogService } from "matrix-bot-sdk";
import * as yaml from "js-yaml";
import * as fs from "fs";
import { DiscordBridgeConfig } from "./config";
@ -22,185 +21,188 @@ import { DiscordBot } from "./bot";
import { DiscordStore } from "./store";
import { Log } from "./log";
import "source-map-support/register";
import * as cliArgs from "command-line-args";
import * as usage from "command-line-usage";
import * as uuid from "uuid/v4";
import { IMatrixEvent } from "./matrixtypes";
import { MetricPeg, PrometheusBridgeMetrics } from "./metrics";
const log = new Log("DiscordAS");
const cli = new Cli({
bridgeConfig: {
affectsRegistration: true,
schema: "./config/config.schema.yaml",
},
generateRegistration,
registrationPath: "discord-registration.yaml",
run,
});
const commandOptions = [
{ name: "config", alias: "c", type: String },
{ name: "url", alias: "u", type: String },
{ name: "port", alias: "p", type: Number },
{ name: "file", alias: "f", type: String },
{ name: "generate-registration", alias: "r", type: Boolean },
{ name: "help", alias: "h", type: Boolean },
];
try {
cli.run();
} catch (err) {
log.error("Failed to start bridge.");
log.error(err);
function generateRegistration(opts, registrationPath) {
if (!opts.url) {
throw Error("'url' not given in command line opts, cannot generate registration file");
}
const reg = {
as_token: uuid(),
hs_token: uuid(),
id: "discord-bridge",
namespaces: {
aliases: [
{
exclusive: true,
regex: "#_discord_.*",
},
],
rooms: [ ],
users: [
{
exclusive: true,
regex: "@_discord_.*",
},
],
},
protocols: ["discord"],
rate_limited: false,
sender_localpart: "_discord_bot",
url: opts.url,
} as IAppserviceRegistration;
fs.writeFileSync(registrationPath, yaml.safeDump(reg));
}
function generateRegistration(reg, callback) {
reg.setId(AppServiceRegistration.generateToken());
reg.setHomeserverToken(AppServiceRegistration.generateToken());
reg.setAppServiceToken(AppServiceRegistration.generateToken());
reg.setSenderLocalpart("_discord_bot");
reg.addRegexPattern("users", "@_discord_.*", true);
reg.addRegexPattern("aliases", "#_discord_.*", true);
reg.setRateLimited(false);
reg.setProtocols(["discord"]);
callback(reg);
function setupLogging() {
const logMap = new Map<string, Log>();
// tslint:disable-next-line:no-any
const logFunc = (level: string, module: string, args: any[]) => {
if (!Array.isArray(args)) {
args = [args];
}
if (args.find((s) => s.includes && s.includes("M_USER_IN_USE"))) {
// Spammy logs begon
return;
}
const mod = "bot-sdk" + module;
let logger = logMap.get(mod);
if (!logger) {
logger = new Log(mod);
logMap.set(mod, logger);
}
logger[level](args);
};
LogService.setLogger({
// tslint:disable-next-line:no-any
debug: (mod: string, args: any[]) => logFunc("silly", mod, args),
// tslint:disable-next-line:no-any
error: (mod: string, args: any[]) => logFunc("error", mod, args),
// tslint:disable-next-line:no-any
info: (mod: string, args: any[]) => logFunc("info", mod, args),
// tslint:disable-next-line:no-any
warn: (mod: string, args: any[]) => logFunc("warn", mod, args),
});
}
// tslint:disable-next-line no-any
type callbackFn = (...args: any[]) => Promise<any>;
async function run() {
const opts = cliArgs(commandOptions);
if (opts.help) {
/* tslint:disable:no-console */
console.log(usage([
{
content: "The matrix appservice for discord",
header: "Matrix Discord Bridge",
},
{
header: "Options",
optionList: commandOptions,
},
]));
process.exit(0);
}
const configPath = opts.config || "config.yaml";
const registrationPath = opts.file || "discord-registration.yaml";
if (opts["generate-registration"]) {
if (fs.existsSync(registrationPath)) {
throw Error("Not writing new registration file, file already exists");
}
generateRegistration(opts, registrationPath);
return;
}
async function run(port: number, fileConfig: DiscordBridgeConfig) {
const config = new DiscordBridgeConfig();
config.applyConfig(fileConfig);
const readConfig = yaml.safeLoad(fs.readFileSync(configPath, "utf8"));
if (typeof readConfig !== "object") {
throw Error("Config is not of type object");
}
config.applyConfig(readConfig);
config.applyEnvironmentOverrides(process.env);
Log.Configure(config.logging);
log.info("Starting Discord AS");
const yamlConfig = yaml.safeLoad(fs.readFileSync(cli.opts.registrationPath, "utf8"));
const registration = AppServiceRegistration.fromObject(yamlConfig);
if (registration === null) {
throw new Error("Failed to parse registration file");
const port = opts.port || config.bridge.port;
if (!port) {
throw Error("Port not given in command line or config file");
}
if (config.database.roomStorePath || config.database.userStorePath) {
log.error("The keys 'roomStorePath' and/or 'userStorePath' is still defined in the config. " +
"Please see docs/bridge-migrations.md on " +
"https://github.com/Half-Shot/matrix-appservice-discord/");
throw Error("Bridge has legacy configuration options and is unable to start");
}
const registration = yaml.safeLoad(fs.readFileSync(registrationPath, "utf8")) as IAppserviceRegistration;
setupLogging();
const botUserId = `@${registration.sender_localpart}:${config.bridge.domain}`;
const clientFactory = new ClientFactory({
appServiceUserId: botUserId,
token: registration.as_token,
url: config.bridge.homeserverUrl,
});
const store = new DiscordStore(config.database);
const callbacks: { [id: string]: callbackFn; } = {};
const bridge = new Bridge({
clientFactory,
controller: {
// onUserQuery: userQuery,
onAliasQueried: async (alias: string, roomId: string) => {
try {
return await callbacks.onAliasQueried(alias, roomId);
} catch (err) { log.error("Exception thrown while handling \"onAliasQueried\" event", err); }
},
onAliasQuery: async (alias: string, aliasLocalpart: string) => {
try {
return await callbacks.onAliasQuery(alias, aliasLocalpart);
} catch (err) { log.error("Exception thrown while handling \"onAliasQuery\" event", err); }
},
onEvent: async (request) => {
const data = request.getData();
try {
MetricPeg.get.registerRequest(data.event_id);
// Build our own context.
if (!store.roomStore) {
log.warn("Discord store not ready yet, dropping message");
MetricPeg.get.requestOutcome(data.event_id, false, "dropped");
return;
}
const roomId = data.room_id;
const context: BridgeContext = {
rooms: {},
};
if (roomId) {
const entries = await store.roomStore.getEntriesByMatrixId(roomId);
context.rooms = entries[0] || {};
}
await request.outcomeFrom(callbacks.onEvent(request, context));
MetricPeg.get.requestOutcome(data.event_id, false, "success");
} catch (err) {
MetricPeg.get.requestOutcome(data.event_id, false, "fail");
log.error("Exception thrown while handling \"onEvent\" event", err);
await request.outcomeFrom(Promise.reject("Failed to handle"));
}
},
onLog: (line, isError) => {
log.verbose("matrix-appservice-bridge", line);
},
thirdPartyLookup: async () => {
try {
return await callbacks.thirdPartyLookup();
} catch (err) {
log.error("Exception thrown while handling \"thirdPartyLookup\" event", err);
}
},
},
disableContext: true,
domain: config.bridge.domain,
const appservice = new Appservice({
bindAddress: config.bridge.bindAddress || "0.0.0.0",
homeserverName: config.bridge.domain,
homeserverUrl: config.bridge.homeserverUrl,
intentOptions: {
clients: {
dontJoin: true, // handled manually
},
},
// To avoid out of order message sending.
queue: {
perRequest: true,
type: "per_room",
},
port,
registration,
// These must be kept for a while yet since we use them for migrations.
roomStore: config.database.roomStorePath,
userStore: config.database.userStorePath,
storage: store,
});
if (config.database.roomStorePath) {
log.warn("[DEPRECATED] The room store is now part of the SQL database."
+ "The config option roomStorePath no longer has any use.");
}
if (config.database.userStorePath) {
log.warn("[DEPRECATED] The user store is now part of the SQL database."
+ "The config option userStorePath no longer has any use.");
}
await bridge.run(port, config);
log.info(`Started listening on port ${port}`);
if (config.bridge.enableMetrics) {
if (config.metrics.enable) {
log.info("Enabled metrics");
MetricPeg.set(new PrometheusBridgeMetrics().init(bridge));
MetricPeg.set(new PrometheusBridgeMetrics().init(appservice, config.metrics));
}
try {
await store.init(undefined, bridge.getRoomStore(), bridge.getUserStore());
await store.init();
} catch (ex) {
log.error("Failed to init database. Exiting.", ex);
process.exit(1);
}
const discordbot = new DiscordBot(botUserId, config, bridge, store);
const discordbot = new DiscordBot(config, appservice, store);
const roomhandler = discordbot.RoomHandler;
const eventProcessor = discordbot.MxEventProcessor;
try {
callbacks.onAliasQueried = roomhandler.OnAliasQueried.bind(roomhandler);
callbacks.onAliasQuery = roomhandler.OnAliasQuery.bind(roomhandler);
callbacks.onEvent = eventProcessor.OnEvent.bind(eventProcessor);
callbacks.thirdPartyLookup = async () => {
return roomhandler.ThirdPartyLookup;
};
} catch (err) {
log.error("Failed to register callbacks. Exiting.", err);
process.exit(1);
}
// tslint:disable-next-line:no-any
appservice.on("query.room", async (roomAlias: string, createRoom: (opts: any) => Promise<void>) => {
try {
const createRoomOpts = await roomhandler.OnAliasQuery(roomAlias);
await createRoom(createRoomOpts);
await roomhandler.OnAliasQueried(roomAlias, createRoomOpts.__roomId);
} catch (err) {
log.error("Exception thrown while handling \"query.room\" event", err);
}
});
log.info("Initing bridge");
appservice.on("room.event", async (roomId: string, event: IMatrixEvent) => {
try {
const entries = await store.roomStore.getEntriesByMatrixId(roomId);
await eventProcessor.OnEvent(event, entries);
} catch (err) {
log.error("Exception thrown while handling \"room.event\" event", err);
}
});
roomhandler.bindThirdparty();
await appservice.begin();
log.info(`Started listening on port ${port}`);
try {
log.info("Initing store.");
await discordbot.init();
log.info(`Started listening on port ${port}.`);
log.info("Initing bot.");
await discordbot.run();
log.info("Discordbot started successfully");
} catch (err) {
@ -209,3 +211,8 @@ async function run(port: number, fileConfig: DiscordBridgeConfig) {
process.exit(1);
}
}
run().catch((err) => {
log.error("A fatal error occurred during startup:", err);
process.exit(1);
});

View file

@ -15,16 +15,16 @@ limitations under the License.
*/
import { DiscordBot } from "./bot";
import * as Discord from "discord.js";
import * as Discord from "better-discord.js";
import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util";
import { Bridge } from "matrix-appservice-bridge";
import { Log } from "./log";
import { Appservice } from "matrix-bot-sdk";
const log = new Log("DiscordCommandHandler");
export class DiscordCommandHandler {
constructor(
private bridge: Bridge,
private bridge: Appservice,
private discord: DiscordBot,
) { }
@ -34,8 +34,14 @@ export class DiscordCommandHandler {
await msg.channel.send("**ERROR:** only available for guild channels");
return;
}
if (!msg.member) {
await msg.channel.send("**ERROR:** could not determine message member");
return;
}
const intent = this.bridge.getIntent();
const discordMember = msg.member;
const intent = this.bridge.botIntent;
const actions: ICommandActions = {
approve: {
@ -43,7 +49,7 @@ export class DiscordCommandHandler {
params: [],
permission: "MANAGE_WEBHOOKS",
run: async () => {
if (await this.discord.Provisioner.MarkApproved(chan, msg.member, true)) {
if (await this.discord.Provisioner.MarkApproved(chan, discordMember, true)) {
return "Thanks for your response! The matrix bridge has been approved";
} else {
return "Thanks for your response, however" +
@ -62,7 +68,7 @@ export class DiscordCommandHandler {
params: [],
permission: "MANAGE_WEBHOOKS",
run: async () => {
if (await this.discord.Provisioner.MarkApproved(chan, msg.member, false)) {
if (await this.discord.Provisioner.MarkApproved(chan, discordMember, false)) {
return "Thanks for your response! The matrix bridge has been declined";
} else {
return "Thanks for your response, however" +
@ -105,7 +111,7 @@ export class DiscordCommandHandler {
if (!Array.isArray(permission)) {
permission = [permission];
}
return permission.every((p) => msg.member.hasPermission(p as Discord.PermissionResolvable));
return permission.every((p) => discordMember.hasPermission(p as Discord.PermissionResolvable));
};
const reply = await Util.ParseCommand("!matrix", msg.content, actions, parameters, permissionCheck);
@ -115,7 +121,7 @@ export class DiscordCommandHandler {
private ModerationActionGenerator(discordChannel: Discord.TextChannel, funcKey: "kick"|"ban"|"unban") {
return async ({name}) => {
let allChannelMxids: string[] = [];
await Promise.all(discordChannel.guild.channels.map(async (chan) => {
await Promise.all(discordChannel.guild.channels.cache.map(async (chan) => {
try {
const chanMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(chan);
allChannelMxids = allChannelMxids.concat(chanMxids);
@ -125,9 +131,8 @@ export class DiscordCommandHandler {
}));
let errorMsg = "";
await Promise.all(allChannelMxids.map(async (chanMxid) => {
const intent = this.bridge.getIntent();
try {
await intent[funcKey](chanMxid, name);
await this.bridge.botIntent.underlyingClient[funcKey + "User"](chanMxid, name);
} catch (e) {
// maybe we don't have permission to kick/ban/unban...?
errorMsg += `\nCouldn't ${funcKey} ${name} from ${chanMxid}`;

View file

@ -14,337 +14,78 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Discord from "discord.js";
import * as markdown from "discord-markdown";
import * as Discord from "better-discord.js";
import { DiscordBot } from "./bot";
import * as escapeHtml from "escape-html";
import { Util } from "./util";
import { Bridge } from "matrix-appservice-bridge";
import { Log } from "./log";
import {
DiscordMessageParser,
IDiscordMessageParserOpts,
IDiscordMessageParserCallbacks,
IDiscordMessageParserResult,
} from "matrix-discord-parser";
const log = new Log("DiscordMessageProcessor");
const MATRIX_TO_LINK = "https://matrix.to/#/";
// somehow the regex works properly if it isn't global
// as we replace the match fully anyways this shouldn't be an issue
const MXC_INSERT_REGEX = /\x01emoji\x01(\w+)\x01([01])\x01([0-9]*)\x01/;
const NAME_MXC_INSERT_REGEX_GROUP = 1;
const ANIMATED_MXC_INSERT_REGEX_GROUP = 2;
const ID_MXC_INSERT_REGEX_GROUP = 3;
const EMOJI_SIZE = 32;
const MAX_EDIT_MSG_LENGTH = 50;
// same as above, no global flag here, too
const CHANNEL_INSERT_REGEX = /\x01chan\x01([0-9]*)\x01/;
const ID_CHANNEL_INSERT_REGEX = 1;
export class DiscordMessageProcessorOpts {
constructor(readonly domain: string, readonly bot?: DiscordBot) {
}
}
export class DiscordMessageProcessorResult {
public formattedBody: string;
public body: string;
public msgtype: string;
}
interface IDiscordNode {
id: string;
}
interface IEmojiNode extends IDiscordNode {
animated: boolean;
name: string;
}
export class DiscordMessageProcessor {
private readonly opts: DiscordMessageProcessorOpts;
constructor(opts: DiscordMessageProcessorOpts, bot: DiscordBot | null = null) {
// Backwards compat
if (bot !== null) {
this.opts = new DiscordMessageProcessorOpts(opts.domain, bot);
} else {
this.opts = opts;
}
private parser: DiscordMessageParser;
constructor(private domain: string, private bot: DiscordBot) {
this.parser = new DiscordMessageParser();
}
public async FormatMessage(msg: Discord.Message): Promise<DiscordMessageProcessorResult> {
const result = new DiscordMessageProcessorResult();
let content = msg.content;
// for the formatted body we need to parse markdown first
// as else it'll HTML escape the result of the discord syntax
let contentPostmark = markdown.toHTML(content, {
discordCallback: this.getDiscordParseCallbacksHTML(msg),
});
// parse the plain text stuff
content = markdown.toHTML(content, {
discordCallback: this.getDiscordParseCallbacks(msg),
discordOnly: true,
escapeHTML: false,
});
content = this.InsertEmbeds(content, msg);
content = await this.InsertMxcImages(content, msg);
content = await this.InsertChannelPills(content, msg);
// parse postmark stuff
contentPostmark = this.InsertEmbedsPostmark(contentPostmark, msg);
contentPostmark = await this.InsertMxcImages(contentPostmark, msg, true);
contentPostmark = await this.InsertChannelPills(contentPostmark, msg, true);
result.body = content;
result.formattedBody = contentPostmark;
result.msgtype = msg.author.bot ? "m.notice" : "m.text";
return result;
public async FormatMessage(msg: Discord.Message): Promise<IDiscordMessageParserResult> {
const opts = {
callbacks: this.getParserCallbacks(msg),
} as IDiscordMessageParserOpts;
return await this.parser.FormatMessage(opts, msg);
}
public async FormatEdit(
oldMsg: Discord.Message,
newMsg: Discord.Message,
link?: string,
): Promise<DiscordMessageProcessorResult> {
oldMsg.embeds = []; // we don't want embeds on old msg
const oldMsgParsed = await this.FormatMessage(oldMsg);
const newMsgParsed = await this.FormatMessage(newMsg);
const result = new DiscordMessageProcessorResult();
result.body = `*edit:* ~~${oldMsgParsed.body}~~ -> ${newMsgParsed.body}`;
result.msgtype = newMsgParsed.msgtype;
oldMsg.content = `*edit:* ~~${oldMsg.content}~~ -> ${newMsg.content}`;
const linkStart = link ? `<a href="${escapeHtml(link)}">` : "";
const linkEnd = link ? "</a>" : "";
if (oldMsg.content.includes("\n") || newMsg.content.includes("\n")
|| newMsg.content.length > MAX_EDIT_MSG_LENGTH) {
result.formattedBody = `<p>${linkStart}<em>edit:</em>${linkEnd}</p><p><del>${oldMsgParsed.formattedBody}` +
`</del></p><hr><p>${newMsgParsed.formattedBody}</p>`;
} else {
result.formattedBody = `${linkStart}<em>edit:</em>${linkEnd} <del>${oldMsgParsed.formattedBody}</del>` +
` -&gt; ${newMsgParsed.formattedBody}`;
}
return result;
msg1: Discord.Message,
msg2: Discord.Message,
link: string,
): Promise<IDiscordMessageParserResult> {
// obsolete once edit PR is merged
const opts = {
callbacks: this.getParserCallbacks(msg2),
} as IDiscordMessageParserOpts;
return await this.parser.FormatEdit(opts, msg1, msg2, link);
}
public InsertEmbeds(content: string, msg: Discord.Message): string {
for (const embed of msg.embeds) {
if (embed.title === undefined && embed.description === undefined) {
continue;
}
if (this.isEmbedInBody(msg, embed)) {
continue;
}
let embedContent = "\n\n----"; // Horizontal rule. Two to make sure the content doesn't become a title.
const embedTitle = embed.url ? `[${embed.title}](${embed.url})` : embed.title;
if (embedTitle) {
embedContent += "\n##### " + embedTitle; // h5 is probably best.
}
if (embed.description) {
embedContent += "\n" + markdown.toHTML(embed.description, {
discordCallback: this.getDiscordParseCallbacks(msg),
discordOnly: true,
escapeHTML: false,
});
}
if (embed.fields) {
for (const field of embed.fields) {
embedContent += `\n**${field.name}**\n`;
embedContent += markdown.toHTML(field.value, {
discordCallback: this.getDiscordParseCallbacks(msg),
discordOnly: true,
escapeHTML: false,
});
}
}
if (embed.image) {
embedContent += "\nImage: " + embed.image.url;
}
if (embed.footer) {
embedContent += "\n" + markdown.toHTML(embed.footer.text, {
discordCallback: this.getDiscordParseCallbacks(msg),
discordOnly: true,
escapeHTML: false,
});
}
content += embedContent;
}
return content;
}
public InsertEmbedsPostmark(content: string, msg: Discord.Message): string {
for (const embed of msg.embeds) {
if (embed.title === undefined && embed.description === undefined) {
continue;
}
if (this.isEmbedInBody(msg, embed)) {
continue;
}
let embedContent = "<hr>"; // Horizontal rule. Two to make sure the content doesn't become a title.
const embedTitle = embed.url ?
`<a href="${escapeHtml(embed.url)}">${escapeHtml(embed.title)}</a>`
: (embed.title ? escapeHtml(embed.title) : undefined);
if (embedTitle) {
embedContent += `<h5>${embedTitle}</h5>`; // h5 is probably best.
}
if (embed.description) {
embedContent += "<p>";
embedContent += markdown.toHTML(embed.description, {
discordCallback: this.getDiscordParseCallbacksHTML(msg),
embed: true,
}) + "</p>";
}
if (embed.fields) {
for (const field of embed.fields) {
embedContent += `<p><strong>${escapeHtml(field.name)}</strong><br>`;
embedContent += markdown.toHTML(field.value, {
discordCallback: this.getDiscordParseCallbacks(msg),
embed: true,
}) + "</p>";
}
}
if (embed.image) {
const imgUrl = escapeHtml(embed.image.url);
embedContent += `<p>Image: <a href="${imgUrl}">${imgUrl}</a></p>`;
}
if (embed.footer) {
embedContent += "<p>";
embedContent += markdown.toHTML(embed.footer.text, {
discordCallback: this.getDiscordParseCallbacksHTML(msg),
embed: true,
}) + "</p>";
}
content += embedContent;
}
return content;
}
public InsertUser(node: IDiscordNode, msg: Discord.Message, html: boolean = false): string {
const id = node.id;
const member = msg.guild.members.get(id);
const memberId = `@_discord_${id}:${this.opts.domain}`;
const memberName = member ? member.displayName : memberId;
if (!html) {
return memberName;
}
return `<a href="${MATRIX_TO_LINK}${escapeHtml(memberId)}">${escapeHtml(memberName)}</a>`;
}
public InsertChannel(node: IDiscordNode): string {
// unfortunately these callbacks are sync, so we flag our channel with some special stuff
// and later on grab the real channel pill async
const FLAG = "\x01";
return `${FLAG}chan${FLAG}${node.id}${FLAG}`;
}
public InsertRole(node: IDiscordNode, msg: Discord.Message, html: boolean = false): string {
const id = node.id;
const role = msg.guild.roles.get(id);
if (!role) {
return html ? `&lt;@&amp;${id}&gt;` : `<@&${id}>`;
}
if (!html) {
return `@${role.name}`;
}
const color = Util.NumberToHTMLColor(role.color);
return `<span data-mx-color="${color}"><strong>@${escapeHtml(role.name)}</strong></span>`;
}
public InsertEmoji(node: IEmojiNode): string {
// unfortunately these callbacks are sync, so we flag our url with some special stuff
// and later on grab the real url async
const FLAG = "\x01";
return `${FLAG}emoji${FLAG}${node.name}${FLAG}${node.animated ? 1 : 0}${FLAG}${node.id}${FLAG}`;
}
public InsertRoom(msg: Discord.Message, def: string): string {
return msg.mentions.everyone ? "@room" : def;
}
public async InsertMxcImages(content: string, msg: Discord.Message, html: boolean = false): Promise<string> {
let results = MXC_INSERT_REGEX.exec(content);
while (results !== null) {
const name = results[NAME_MXC_INSERT_REGEX_GROUP];
const animated = results[ANIMATED_MXC_INSERT_REGEX_GROUP] === "1";
const id = results[ID_MXC_INSERT_REGEX_GROUP];
let replace = "";
const nameHtml = escapeHtml(name);
try {
const mxcUrl = await this.opts.bot!.GetEmoji(name, animated, id);
if (html) {
replace = `<img alt="${nameHtml}" title="${nameHtml}" ` +
`height="${EMOJI_SIZE}" src="${mxcUrl}" />`;
} else {
replace = `:${name}:`;
}
} catch (ex) {
log.warn(
`Could not insert emoji ${id} for msg ${msg.id} in guild ${msg.guild.id}: ${ex}`,
);
if (html) {
replace = `&lt;${animated ? "a" : ""}:${nameHtml}:${id}&gt;`;
} else {
replace = `<${animated ? "a" : ""}:${name}:${id}>`;
}
}
content = content.replace(results[0], replace);
results = MXC_INSERT_REGEX.exec(content);
}
return content;
}
public async InsertChannelPills(content: string, msg: Discord.Message, html: boolean = false): Promise<string> {
let results = CHANNEL_INSERT_REGEX.exec(content);
while (results !== null) {
const id = results[ID_CHANNEL_INSERT_REGEX];
let replace = "";
const channel = msg.guild.channels.get(id);
if (channel) {
const alias = await this.opts.bot!.ChannelSyncroniser.GetAliasFromChannel(channel);
if (alias) {
const name = "#" + channel.name;
replace = html ? `<a href="${MATRIX_TO_LINK}${escapeHtml(alias)}">${escapeHtml(name)}</a>` : name;
}
}
if (!replace) {
replace = html ? `&lt;#${escapeHtml(id)}&gt;` : `<#${id}>`;
}
content = content.replace(results[0], replace);
results = CHANNEL_INSERT_REGEX.exec(content);
}
return content;
}
private isEmbedInBody(msg: Discord.Message, embed: Discord.MessageEmbed): boolean {
if (!embed.url) {
return false;
}
let url = embed.url;
if (url.substr(url.length - 1) === "/") {
url = url.substr(0, url.length - 1);
}
return msg.content.includes(url);
}
private getDiscordParseCallbacks(msg: Discord.Message) {
private getParserCallbacks(msg: Discord.Message): IDiscordMessageParserCallbacks {
return {
channel: (node) => this.InsertChannel(node), // are post-inserted
emoji: (node) => this.InsertEmoji(node), // are post-inserted
everyone: (_) => this.InsertRoom(msg, "@everyone"),
here: (_) => this.InsertRoom(msg, "@here"),
role: (node) => this.InsertRole(node, msg),
user: (node) => this.InsertUser(node, msg),
getChannel: async (id: string) => {
const channel = msg.guild?.channels.resolve(id);
if (!channel) {
return null;
}
const alias = await this.bot.ChannelSyncroniser.GetAliasFromChannel(channel);
if (!alias) {
return null;
}
return {
mxid: alias,
name: channel.name,
};
},
getEmoji: async (name: string, animated: boolean, id: string) => {
try {
const mxcUrl = await this.bot.GetEmoji(name, animated, id);
return mxcUrl;
} catch (ex) {
log.warn(`Could not get emoji ${id} with name ${name}`, ex);
}
return null;
},
getUser: async (id: string) => {
const member = msg.guild?.members.resolve(id);
const mxid = `@_discord_${id}:${this.domain}`;
const name = member ? member.displayName : mxid;
return {
mxid,
name,
};
},
};
}
private getDiscordParseCallbacksHTML(msg: Discord.Message) {
return {
channel: (node) => this.InsertChannel(node), // are post-inserted
emoji: (node) => this.InsertEmoji(node), // are post-inserted
everyone: (_) => this.InsertRoom(msg, "@everyone"),
here: (_) => this.InsertRoom(msg, "@here"),
role: (node) => this.InsertRole(node, msg, true),
user: (node) => this.InsertUser(node, msg, true),
};
}
}

View file

@ -17,13 +17,13 @@ limitations under the License.
import { DiscordBot } from "./bot";
import { Log } from "./log";
import { DiscordBridgeConfig } from "./config";
import { Bridge, BridgeContext } from "matrix-appservice-bridge";
import { IMatrixEvent } from "./matrixtypes";
import { Provisioner } from "./provisioner";
import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util";
import * as Discord from "discord.js";
import * as markdown from "discord-markdown";
import { RemoteStoreRoom } from "./db/roomstore";
import * as Discord from "better-discord.js";
import { Appservice } from "matrix-bot-sdk";
import { IRoomStoreEntry } from "./db/roomstore";
import * as markdown from "marked";
const log = new Log("MatrixCommandHandler");
/* tslint:disable:no-magic-numbers */
@ -38,7 +38,7 @@ export class MatrixCommandHandler {
private provisioner: Provisioner;
constructor(
private discord: DiscordBot,
private bridge: Bridge,
private bridge: Appservice,
private config: DiscordBridgeConfig,
) {
this.provisioner = this.discord.Provisioner;
@ -46,11 +46,11 @@ export class MatrixCommandHandler {
public async HandleInvite(event: IMatrixEvent) {
log.info(`Received invite for ${event.state_key} in room ${event.room_id}`);
await this.bridge.getIntent().join(event.room_id);
await this.bridge.botIntent.joinRoom(event.room_id);
this.botJoinedRooms.add(event.room_id);
}
public async Process(event: IMatrixEvent, context: BridgeContext) {
public async Process(event: IMatrixEvent, roomEntry: IRoomStoreEntry|null) {
if (!(await this.isBotInRoom(event.room_id))) {
log.warn(`Bot is not in ${event.room_id}. Ignoring command`);
return;
@ -78,7 +78,7 @@ export class MatrixCommandHandler {
subcat: "m.room.power_levels",
},
run: async ({guildId, channelId}) => {
if (context.rooms.remote) {
if (roomEntry && roomEntry.remote) {
return "This room is already bridged to a Discord guild.";
}
if (!guildId || !channelId) {
@ -89,10 +89,11 @@ export class MatrixCommandHandler {
const channel = discordResult.channel as Discord.TextChannel;
log.info(`Bridging matrix room ${event.room_id} to ${guildId}/${channelId}`);
this.bridge.getIntent().sendMessage(event.room_id, {
body: "I'm asking permission from the guild administrators to make this bridge.",
msgtype: "m.notice",
});
await this.bridge.botIntent.sendText(
event.room_id,
"I'm asking permission from the guild administrators to make this bridge.",
"m.notice",
);
await this.provisioner.AskBridgePermission(channel, event.sender);
await this.provisioner.BridgeMatrixRoom(channel, event.room_id);
@ -119,16 +120,15 @@ export class MatrixCommandHandler {
subcat: "m.room.power_levels",
},
run: async () => {
const remoteRoom = context.rooms.remote as RemoteStoreRoom;
if (!remoteRoom) {
if (!roomEntry || !roomEntry.remote) {
return "This room is not bridged.";
}
if (!remoteRoom.data.plumbed) {
if (!roomEntry.remote.data.plumbed) {
return "This room cannot be unbridged.";
}
const res = await this.discord.LookupRoom(
remoteRoom.data.discord_guild!,
remoteRoom.data.discord_channel!,
roomEntry.remote.data.discord_guild!,
roomEntry.remote.data.discord_channel!,
);
try {
await this.provisioner.UnbridgeChannel(res.channel, event.room_id);
@ -177,7 +177,7 @@ export class MatrixCommandHandler {
return "The owner of this bridge does not permit self-service bridging.";
}
return await Util.CheckMatrixPermission(
this.bridge.getIntent().getClient(),
this.bridge.botClient,
event.sender,
event.room_id,
permission.level,
@ -187,9 +187,8 @@ export class MatrixCommandHandler {
};
const reply = await Util.ParseCommand("!discord", event.content!.body!, actions, parameters, permissionCheck);
const formattedReply = markdown.toHTML(reply);
await this.bridge.getIntent().sendMessage(event.room_id, {
const formattedReply = markdown(reply);
await this.bridge.botClient.sendMessage(event.room_id, {
body: reply,
format: "org.matrix.custom.html",
formatted_body: formattedReply,
@ -204,8 +203,7 @@ export class MatrixCommandHandler {
try {
log.verbose("Got new room cache for bot");
this.botJoinedRoomsCacheUpdatedAt = Date.now();
const rooms = (await this.bridge.getBot().getJoinedRooms()) as string[];
this.botJoinedRooms = new Set(rooms);
this.botJoinedRooms = new Set(await this.bridge.botIntent.underlyingClient.getJoinedRooms());
} catch (e) {
log.error("Failed to get room cache for bot, ", e);
return false;

View file

@ -14,22 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Discord from "discord.js";
import * as Discord from "better-discord.js";
import { DiscordBot } from "./bot";
import { DiscordBridgeConfig } from "./config";
import * as escapeStringRegexp from "escape-string-regexp";
import { Util } from "./util";
import { Util, wrapError } from "./util";
import * as path from "path";
import * as mime from "mime";
import { MatrixUser, Bridge, BridgeContext } from "matrix-appservice-bridge";
import { Client as MatrixClient } from "matrix-js-sdk";
import { IMatrixEvent, IMatrixEventContent, IMatrixMessage } from "./matrixtypes";
import { MatrixMessageProcessor, IMatrixMessageProcessorParams } from "./matrixmessageprocessor";
import { MatrixCommandHandler } from "./matrixcommandhandler";
import { DbEvent } from "./db/dbdataevent";
import { Log } from "./log";
import { IRoomStoreEntry, RemoteStoreRoom } from "./db/roomstore";
import { Appservice, MatrixClient } from "matrix-bot-sdk";
import { DiscordStore } from "./store";
import { TimedCache } from "./structures/timedcache";
import { MetricPeg } from "./metrics";
const log = new Log("MatrixEventProcessor");
const MaxFileSize = 8000000;
@ -44,22 +45,25 @@ const PROFILE_CACHE_LIFETIME = 900000;
export class MatrixEventProcessorOpts {
constructor(
readonly config: DiscordBridgeConfig,
readonly bridge: Bridge,
readonly bridge: Appservice,
readonly discord: DiscordBot,
readonly store: DiscordStore,
) {
}
}
export interface IMatrixEventProcessorResult {
messageEmbed: Discord.RichEmbed;
replyEmbed?: Discord.RichEmbed;
messageEmbed: Discord.MessageEmbed;
replyEmbed?: Discord.MessageEmbed;
imageEmbed?: Discord.MessageEmbed;
}
export class MatrixEventProcessor {
private config: DiscordBridgeConfig;
private bridge: Bridge;
private bridge: Appservice;
private discord: DiscordBot;
private store: DiscordStore;
private matrixMsgProcessor: MatrixMessageProcessor;
private mxCommandHandler: MatrixCommandHandler;
private mxUserProfileCache: TimedCache<string, {displayname: string, avatar_url: string|undefined}>;
@ -67,8 +71,10 @@ export class MatrixEventProcessor {
constructor(opts: MatrixEventProcessorOpts, cm?: MatrixCommandHandler) {
this.config = opts.config;
this.bridge = opts.bridge;
this.store = opts.store;
this.discord = opts.discord;
this.matrixMsgProcessor = new MatrixMessageProcessor(this.discord);
this.store = opts.store;
this.matrixMsgProcessor = new MatrixMessageProcessor(this.discord, this.config);
this.mxUserProfileCache = new TimedCache(PROFILE_CACHE_LIFETIME);
if (cm) {
this.mxCommandHandler = cm;
@ -77,27 +83,39 @@ export class MatrixEventProcessor {
}
}
public async OnEvent(request, context: BridgeContext): Promise<void> {
const event = request.getData() as IMatrixEvent;
/**
* Callback which is called when the HS notifies the bridge of a new event.
*
* @param request Request object containing the event for which this callback is called.
* @param context The current context of the bridge.
* @throws {Unstable.EventNotHandledError} When the event can finally not be handled.
*/
public async OnEvent(event: IMatrixEvent, rooms: IRoomStoreEntry[]): Promise<void> {
const remoteRoom = rooms[0];
if (event.unsigned.age > AGE_LIMIT) {
log.warn(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`);
MetricPeg.get.requestOutcome(event.event_id, false, "dropped");
log.info(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`);
// throw new Unstable.EventTooOldError(
// `Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`,
// );
return;
}
if (
event.type === "m.room.member" &&
event.content!.membership === "invite" &&
event.state_key === this.bridge.getClientFactory()._botUserId
event.state_key === this.bridge.botUserId
) {
await this.mxCommandHandler.HandleInvite(event);
return;
} else if (event.type === "m.room.member" && this.bridge.getBot().isRemoteUser(event.state_key)) {
} else if (event.type === "m.room.member" && this.bridge.isNamespacedUser(event.state_key)) {
if (["leave", "ban"].includes(event.content!.membership!) && event.sender !== event.state_key) {
// Kick/Ban handling
let prevMembership = "";
if (event.content!.membership === "leave") {
const intent = this.bridge.getIntent();
prevMembership = (await intent.getEvent(event.room_id, event.replaces_state)).content.membership;
if (event.content!.membership === "leave" && event.replaces_state) {
const intent = this.bridge.botIntent;
prevMembership = (await intent.underlyingClient.getEvent(
event.room_id,
event.replaces_state,
)).content.membership;
}
await this.discord.HandleMatrixKickBan(
event.room_id,
@ -109,46 +127,40 @@ export class MatrixEventProcessor {
);
}
return;
} else if (this.bridge.isNamespacedUser(event.sender)) {
// Ignore echo
return;
} else if (["m.room.member", "m.room.name", "m.room.topic"].includes(event.type)) {
await this.ProcessStateEvent(event);
return;
} else if (event.type === "m.room.redaction" && context.rooms.remote) {
} else if (event.type === "m.room.redaction" && remoteRoom) {
await this.discord.ProcessMatrixRedact(event);
return;
} else if (event.type === "m.room.message" || event.type === "m.sticker") {
log.verbose(`Got ${event.type} event`);
const isBotCommand = event.type === "m.room.message" &&
event.content!.body &&
event.content!.body!.startsWith("!discord");
if (isBotCommand) {
await this.mxCommandHandler.Process(event, context);
} else if (context.rooms.remote) {
const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS);
if (isBotCommand(event)) {
await this.mxCommandHandler.Process(event, remoteRoom);
} else if (remoteRoom) {
try {
await this.ProcessMsgEvent(event, srvChanPair[0], srvChanPair[1]);
await this.ProcessMsgEvent(event, remoteRoom.remote!);
} catch (err) {
log.warn("There was an error sending a matrix event", err);
}
}
return;
} else if (event.type === "m.room.encryption" && context.rooms.remote) {
try {
await this.HandleEncryptionWarning(event.room_id);
return;
} catch (err) {
throw new Error(`Failed to handle encrypted room, ${err}`);
}
} else if (event.type === "m.room.encryption" && remoteRoom) {
await this.HandleEncryptionWarning(event.room_id);
return;
}
log.verbose("Event not processed by bridge");
MetricPeg.get.requestOutcome(event.event_id, false, "dropped");
// throw new Unstable.EventUnknownError(`${event.event_id} not processed by bridge`);
log.verbose(`${event.event_id} not processed by bridge`);
}
public async HandleEncryptionWarning(roomId: string): Promise<void> {
const intent = this.bridge.getIntent();
log.info(`User has turned on encryption in ${roomId}, so leaving.`);
/* N.B 'status' is not specced but https://github.com/matrix-org/matrix-doc/pull/828
has been open for over a year with no resolution. */
const sendPromise = intent.sendMessage(roomId, {
const sendPromise = this.bridge.botIntent.sendEvent(roomId, {
body: "You have turned on encryption in this room, so the service will not bridge any new messages.",
msgtype: "m.notice",
status: "critical",
@ -158,26 +170,51 @@ export class MatrixEventProcessor {
"Someone on Matrix has turned on encryption in this room, so the service will not bridge any new messages",
);
await sendPromise;
await intent.leave(roomId);
await this.bridge.getRoomStore().removeEntriesByMatrixRoomId(roomId);
await this.bridge.botIntent.underlyingClient.leaveRoom(roomId);
await this.store.roomStore.removeEntriesByMatrixRoomId(roomId);
}
public async ProcessMsgEvent(event: IMatrixEvent, guildId: string, channelId: string) {
const mxClient = this.bridge.getClientFactory().getClientAs();
/**
* Processes a matrix event by sending it to Discord and marking the event as read.
*
* @param event The matrix m.room.message event to process.
* @param context Context of the bridge.
* @throws {Unstable.ForeignNetworkError}
*/
public async ProcessMsgEvent(event: IMatrixEvent, room: RemoteStoreRoom): Promise<void> {
const guildId = room.data.discord_guild!;
const channelId = room.data.discord_channel!;
const mxClient = this.bridge.botClient;
log.verbose(`Looking up ${guildId}_${channelId}`);
const roomLookup = await this.discord.LookupRoom(guildId, channelId, event.sender);
const chan = roomLookup.channel;
const embedSet = await this.EventToEmbed(event, chan);
const opts: Discord.MessageOptions = {};
const file = await this.HandleAttachment(event, mxClient);
if (typeof(file) === "string") {
embedSet.messageEmbed.description += " " + file;
} else {
opts.file = file;
let editEventId = "";
if (event.content && event.content["m.relates_to"] && event.content["m.relates_to"].rel_type === "m.replace") {
const editMatrixId = `${event.content["m.relates_to"].event_id};${event.room_id}`;
const storeEvent = await this.store.Get(DbEvent, {matrix_id: editMatrixId});
if (storeEvent && storeEvent.Result && storeEvent.Next()) {
editEventId = storeEvent.DiscordId;
}
}
await this.discord.send(embedSet, opts, roomLookup, event);
const embedSet = await this.EventToEmbed(event, chan);
const opts: Discord.MessageOptions = {};
const file = await this.HandleAttachment(event, mxClient, roomLookup.canSendEmbeds);
if (typeof(file) === "string") {
embedSet.messageEmbed.description += " " + file;
} else if ((file as Discord.FileOptions).name && (file as Discord.FileOptions).attachment) {
opts.files = [file as Discord.FileOptions];
} else {
embedSet.imageEmbed = file as Discord.MessageEmbed;
}
// Throws an `Unstable.ForeignNetworkError` when sending the message fails.
if (editEventId) {
await this.discord.edit(embedSet, opts, roomLookup, event, editEventId);
} else {
await this.discord.send(embedSet, opts, roomLookup, event);
}
// Don't await this.
this.sendReadReceipt(event).catch((ex) => {
log.verbose("Failed to send read reciept for ", event.event_id, ex);
@ -186,7 +223,6 @@ export class MatrixEventProcessor {
public async ProcessStateEvent(event: IMatrixEvent) {
log.verbose(`Got state event from ${event.room_id} ${event.type}`);
const channel = await this.discord.GetChannelFromRoomId(event.room_id) as Discord.TextChannel;
const SUPPORTED_EVENTS = ["m.room.member", "m.room.name", "m.room.topic"];
if (!SUPPORTED_EVENTS.includes(event.type)) {
@ -194,7 +230,7 @@ export class MatrixEventProcessor {
return;
}
if (event.sender === this.bridge.getIntent().getClient().getUserId()) {
if (event.sender === this.bridge.botUserId) {
log.verbose(`${event.event_id} ${event.type} is by our bot user, ignoring.`);
return;
}
@ -202,6 +238,7 @@ export class MatrixEventProcessor {
let msg = `\`${event.sender}\` `;
const allowJoinLeave = !this.config.bridge.disableJoinLeaveNotifications;
const allowInvite = !this.config.bridge.disableInviteNotifications;
if (event.type === "m.room.name") {
msg += `set the name to \`${event.content!.name}\``;
@ -209,9 +246,9 @@ export class MatrixEventProcessor {
msg += `set the topic to \`${event.content!.topic}\``;
} else if (event.type === "m.room.member") {
const membership = event.content!.membership;
const intent = this.bridge.getIntent();
const client = this.bridge.botIntent.underlyingClient;
const isNewJoin = event.unsigned.replaces_state === undefined ? true : (
await intent.getEvent(event.room_id, event.unsigned.replaces_state)).content.membership !== "join";
await client.getEvent(event.room_id, event.unsigned.replaces_state)).content.membership !== "join";
if (membership === "join") {
this.mxUserProfileCache.delete(`${event.room_id}:${event.sender}`);
this.mxUserProfileCache.delete(event.sender);
@ -226,7 +263,7 @@ export class MatrixEventProcessor {
}
if (membership === "join" && isNewJoin && allowJoinLeave) {
msg += "joined the room";
} else if (membership === "invite") {
} else if (membership === "invite" && allowInvite) {
msg += `invited \`${event.state_key}\` to the room`;
} else if (membership === "leave" && event.state_key !== event.sender) {
msg += `kicked \`${event.state_key}\` from the room`;
@ -241,6 +278,7 @@ export class MatrixEventProcessor {
}
msg += " on Matrix.";
const channel = await this.discord.GetChannelFromRoomId(event.room_id) as Discord.TextChannel;
await this.discord.sendAsBot(msg, channel, event);
await this.sendReadReceipt(event);
}
@ -248,7 +286,7 @@ export class MatrixEventProcessor {
public async EventToEmbed(
event: IMatrixEvent, channel: Discord.TextChannel, getReply: boolean = true,
): Promise<IMatrixEventProcessorResult> {
const mxClient = this.bridge.getClientFactory().getClientAs();
const mxClient = this.bridge.botIntent.underlyingClient;
const profile = await this.GetUserProfileForRoom(event.room_id, event.sender);
const params = {
mxClient,
@ -261,10 +299,11 @@ export class MatrixEventProcessor {
let body: string = "";
if (event.type !== "m.sticker") {
body = await this.matrixMsgProcessor.FormatMessage(event.content as IMatrixMessage, channel.guild, params);
const content = event.content!["m.new_content"] ? event.content!["m.new_content"] : event.content;
body = await this.matrixMsgProcessor.FormatMessage(content as IMatrixMessage, channel.guild, params);
}
const messageEmbed = new Discord.RichEmbed();
const messageEmbed = new Discord.MessageEmbed();
messageEmbed.setDescription(body);
await this.SetEmbedAuthor(messageEmbed, event.sender, profile);
const replyEmbed = getReply ? (await this.GetEmbedForReply(event, channel)) : undefined;
@ -284,7 +323,11 @@ export class MatrixEventProcessor {
};
}
public async HandleAttachment(event: IMatrixEvent, mxClient: MatrixClient): Promise<string|Discord.FileOptions> {
public async HandleAttachment(
event: IMatrixEvent,
mxClient: MatrixClient,
sendEmbeds: boolean = false,
): Promise<string|Discord.FileOptions|Discord.MessageEmbed> {
if (!this.HasAttachment(event)) {
return "";
}
@ -296,7 +339,7 @@ export class MatrixEventProcessor {
if (!event.content.info) {
// Fractal sends images without an info, which is technically allowed
// but super unhelpful: https://gitlab.gnome.org/World/fractal/issues/206
event.content.info = {size: 0};
event.content.info = {mimetype: "", size: 0};
}
if (!event.content.url) {
@ -305,10 +348,10 @@ export class MatrixEventProcessor {
}
let size = event.content.info.size || 0;
const url = mxClient.mxcUrlToHttp(event.content.url);
const name = this.GetFilenameForMediaEvent(event.content);
const url = this.bridge.botClient.mxcToHttp(event.content.url);
if (size < MaxFileSize) {
const attachment = await Util.DownloadFile(url);
const attachment = (await Util.DownloadFile(url)).buffer;
size = attachment.byteLength;
if (size < MaxFileSize) {
return {
@ -317,13 +360,17 @@ export class MatrixEventProcessor {
} as Discord.FileOptions;
}
}
if (sendEmbeds && event.content.info.mimetype.split("/")[0] === "image") {
return new Discord.MessageEmbed()
.setImage(url);
}
return `[${name}](${url})`;
}
public async GetEmbedForReply(
event: IMatrixEvent,
channel: Discord.TextChannel,
): Promise<Discord.RichEmbed|undefined> {
): Promise<Discord.MessageEmbed|undefined> {
if (!event.content) {
event.content = {};
}
@ -336,30 +383,31 @@ export class MatrixEventProcessor {
return;
}
const intent = this.bridge.getIntent();
const intent = this.bridge.botIntent;
// Try to get the event.
try {
const sourceEvent = await intent.getEvent(event.room_id, eventId);
sourceEvent.content.body = sourceEvent.content.body || "Reply with unknown content";
const replyEmbed = (await this.EventToEmbed(sourceEvent, channel, false)).messageEmbed;
const sourceEvent = (await intent.underlyingClient.getEvent(event.room_id, eventId)) as IMatrixEvent;
if (!sourceEvent || !sourceEvent.content || !sourceEvent.content.body) {
throw Error("No content could be found");
}
const replyEmbed = (await this.EventToEmbed(sourceEvent, channel, true)).messageEmbed;
// if we reply to a discord member, ping them!
if (this.bridge.getBot().isRemoteUser(sourceEvent.sender)) {
const uid = new MatrixUser(sourceEvent.sender.replace("@", "")).localpart.substring("_discord".length);
if (this.bridge.isNamespacedUser(sourceEvent.sender)) {
const uid = this.bridge.getSuffixForUserId(sourceEvent.sender);
replyEmbed.addField("ping", `<@${uid}>`);
}
replyEmbed.setTimestamp(new Date(sourceEvent.origin_server_ts));
replyEmbed.setTimestamp(new Date(sourceEvent.origin_server_ts!));
if (this.HasAttachment(sourceEvent)) {
const mxClient = this.bridge.getClientFactory().getClientAs();
const url = mxClient.mxcUrlToHttp(sourceEvent.content.url);
if (["m.image", "m.sticker"].includes(sourceEvent.content.msgtype as string)
const url = this.bridge.botClient.mxcToHttp(sourceEvent.content!.url!);
if (["m.image", "m.sticker"].includes(sourceEvent.content!.msgtype as string)
|| sourceEvent.type === "m.sticker") {
// we have an image reply
replyEmbed.setImage(url);
} else {
const name = this.GetFilenameForMediaEvent(sourceEvent.content);
const name = this.GetFilenameForMediaEvent(sourceEvent.content!);
replyEmbed.description = `[${name}](${url})`;
}
}
@ -368,15 +416,14 @@ export class MatrixEventProcessor {
log.warn("Failed to handle reply, showing a unknown embed:", ex);
}
// For some reason we failed to get the event, so using fallback.
const embed = new Discord.RichEmbed();
const embed = new Discord.MessageEmbed();
embed.setDescription("Reply with unknown content");
embed.setAuthor("Unknown");
return embed;
}
private async GetUserProfileForRoom(roomId: string, userId: string) {
const mxClient = this.bridge.getClientFactory().getClientAs();
const intent = this.bridge.getIntent();
const mxClient = this.bridge.botIntent.underlyingClient;
let profile: {displayname: string, avatar_url: string|undefined} | undefined;
try {
// First try to pull out the room-specific profile from the cache.
@ -387,7 +434,7 @@ export class MatrixEventProcessor {
log.verbose(`Profile ${userId}:${roomId} not cached`);
// Failing that, try fetching the state.
profile = await mxClient.getStateEvent(roomId, "m.room.member", userId);
profile = await mxClient.getRoomStateEvent(roomId, "m.room.member", userId);
if (profile) {
this.mxUserProfileCache.set(`${roomId}:${userId}`, profile);
return profile;
@ -401,7 +448,7 @@ export class MatrixEventProcessor {
// Failing that, try fetching the profile.
log.verbose(`Profile ${userId} not cached`);
profile = await intent.getProfileInfo(userId);
profile = await mxClient.getUserProfile(userId);
if (profile) {
this.mxUserProfileCache.set(userId, profile);
return profile;
@ -416,7 +463,7 @@ export class MatrixEventProcessor {
private async sendReadReceipt(event: IMatrixEvent) {
if (!this.config.bridge.disableReadReceipts) {
try {
await this.bridge.getIntent().sendReadReceipt(event.room_id, event.event_id);
await this.bridge.botIntent.underlyingClient.sendReadReceipt(event.room_id, event.event_id);
} catch (err) {
log.error(`Failed to send read receipt for ${event}. `, err);
}
@ -440,26 +487,26 @@ export class MatrixEventProcessor {
return hasAttachment;
}
private async SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile?: {
private async SetEmbedAuthor(embed: Discord.MessageEmbed, sender: string, profile?: {
displayname: string,
avatar_url: string|undefined }) {
let displayName = sender;
let avatarUrl;
// Are they a discord user.
if (this.bridge.getBot().isRemoteUser(sender)) {
const localpart = new MatrixUser(sender.replace("@", "")).localpart;
if (this.bridge.isNamespacedUser(sender)) {
const localpart = Util.ParseMxid(sender).localpart;
const userOrMember = await this.discord.GetDiscordUserOrMember(localpart.substring("_discord".length));
if (userOrMember instanceof Discord.User) {
embed.setAuthor(
userOrMember.username,
userOrMember.avatarURL,
userOrMember.avatarURL() || undefined,
);
return;
} else if (userOrMember instanceof Discord.GuildMember) {
embed.setAuthor(
userOrMember.displayName,
userOrMember.user.avatarURL,
userOrMember.user.avatarURL() || undefined,
);
return;
}
@ -474,8 +521,12 @@ export class MatrixEventProcessor {
}
if (profile.avatar_url) {
const mxClient = this.bridge.getClientFactory().getClientAs();
avatarUrl = mxClient.mxcUrlToHttp(profile.avatar_url, DISCORD_AVATAR_WIDTH, DISCORD_AVATAR_HEIGHT);
avatarUrl = this.bridge.botClient.mxcToHttpThumbnail(
profile.avatar_url,
DISCORD_AVATAR_WIDTH,
DISCORD_AVATAR_HEIGHT,
"scale",
);
}
}
embed.setAuthor(
@ -486,12 +537,27 @@ export class MatrixEventProcessor {
}
private GetFilenameForMediaEvent(content: IMatrixEventContent): string {
let ext = "";
try {
ext = "." + mime.getExtension(content.info.mimetype);
} catch (err) { } // pass, we don't have an extension
if (content.body) {
if (path.extname(content.body) !== "") {
return content.body;
}
return `${path.basename(content.body)}.${mime.extension(content.info.mimetype)}`;
return path.basename(content.body) + ext;
}
return "matrix-media." + mime.extension(content.info.mimetype);
return "matrix-media" + ext;
}
}
/**
* Returns true if the given event is a bot command.
*/
function isBotCommand(event: IMatrixEvent): boolean {
return !!(
event.type === "m.room.message" &&
event.content!.body &&
event.content!.body!.startsWith("!discord")
);
}

View file

@ -14,16 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Discord from "discord.js";
import { IMatrixMessage, IMatrixEvent } from "./matrixtypes";
import * as Discord from "better-discord.js";
import { IMatrixMessage } from "./matrixtypes";
import * as Parser from "node-html-parser";
import { Util } from "./util";
import { DiscordBot } from "./bot";
import { Client as MatrixClient } from "matrix-js-sdk";
import { MatrixClient } from "matrix-bot-sdk";
import { DiscordBridgeConfig } from "./config";
import {
IMatrixMessageParserCallbacks,
IMatrixMessageParserOpts,
MatrixMessageParser,
} from "matrix-discord-parser";
const MIN_NAME_LENGTH = 2;
const MAX_NAME_LENGTH = 32;
const MATRIX_TO_LINK = "https://matrix.to/#/";
const DEFAULT_ROOM_NOTIFY_POWER_LEVEL = 50;
export interface IMatrixMessageProcessorParams {
@ -34,326 +37,104 @@ export interface IMatrixMessageProcessorParams {
}
export class MatrixMessageProcessor {
private guild: Discord.Guild;
private listDepth: number = 0;
private listBulletPoints: string[] = ["●", "○", "■", "‣"];
private params?: IMatrixMessageProcessorParams;
constructor(public bot: DiscordBot) { }
private parser: MatrixMessageParser;
constructor(public bot: DiscordBot, private config: DiscordBridgeConfig) {
this.parser = new MatrixMessageParser();
}
public async FormatMessage(
msg: IMatrixMessage,
guild: Discord.Guild,
params?: IMatrixMessageProcessorParams,
): Promise<string> {
this.guild = guild;
this.listDepth = 0;
this.params = params;
let reply = "";
if (msg.formatted_body) {
// parser needs everything wrapped in html elements
// so we wrap everything in <div> just to be sure stuff is wrapped
// as <div> will be un-touched anyways
const parsed = Parser.parse(`<div>${msg.formatted_body}</div>`, {
lowerCaseTagName: true,
pre: true,
// tslint:disable-next-line no-any
} as any);
reply = await this.walkNode(parsed);
reply = reply.replace(/\s*$/, ""); // trim off whitespace at end
} else {
reply = await this.escapeDiscord(msg.body);
}
if (msg.msgtype === "m.emote") {
if (params &&
params.displayname &&
params.displayname.length >= MIN_NAME_LENGTH &&
params.displayname.length <= MAX_NAME_LENGTH) {
reply = `_${await this.escapeDiscord(params.displayname)} ${reply}_`;
} else {
reply = `_${reply}_`;
}
}
return reply;
const opts = this.getParserOpts(msg, guild, params);
return this.parser.FormatMessage(opts, msg);
}
private async canNotifyRoom() {
if (!this.params || !this.params.mxClient || !this.params.roomId || !this.params.userId) {
return false;
}
return await Util.CheckMatrixPermission(
this.params.mxClient,
this.params.userId,
this.params.roomId,
DEFAULT_ROOM_NOTIFY_POWER_LEVEL,
"notifications",
"room",
);
private getParserOpts(
msg: IMatrixMessage,
guild: Discord.Guild,
params?: IMatrixMessageProcessorParams,
): IMatrixMessageParserOpts {
return {
callbacks: this.getParserCallbacks(msg, guild, params),
determineCodeLanguage: this.config.bridge.determineCodeLanguage,
displayname: params ? params.displayname || "" : "",
};
}
private async escapeDiscord(msg: string): Promise<string> {
// \u200B is the zero-width space --> they still look the same but don't mention
msg = msg.replace(/@everyone/g, "@\u200Beveryone");
msg = msg.replace(/@here/g, "@\u200Bhere");
// Check the Matrix permissions to see if this user has the required
// power level to notify with @room; if so, replace it with @here.
if (msg.includes("@room") && await this.canNotifyRoom()) {
msg = msg.replace(/@room/g, "@here");
}
const escapeChars = ["\\", "*", "_", "~", "`", "|"];
msg = msg.split(" ").map((s) => {
if (s.match(/^https?:\/\//)) {
return s;
}
escapeChars.forEach((char) => {
s = s.replace(new RegExp("\\" + char, "g"), "\\" + char);
});
return s;
}).join(" ");
return msg;
}
private parsePreContent(node: Parser.HTMLElement): string {
let text = node.text;
const match = text.match(/^<code([^>]*)>/i);
if (!match) {
if (text[0] !== "\n") {
text = "\n" + text;
}
return text;
}
// remove <code> opening-tag
text = text.substr(match[0].length);
// remove </code> closing tag
text = text.replace(/<\/code>$/i, "");
if (text[0] !== "\n") {
text = "\n" + text;
}
const language = match[1].match(/language-(\w*)/i);
if (language) {
text = language[1] + text;
}
return text;
}
private parseUser(id: string): string {
const USER_REGEX = /^@_discord_([0-9]*)/;
const match = id.match(USER_REGEX);
if (!match || !this.guild.members.get(match[1])) {
return "";
}
return `<@${match[1]}>`;
}
private async parseChannel(id: string): Promise<string> {
const CHANNEL_REGEX = /^#_discord_[0-9]*_([0-9]*):/;
const match = id.match(CHANNEL_REGEX);
if (!match || !this.guild.channels.get(match[1])) {
/*
This isn't formatted in #_discord_, so let's fetch the internal room ID
and see if it is still a bridged room!
*/
if (this.params && this.params.mxClient) {
try {
const resp = await this.params.mxClient.getRoomIdForAlias(id);
if (resp && resp.room_id) {
const roomId = resp.room_id;
const channel = await this.bot.GetChannelFromRoomId(roomId);
return `<#${channel.id}>`;
private getParserCallbacks(
msg: IMatrixMessage,
guild: Discord.Guild,
params?: IMatrixMessageProcessorParams,
): IMatrixMessageParserCallbacks {
return {
canNotifyRoom: async () => {
if (!params || !params.mxClient || !params.roomId || !params.userId) {
return false;
}
return await Util.CheckMatrixPermission(
params.mxClient,
params.userId,
params.roomId,
DEFAULT_ROOM_NOTIFY_POWER_LEVEL,
"notifications",
"room",
);
},
getChannelId: async (mxid: string) => {
const CHANNEL_REGEX = /^#_discord_[0-9]*_([0-9]*):/;
const match = mxid.match(CHANNEL_REGEX);
const channel = match && guild.channels.resolve(match[1]);
if (!channel) {
/*
This isn't formatted in #_discord_, so let's fetch the internal room ID
and see if it is still a bridged room!
*/
if (params && params.mxClient) {
try {
const resp = await params.mxClient.lookupRoomAlias(mxid);
if (resp && resp.roomId) {
const roomId = resp.roomId;
const ch = await this.bot.GetChannelFromRoomId(roomId);
return ch.id;
}
} catch (err) { } // ignore, room ID wasn't found
}
} catch (err) { } // ignore, room ID wasn't found
}
return "";
}
return `<#${match[1]}>`;
}
private async parseLinkContent(node: Parser.HTMLElement): Promise<string> {
const attrs = node.attributes;
const content = await this.walkChildNodes(node);
if (!attrs.href || content === attrs.href) {
return content;
}
return `[${content}](${attrs.href})`;
}
private async parsePillContent(node: Parser.HTMLElement): Promise<string> {
const attrs = node.attributes;
if (!attrs.href || !attrs.href.startsWith(MATRIX_TO_LINK)) {
return await this.parseLinkContent(node);
}
const id = attrs.href.replace(MATRIX_TO_LINK, "");
let reply = "";
switch (id[0]) {
case "@":
// user pill
reply = this.parseUser(id);
break;
case "#":
reply = await this.parseChannel(id);
break;
}
if (!reply) {
return await this.parseLinkContent(node);
}
return reply;
}
private async parseImageContent(node: Parser.HTMLElement): Promise<string> {
const EMOTE_NAME_REGEX = /^:?(\w+):?/;
const attrs = node.attributes;
const name = attrs.alt || attrs.title || "";
let emoji: Discord.Emoji | null = null;
// first check for matching mxc url
if (attrs.src) {
let id = "";
try {
const emojiDb = await this.bot.GetEmojiByMxc(attrs.src);
id = emojiDb.EmojiId;
emoji = this.guild.emojis.find((e) => e.id === id);
} catch (e) {
emoji = null;
}
}
// nexc check for matching alt text / title
if (!emoji) {
const match = name.match(EMOTE_NAME_REGEX);
let emojiName = "";
if (match) {
emojiName = match[1];
emoji = this.guild.emojis.find((e) => e.name === emojiName);
}
}
if (!emoji) {
const content = await this.escapeDiscord(name);
const url = this.params && this.params.mxClient ? this.params.mxClient.mxcUrlToHttp(attrs.src) : attrs.src;
return attrs.src ? `[${content}](${url})` : content;
}
return `<${emoji.animated ? "a" : ""}:${emoji.name}:${emoji.id}>`;
}
private async parseBlockquoteContent(node: Parser.HTMLElement): Promise<string> {
let msg = await this.walkChildNodes(node);
msg = msg.split("\n").map((s) => {
return "> " + s;
}).join("\n");
msg = msg + "\n\n";
return msg;
}
private async parseUlContent(node: Parser.HTMLElement): Promise<string> {
this.listDepth++;
const entries = await this.arrayChildNodes(node, ["li"]);
this.listDepth--;
const bulletPoint = this.listBulletPoints[this.listDepth % this.listBulletPoints.length];
let msg = entries.map((s) => {
return `${" ".repeat(this.listDepth)}${bulletPoint} ${s}`;
}).join("\n");
if (this.listDepth === 0) {
msg = `\n${msg}\n\n`;
}
return msg;
}
private async parseOlContent(node: Parser.HTMLElement): Promise<string> {
this.listDepth++;
const entries = await this.arrayChildNodes(node, ["li"]);
this.listDepth--;
let entry = 0;
const attrs = node.attributes;
if (attrs.start && attrs.start.match(/^[0-9]+$/)) {
entry = parseInt(attrs.start, 10) - 1;
}
let msg = entries.map((s) => {
entry++;
return `${" ".repeat(this.listDepth)}${entry}. ${s}`;
}).join("\n");
if (this.listDepth === 0) {
msg = `\n${msg}\n\n`;
}
return msg;
}
private async arrayChildNodes(node: Parser.Node, types: string[] = []): Promise<string[]> {
const replies: string[] = [];
await Util.AsyncForEach(node.childNodes, async (child) => {
if (types.length && (
child.nodeType === Parser.NodeType.TEXT_NODE
|| !types.includes((child as Parser.HTMLElement).tagName)
)) {
return;
}
replies.push(await this.walkNode(child));
});
return replies;
}
private async walkChildNodes(node: Parser.Node): Promise<string> {
let reply = "";
await Util.AsyncForEach(node.childNodes, async (child) => {
reply += await this.walkNode(child);
});
return reply;
}
private async walkNode(node: Parser.Node): Promise<string> {
if (node.nodeType === Parser.NodeType.TEXT_NODE) {
// ignore \n between single nodes
if ((node as Parser.TextNode).text === "\n") {
return "";
}
return await this.escapeDiscord((node as Parser.TextNode).text);
} else if (node.nodeType === Parser.NodeType.ELEMENT_NODE) {
const nodeHtml = node as Parser.HTMLElement;
switch (nodeHtml.tagName) {
case "em":
case "i":
return `*${await this.walkChildNodes(nodeHtml)}*`;
case "strong":
case "b":
return `**${await this.walkChildNodes(nodeHtml)}**`;
case "u":
return `__${await this.walkChildNodes(nodeHtml)}__`;
case "del":
return `~~${await this.walkChildNodes(nodeHtml)}~~`;
case "code":
return `\`${nodeHtml.text}\``;
case "pre":
return `\`\`\`${this.parsePreContent(nodeHtml)}\`\`\``;
case "a":
return await this.parsePillContent(nodeHtml);
case "img":
return await this.parseImageContent(nodeHtml);
case "br":
return "\n";
case "blockquote":
return await this.parseBlockquoteContent(nodeHtml);
case "ul":
return await this.parseUlContent(nodeHtml);
case "ol":
return await this.parseOlContent(nodeHtml);
case "mx-reply":
return "";
case "hr":
return "\n----------\n";
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
const level = parseInt(nodeHtml.tagName[1], 10);
return `**${"#".repeat(level)} ${await this.walkChildNodes(nodeHtml)}**\n`;
default:
return await this.walkChildNodes(nodeHtml);
}
}
return "";
return null;
}
return match && match[1] || null;
},
getEmoji: async (mxc: string, name: string) => {
let emoji: {id: string, animated: boolean, name: string} | null = null;
try {
const emojiDb = await this.bot.GetEmojiByMxc(mxc);
const id = emojiDb.EmojiId;
emoji = guild.emojis.resolve(id);
} catch (e) {
emoji = null;
}
if (!emoji) {
emoji = guild.emojis.resolve(name);
}
return emoji;
},
getUserId: async (mxid: string) => {
const USER_REGEX = /^@_discord_([0-9]*)/;
const match = mxid.match(USER_REGEX);
const member = match && await guild.members.fetch(match[1]);
if (!match || !member) {
return null;
}
return match[1];
},
mxcUrlToHttp: (mxc: string) => {
if (params && params.mxClient) {
return params.mxClient.mxcToHttp(mxc);
}
return mxc;
},
};
}
}

View file

@ -14,26 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DiscordBot } from "./bot";
import {
Bridge,
RemoteRoom,
thirdPartyLookup,
thirdPartyProtocolResult,
thirdPartyUserResult,
thirdPartyLocationResult,
ProvisionedRoom,
Intent,
} from "matrix-appservice-bridge";
import { DiscordBot, IThirdPartyLookup } from "./bot";
import { DiscordBridgeConfig } from "./config";
import * as Discord from "discord.js";
import * as Discord from "better-discord.js";
import { Util } from "./util";
import { Provisioner } from "./provisioner";
import { Log } from "./log";
const log = new Log("MatrixRoomHandler");
import { IMatrixEvent } from "./matrixtypes";
import { DbRoomStore, MatrixStoreRoom, RemoteStoreRoom } from "./db/roomstore";
import { Appservice, Intent, IApplicationServiceProtocol } from "matrix-bot-sdk";
const ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoESwIsTbJrfXyAAogrNxA";
/* tslint:disable:no-magic-numbers */
@ -63,21 +53,37 @@ export class MatrixRoomHandler {
private discord: DiscordBot,
private config: DiscordBridgeConfig,
private provisioner: Provisioner,
private bridge: Bridge,
private bridge: Appservice,
private roomStore: DbRoomStore) {
this.botUserId = this.discord.BotUserId;
this.botJoinedRooms = new Set();
}
public get ThirdPartyLookup(): thirdPartyLookup {
return {
getLocation: this.tpGetLocation.bind(this),
getProtocol: this.tpGetProtocol.bind(this),
getUser: this.tpGetUser.bind(this),
parseLocation: this.tpParseLocation.bind(this),
parseUser: this.tpParseUser.bind(this),
protocols: ["discord"],
};
public bindThirdparty() {
this.bridge.on("thirdparty.protocol",
(protocol: string, cb: (protocolResponse: IApplicationServiceProtocol) => void) => {
this.tpGetProtocol(protocol)
.then(cb)
.catch((err) => log.warn("Failed to get protocol", err));
});
// tslint:disable-next-line:no-any
this.bridge.on("thirdparty.location.remote", (protocol: string, fields: any, cb: (response: any) => void) => {
this.tpGetLocation(protocol, fields)
.then(cb)
.catch((err) => log.warn("Failed to get remote locations", err));
});
// These are not supported.
this.bridge.on("thirdparty.location.matrix", (matrixId: string, cb: (response: null) => void) => {
cb(null);
});
this.bridge.on("thirdparty.user.remote", (matrixId: string, fields: unknown, cb: (response: null) => void) => {
cb(null);
});
this.bridge.on("thirdparty.user.matrix", (matrixId: string, cb: (response: null) => void) => {
cb(null);
});
}
public async OnAliasQueried(alias: string, roomId: string) {
@ -104,11 +110,13 @@ export class MatrixRoomHandler {
}
// Fire and forget RoomDirectory mapping
this.bridge.getIntent().getClient().setRoomDirectoryVisibilityAppService(
this.bridge.setRoomDirectoryVisibility(
channel.guild.id,
roomId,
"public",
);
).catch((err) => {
log.warn("Failed to set room directory visibility for new room:", err);
});
await this.discord.ChannelSyncroniser.OnUpdate(channel);
const promiseList: Promise<void>[] = [];
// Join a whole bunch of users.
@ -136,7 +144,9 @@ export class MatrixRoomHandler {
await Promise.all(promiseList);
}
public async OnAliasQuery(alias: string, aliasLocalpart: string): Promise<ProvisionedRoom> {
// tslint:disable-next-line no-any
public async OnAliasQuery(alias: string): Promise<any> {
const aliasLocalpart = alias.substr("#".length, alias.indexOf(":") - 1);
log.info("Got request for #", aliasLocalpart);
const srvChanPair = aliasLocalpart.substr("_discord_".length).split("_", ROOM_NAME_PARTS);
if (srvChanPair.length < ROOM_NAME_PARTS || srvChanPair[0] === "" || srvChanPair[1] === "") {
@ -150,10 +160,21 @@ export class MatrixRoomHandler {
} catch (err) {
log.error(`Couldn't find discord room '${aliasLocalpart}'.`, err);
}
}
public async tpGetProtocol(protocol: string): Promise<thirdPartyProtocolResult> {
public async tpGetProtocol(protocol: string): Promise<IApplicationServiceProtocol> {
const instances = {};
for (const guild of this.discord.GetGuilds()) {
instances[guild.name] = {
bot_user_id: this.botUserId,
desc: guild.name,
fields: {
guild_id: guild.id,
},
icon: guild.iconURL || ICON_URL,
network_id: guild.id,
};
}
return {
field_types: {
// guild_name: {
@ -181,43 +202,20 @@ export class MatrixRoomHandler {
regexp: "[A-Za-z0-9_\-]{2,100}",
},
},
instances: this.discord.GetGuilds().map((guild) => {
return {
bot_user_id: this.botUserId,
desc: guild.name,
fields: {
guild_id: guild.id,
},
icon: guild.iconURL || ICON_URL, // TODO: Use icons from our content repo. Potential security risk.
network_id: guild.id,
};
}),
icon: "", // TODO: Add this.
instances,
location_fields: ["guild_id", "channel_name"],
user_fields: ["username", "discriminator"],
};
}
// tslint:disable-next-line no-any
public async tpGetLocation(protocol: string, fields: any): Promise<thirdPartyLocationResult[]> {
public async tpGetLocation(protocol: string, fields: any): Promise<IThirdPartyLookup[]> {
log.info("Got location request ", protocol, fields);
const chans = this.discord.ThirdpartySearchForChannels(fields.guild_id, fields.channel_name);
return chans;
}
public async tpParseLocation(alias: string): Promise<thirdPartyLocationResult[]> {
throw {err: "Unsupported", code: HTTP_UNSUPPORTED};
}
// tslint:disable-next-line no-any
public async tpGetUser(protocol: string, fields: any): Promise<thirdPartyUserResult[]> {
log.info("Got user request ", protocol, fields);
throw {err: "Unsupported", code: HTTP_UNSUPPORTED};
}
public async tpParseUser(userid: string): Promise<thirdPartyUserResult[]> {
throw {err: "Unsupported", code: HTTP_UNSUPPORTED};
}
private async joinRoom(intent: Intent, roomIdOrAlias: string, member?: Discord.GuildMember): Promise<void> {
let currentSchedule = JOIN_ROOM_SCHEDULE[0];
const doJoin = async () => {
@ -225,15 +223,15 @@ export class MatrixRoomHandler {
if (member) {
await this.discord.UserSyncroniser.JoinRoom(member, roomIdOrAlias);
} else {
await intent.getClient().joinRoom(roomIdOrAlias);
await intent.joinRoom(roomIdOrAlias);
}
};
const errorHandler = async (err) => {
log.error(`Error joining room ${roomIdOrAlias} as ${intent.getClient().getUserId()}`);
log.error(`Error joining room ${roomIdOrAlias} as ${intent.userId}`);
log.error(err);
const idx = JOIN_ROOM_SCHEDULE.indexOf(currentSchedule);
if (idx === JOIN_ROOM_SCHEDULE.length - 1) {
log.warn(`Cannot join ${roomIdOrAlias} as ${intent.getClient().getUserId()}`);
log.warn(`Cannot join ${roomIdOrAlias} as ${intent.userId}`);
throw new Error(err);
} else {
currentSchedule = JOIN_ROOM_SCHEDULE[idx + 1];
@ -253,7 +251,7 @@ export class MatrixRoomHandler {
}
private async createMatrixRoom(channel: Discord.TextChannel,
alias: string, aliasLocalpart: string): ProvisionedRoom {
alias: string, aliasLocalpart: string) {
const remote = new RemoteStoreRoom(`discord_${channel.guild.id}_${channel.id}`, {
discord_channel: channel.id,
discord_guild: channel.guild.id,
@ -280,8 +278,6 @@ export class MatrixRoomHandler {
new MatrixStoreRoom(alias),
remote,
);
return {
creationOpts,
} as ProvisionedRoom;
return creationOpts;
}
}

View file

@ -52,6 +52,8 @@ export interface IMatrixMessage {
msgtype: string;
formatted_body?: string;
format?: string;
"m.new_content"?: any; // tslint:disable-line no-any
"m.relates_to"?: any; // tslint:disable-line no-any
}
export interface IMatrixMediaInfo {

View file

@ -14,30 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { PrometheusMetrics, Bridge } from "matrix-appservice-bridge";
import { Gauge, Counter, Histogram } from "prom-client";
import { Gauge, Counter, Histogram, default as promClient } from "prom-client";
import { Log } from "./log";
import { Appservice,
IMetricContext,
METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL,
METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL,
FunctionCallContext,
METRIC_MATRIX_CLIENT_FUNCTION_CALL} from "matrix-bot-sdk";
import { DiscordBridgeConfigMetrics } from "./config";
import * as http from "http";
const AgeCounters = PrometheusMetrics.AgeCounters;
const log = new Log("BridgeMetrics");
const REQUEST_EXPIRE_TIME_MS = 30000;
interface IAgeCounter {
setGauge(gauge: Gauge, morelabels: string[]);
bump(age: number);
}
interface IBridgeGauges {
matrixRoomConfigs: number;
remoteRoomConfigs: number;
matrixGhosts: number;
remoteGhosts: number;
matrixRoomsByAge: IAgeCounter;
remoteRoomsByAge: IAgeCounter;
matrixUsersByAge: IAgeCounter;
remoteUsersByAge: IAgeCounter;
}
export interface IBridgeMetrics {
registerRequest(id: string);
requestOutcome(id: string, isRemote: boolean, outcome: string);
@ -67,55 +57,71 @@ export class MetricPeg {
}
export class PrometheusBridgeMetrics implements IBridgeMetrics {
private metrics;
private remoteCallCounter: Counter;
private storeCallCounter: Counter;
private presenceGauge: Gauge;
private remoteRequest: Histogram;
private matrixRequest: Histogram;
private matrixCallCounter: Counter<string>;
private remoteCallCounter: Counter<string>;
private storeCallCounter: Counter<string>;
private presenceGauge: Gauge<string>;
private remoteRequest: Histogram<string>;
private matrixRequest: Histogram<string>;
private requestsInFlight: Map<string, number>;
private bridgeGauges: IBridgeGauges = {
matrixGhosts: 0,
matrixRoomConfigs: 0,
matrixRoomsByAge: new AgeCounters(),
matrixUsersByAge: new AgeCounters(),
remoteGhosts: 0,
remoteRoomConfigs: 0,
remoteRoomsByAge: new AgeCounters(),
remoteUsersByAge: new AgeCounters(),
};
private matrixRequestStatus: Map<string, "success"|"failed">;
private httpServer: http.Server;
public init(bridge: Bridge) {
this.metrics = new PrometheusMetrics();
this.metrics.registerMatrixSdkMetrics();
this.metrics.registerBridgeGauges(() => this.bridgeGauges);
this.metrics.addAppServicePath(bridge);
this.remoteCallCounter = this.metrics.addCounter({
public init(as: Appservice, config: DiscordBridgeConfigMetrics) {
promClient.collectDefaultMetrics();
// TODO: Bind this for every user.
this.httpServer = http.createServer((req, res) => {
if (req.method !== "GET" || req.url !== "/metrics") {
// tslint:disable-next-line:no-magic-numbers
res.writeHead(404, "Not found");
res.end();
}
// tslint:disable-next-line:no-magic-numbers
res.writeHead(200, "OK", {"Content-Type": promClient.register.contentType});
res.write(promClient.register.metrics());
res.end();
});
this.matrixCallCounter = new Counter({
help: "Count of matrix API calls made",
labelNames: ["method", "result"],
name: "matrix_api_calls",
});
promClient.register.registerMetric(this.matrixCallCounter);
this.remoteCallCounter = new Counter({
help: "Count of remote API calls made",
labels: ["method"],
labelNames: ["method"],
name: "remote_api_calls",
});
this.storeCallCounter = this.metrics.addCounter({
promClient.register.registerMetric(this.remoteCallCounter);
this.storeCallCounter = new Counter({
help: "Count of store function calls made",
labels: ["method", "cached"],
labelNames: ["method", "cached"],
name: "store_calls",
});
this.presenceGauge = this.metrics.addGauge({
help: "Count of users in the presence queue",
labels: [],
promClient.register.registerMetric(this.storeCallCounter);
this.presenceGauge = new Gauge({
help: "Count of users in the presence queue",
name: "active_presence_users",
});
this.matrixRequest = this.metrics.addTimer({
promClient.register.registerMetric(this.presenceGauge);
this.matrixRequest = new Histogram({
help: "Histogram of processing durations of received Matrix messages",
labels: ["outcome"],
labelNames: ["outcome"],
name: "matrix_request_seconds",
});
this.remoteRequest = this.metrics.addTimer({
promClient.register.registerMetric(this.matrixRequest);
this.remoteRequest = new Histogram({
help: "Histogram of processing durations of received remote messages",
labels: ["outcome"],
labelNames: ["outcome"],
name: "remote_request_seconds",
});
promClient.register.registerMetric(this.remoteRequest);
this.requestsInFlight = new Map();
setInterval(() => {
this.requestsInFlight.forEach((time, id) => {
@ -124,6 +130,17 @@ export class PrometheusBridgeMetrics implements IBridgeMetrics {
}
});
}, REQUEST_EXPIRE_TIME_MS);
this.httpServer.listen(config.port, config.host);
// Bind bot-sdk metrics
as.botClient.metrics.registerListener({
onDecrement: this.sdkDecrementMetric.bind(this),
onEndMetric: this.sdkEndMetric.bind(this),
onIncrement: this.sdkIncrementMetric.bind(this),
onReset: this.sdkResetMetric.bind(this),
onStartMetric: this.sdkStartMetric.bind(this),
});
return this;
}
@ -152,4 +169,36 @@ export class PrometheusBridgeMetrics implements IBridgeMetrics {
public storeCall(method: string, cached: boolean) {
this.storeCallCounter.inc({method, cached: cached ? "yes" : "no"});
}
private sdkStartMetric(metricName: string, context: IMetricContext) {
// We don't use this yet.
}
private sdkEndMetric(metricName: string, context: FunctionCallContext, timeMs: number) {
if (metricName !== METRIC_MATRIX_CLIENT_FUNCTION_CALL) {
return; // We don't handle any other type yet.
}
const successFail = this.matrixRequestStatus.get(context.uniqueId)!;
this.matrixRequestStatus.delete(context.uniqueId);
this.matrixRequest.observe({
method: context.functionName,
result: successFail,
}, timeMs);
}
private sdkResetMetric(metricName: string, context: IMetricContext) {
// We don't use this yet.
}
private sdkIncrementMetric(metricName: string, context: IMetricContext, amount: number) {
if (metricName === METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL) {
this.matrixRequestStatus.set(context.uniqueId, "success");
} else if (metricName === METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL) {
this.matrixRequestStatus.set(context.uniqueId, "failed");
}
}
private sdkDecrementMetric(metricName: string, context: IMetricContext, amount: number) {
// We don't use this yet.
}
}

View file

@ -14,26 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { User, Presence } from "discord.js";
import { User, Presence } from "better-discord.js";
import { DiscordBot } from "./bot";
import { Log } from "./log";
import { MetricPeg } from "./metrics";
const log = new Log("PresenceHandler");
export class PresenceHandlerStatus {
/* One of: ["online", "offline", "unavailable"] */
public Presence: string;
public Presence: "online"|"offline"|"unavailable";
public StatusMsg: string;
public ShouldDrop: boolean = false;
}
interface IMatrixPresence {
presence?: string;
presence?: "online"|"offline"|"unavailable";
status_msg?: string;
}
export class PresenceHandler {
private presenceQueue: User[];
private presenceQueue: Presence[];
private interval: NodeJS.Timeout | null;
constructor(private bot: DiscordBot) {
this.presenceQueue = [];
@ -63,17 +62,24 @@ export class PresenceHandler {
this.interval = null;
}
public EnqueueUser(user: User) {
if (user.id !== this.bot.GetBotId() && this.presenceQueue.find((u) => u.id === user.id) === undefined) {
log.info(`Adding ${user.id} (${user.username}) to the presence queue`);
this.presenceQueue.push(user);
MetricPeg.get.setPresenceCount(this.presenceQueue.length);
public EnqueueUser(presence: Presence) {
if (presence.userID === this.bot.GetBotId()) {
return;
}
// Delete stale presence
const indexOfPresence = this.presenceQueue.findIndex((u) => u.userID === presence.userID);
if (indexOfPresence !== -1) {
this.presenceQueue.splice(indexOfPresence, 1);
}
log.verbose(`Adding ${presence.userID} (${presence.user?.username}) to the presence queue`);
this.presenceQueue.push(presence);
MetricPeg.get.setPresenceCount(this.presenceQueue.length);
}
public DequeueUser(user: User) {
const index = this.presenceQueue.findIndex((item) => {
return user.id === item.id;
return user.id === item.userID;
});
if (index !== -1) {
this.presenceQueue.splice(index, 1);
@ -85,20 +91,23 @@ export class PresenceHandler {
}
}
public async ProcessUser(user: User): Promise<boolean> {
const status = this.getUserPresence(user.presence);
await this.setMatrixPresence(user, status);
public async ProcessUser(presence: Presence): Promise<boolean> {
if (!presence.user) {
return true;
}
const status = this.getUserPresence(presence);
await this.setMatrixPresence(presence.user, status);
return status.ShouldDrop;
}
private async processIntervalThread() {
const user = this.presenceQueue.shift();
if (user) {
const proccessed = await this.ProcessUser(user);
const presence = this.presenceQueue.shift();
if (presence) {
const proccessed = await this.ProcessUser(presence);
if (!proccessed) {
this.presenceQueue.push(user);
this.presenceQueue.push(presence);
} else {
log.info(`Dropping ${user.id} from the presence queue.`);
log.verbose(`Dropping ${presence.userID} from the presence queue.`);
MetricPeg.get.setPresenceCount(this.presenceQueue.length);
}
}
@ -107,10 +116,13 @@ export class PresenceHandler {
private getUserPresence(presence: Presence): PresenceHandlerStatus {
const status = new PresenceHandlerStatus();
if (presence.game) {
status.StatusMsg = `${presence.game.streaming ? "Streaming" : "Playing"} ${presence.game.name}`;
if (presence.game.url) {
status.StatusMsg += ` | ${presence.game.url}`;
// How do we show multiple activities?
const activity = presence.activities[0];
if (activity) {
const type = activity.type[0] + activity.type.substring(1).toLowerCase(); // STREAMING -> Streaming;
status.StatusMsg = `${type} ${activity.name}`;
if (activity.url) {
status.StatusMsg += ` | ${activity.url}`;
}
}
@ -118,7 +130,7 @@ export class PresenceHandler {
status.Presence = "online";
} else if (presence.status === "dnd") {
status.Presence = "online";
status.StatusMsg = status.StatusMsg ? "Do not disturb | " + status.StatusMsg : "Do not disturb";
status.StatusMsg = status.StatusMsg ? `Do not disturb | ${status.StatusMsg}` : "Do not disturb";
} else if (presence.status === "offline") {
status.Presence = "offline";
status.ShouldDrop = true; // Drop until we recieve an update.
@ -130,12 +142,9 @@ export class PresenceHandler {
private async setMatrixPresence(user: User, status: PresenceHandlerStatus) {
const intent = this.bot.GetIntentFromDiscordMember(user);
const statusObj: IMatrixPresence = {presence: status.Presence};
if (status.StatusMsg) {
statusObj.status_msg = status.StatusMsg;
}
try {
await intent.getClient().setPresence(statusObj);
await intent.ensureRegistered();
await intent.underlyingClient.setPresenceStatus(status.Presence, status.StatusMsg);
} catch (ex) {
if (ex.errcode !== "M_FORBIDDEN") {
log.warn(`Could not update Matrix presence for ${user.id}`);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Discord from "discord.js";
import * as Discord from "better-discord.js";
import { DbRoomStore, RemoteStoreRoom, MatrixStoreRoom } from "./db/roomstore";
import { ChannelSyncroniser } from "./channelsyncroniser";
import { Log } from "./log";
@ -36,7 +36,6 @@ export class Provisioner {
discord_type: "text",
plumbed: true,
});
const local = new MatrixStoreRoom(roomId);
return this.roomStore.linkRooms(local, remote);
}

View file

@ -24,20 +24,21 @@ import { Postgres } from "./db/postgres";
import { IDatabaseConnector } from "./db/connector";
import { DbRoomStore } from "./db/roomstore";
import { DbUserStore } from "./db/userstore";
import {
RoomStore, UserStore,
} from "matrix-appservice-bridge";
import { IAppserviceStorageProvider } from "matrix-bot-sdk";
const log = new Log("DiscordStore");
export const CURRENT_SCHEMA = 10;
export const CURRENT_SCHEMA = 11;
/**
* Stores data for specific users and data not specific to rooms.
*/
export class DiscordStore {
export class DiscordStore implements IAppserviceStorageProvider {
public db: IDatabaseConnector;
private config: DiscordBridgeConfigDatabase;
private pRoomStore: DbRoomStore;
private pUserStore: DbUserStore;
private registeredUsers: Set<string>;
private asTxns: Set<string>;
constructor(configOrFile: DiscordBridgeConfigDatabase|string) {
if (typeof(configOrFile) === "string") {
this.config = new DiscordBridgeConfigDatabase();
@ -45,6 +46,8 @@ export class DiscordStore {
} else {
this.config = configOrFile;
}
this.registeredUsers = new Set();
this.asTxns = new Set();
}
get roomStore() {
@ -66,7 +69,7 @@ export class DiscordStore {
}
const BACKUP_NAME = this.config.filename + ".backup";
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
// Check to see if a backup file already exists.
fs.access(BACKUP_NAME, (err) => {
return resolve(err === null);
@ -91,10 +94,8 @@ export class DiscordStore {
* Checks the database has all the tables needed.
*/
public async init(
overrideSchema: number = 0, roomStore: RoomStore = null, userStore: UserStore = null,
overrideSchema: number = 0,
): Promise<void> {
const SCHEMA_ROOM_STORE_REQUIRED = 8;
const SCHEMA_USER_STORE_REQUIRED = 9;
log.info("Starting DB Init");
await this.openDatabase();
let version = await this.getSchemaVersion();
@ -102,15 +103,9 @@ export class DiscordStore {
log.info(`Database schema version is ${version}, latest version is ${targetSchema}`);
while (version < targetSchema) {
version++;
const schemaClass = require(`./db/schema/v${version}.js`).Schema;
const schemaClass = require(`./db/schema/v${version}`).Schema;
let schema: IDbSchema;
if (version === SCHEMA_ROOM_STORE_REQUIRED) { // 8 requires access to the roomstore.
schema = (new schemaClass(roomStore) as IDbSchema);
} else if (version === SCHEMA_USER_STORE_REQUIRED) {
schema = (new schemaClass(userStore) as IDbSchema);
} else {
schema = (new schemaClass() as IDbSchema);
}
schema = (new schemaClass() as IDbSchema);
log.info(`Updating database to v${version}, "${schema.description}"`);
try {
await schema.run(this);
@ -130,6 +125,14 @@ export class DiscordStore {
await this.setSchemaVersion(version);
}
log.info("Updated database to the latest schema");
// We need to prepopulate some sets
for (const row of await this.db.All("SELECT * FROM registered_users")) {
this.registeredUsers.add(row.user_id as string);
}
for (const row of await this.db.All("SELECT * FROM as_txns")) {
this.asTxns.add(row.txn_id as string);
}
}
public async close() {
@ -172,23 +175,30 @@ export class DiscordStore {
}
}
public async deleteUserToken(discordId: string): Promise<void> {
public async deleteUserToken(mxid: string): Promise<void> {
const res = await this.db.Get("SELECT * from user_id_discord_id WHERE user_id = $id", {
id: mxid,
});
if (!res) {
return;
}
const discordId = res.discord_id;
log.silly("SQL", "deleteUserToken => ", discordId);
try {
await Promise.all([
this.db.Run(
`
DELETE FROM user_id_discord_id WHERE discord_id = $id;
DELETE FROM user_id_discord_id WHERE discord_id = $id
`
, {
$id: discordId,
id: discordId,
}),
this.db.Run(
`
DELETE FROM discord_id_token WHERE discord_id = $id;
DELETE FROM discord_id_token WHERE discord_id = $id
`
, {
$id: discordId,
id: discordId,
}),
]);
} catch (err) {
@ -238,7 +248,8 @@ export class DiscordStore {
throw err;
}
}
// tslint:disable-next-line no-any
// tslint:disable-next-line no-any callable-types
public async Get<T extends IDbData>(dbType: {new(): T; }, params: any): Promise<T|null> {
const dType = new dbType();
log.silly(`get <${dType.constructor.name} with params ${params}>`);
@ -267,6 +278,26 @@ export class DiscordStore {
await data.Delete(this);
}
public addRegisteredUser(userId: string) {
this.registeredUsers.add(userId);
this.db.Run("INSERT INTO registered_users VALUES ($userId)", {userId}).catch((err) => {
log.warn("Failed to insert registered user", err);
});
}
public isUserRegistered(userId: string): boolean {
return this.registeredUsers.has(userId);
}
public setTransactionCompleted(transactionId: string) {
this.asTxns.add(transactionId);
this.db.Run("INSERT INTO as_txns (txn_id) VALUES ($transactionId)", {transactionId}).catch((err) => {
log.warn("Failed to insert txn", err);
});
}
public isTransactionCompleted(transactionId: string): boolean {
return this.asTxns.has(transactionId);
}
private async getSchemaVersion( ): Promise<number> {
log.silly("_get_schema_version");
let version = 0;

60
src/structures/lock.ts Normal file
View file

@ -0,0 +1,60 @@
export class Lock<T> {
private locks: Map<T, {i: NodeJS.Timeout|null, r: (() => void)|null}>;
private lockPromises: Map<T, Promise<{}>>;
constructor(
private timeout: number,
) {
this.locks = new Map();
this.lockPromises = new Map();
}
public set(key: T) {
// if there is a lock set.....we don't set a second one ontop
if (this.locks.has(key)) {
return;
}
// set a dummy lock so that if we re-set again before releasing it won't do anthing
this.locks.set(key, {i: null, r: null});
const p = new Promise<{}>((resolve) => {
// first we check if the lock has the key....if not, e.g. if it
// got released too quickly, we still want to resolve our promise
if (!this.locks.has(key)) {
resolve();
return;
}
// create the interval that will release our promise after the timeout
const i = setTimeout(() => {
this.release(key);
}, this.timeout);
// aaand store to our lock
this.locks.set(key, {r: resolve, i});
});
this.lockPromises.set(key, p);
}
public release(key: T) {
// if there is nothing to release then there is nothing to release
if (!this.locks.has(key)) {
return;
}
const lock = this.locks.get(key)!;
if (lock.r !== null) {
lock.r();
}
if (lock.i !== null) {
clearTimeout(lock.i);
}
this.locks.delete(key);
this.lockPromises.delete(key);
}
public async wait(key: T) {
// we wait for a lock release only if a promise is present
const promise = this.lockPromises.get(key);
if (promise) {
await promise;
}
}
}

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { User, GuildMember } from "discord.js";
import { User, GuildMember } from "better-discord.js";
import { DiscordBot } from "./bot";
import { Util } from "./util";
import { Bridge, Intent, MatrixUser } from "matrix-appservice-bridge";
import { DiscordBridgeConfig } from "./config";
import { Log } from "./log";
import { IMatrixEvent } from "./matrixtypes";
import { DbUserStore, RemoteUser } from "./db/userstore";
import { Appservice, Intent } from "matrix-bot-sdk";
const log = new Log("UserSync");
@ -82,7 +82,7 @@ export class UserSyncroniser {
// roomId+userId => ev
public userStateHold: Map<string, IMatrixEvent>;
constructor(
private bridge: Bridge,
private bridge: Appservice,
private config: DiscordBridgeConfig,
private discord: DiscordBot,
private userStore: DbUserStore) {
@ -96,8 +96,8 @@ export class UserSyncroniser {
* @returns {Promise<void>}
* @constructor
*/
public async OnUpdateUser(discordUser: User, webhookID?: string) {
const userState = await this.GetUserUpdateState(discordUser, webhookID);
public async OnUpdateUser(discordUser: User, isWebhook: boolean = false) {
const userState = await this.GetUserUpdateState(discordUser, isWebhook);
try {
await this.ApplyStateToProfile(userState);
} catch (e) {
@ -106,7 +106,7 @@ export class UserSyncroniser {
}
public async ApplyStateToProfile(userState: IUserState) {
const intent = this.bridge.getIntent(userState.mxUserId);
const intent = this.bridge.getIntentForUserId(userState.mxUserId);
let userUpdated = false;
let remoteUser: RemoteUser;
if (userState.createUser) {
@ -122,30 +122,32 @@ export class UserSyncroniser {
const rUser = await this.userStore.getRemoteUser(userState.id);
remoteUser = rUser ? rUser : new RemoteUser(userState.id);
}
await intent.ensureRegistered();
if (userState.displayName !== null) {
log.verbose(`Updating displayname for ${userState.mxUserId} to "${userState.displayName}"`);
await intent.setDisplayName(userState.displayName);
await intent.underlyingClient.setDisplayName(userState.displayName);
remoteUser.displayname = userState.displayName;
userUpdated = true;
}
if (userState.avatarUrl !== null) {
log.verbose(`Updating avatar_url for ${userState.mxUserId} to "${userState.avatarUrl}"`);
const avatarMxc = await Util.UploadContentFromUrl(
userState.avatarUrl,
intent,
const data = await Util.DownloadFile(userState.avatarUrl);
const avatarMxc = await intent.underlyingClient.uploadContent(
data.buffer,
data.mimeType,
userState.avatarId,
);
await intent.setAvatarUrl(avatarMxc.mxcUrl);
await intent.underlyingClient.setAvatarUrl(avatarMxc);
remoteUser.avatarurl = userState.avatarUrl;
remoteUser.avatarurlMxc = avatarMxc.mxcUrl;
remoteUser.avatarurlMxc = avatarMxc;
userUpdated = true;
}
if (userState.removeAvatar) {
log.verbose(`Clearing avatar_url for ${userState.mxUserId} to "${userState.avatarUrl}"`);
await intent.setAvatarUrl(null);
await intent.underlyingClient.setAvatarUrl("");
remoteUser.avatarurl = null;
remoteUser.avatarurlMxc = null;
userUpdated = true;
@ -157,10 +159,10 @@ export class UserSyncroniser {
}
}
public async JoinRoom(member: GuildMember | User, roomId: string, webhookID?: string) {
public async JoinRoom(member: GuildMember | User, roomId: string, isWebhook: boolean = false) {
let state: IGuildMemberState;
if (member instanceof User) {
state = await this.GetUserStateForDiscordUser(member, webhookID);
state = await this.GetUserStateForDiscordUser(member, isWebhook);
} else {
state = await this.GetUserStateForGuildMember(member);
}
@ -175,7 +177,7 @@ export class UserSyncroniser {
} else {
log.info(`User not in room ${roomId}, inviting`);
try {
await this.bridge.getIntent().invite(roomId, state.mxUserId);
await this.bridge.botIntent.underlyingClient.inviteUser(state.mxUserId, roomId);
await this.ApplyStateToRoom(state, roomId, guildId);
} catch (e) {
log.error(`Failed to join ${state.id} to ${roomId}`, e);
@ -204,10 +206,10 @@ export class UserSyncroniser {
} else {
log.warn("Remote user wasn't found, using blank avatar");
}
const intent = this.bridge.getIntent(memberState.mxUserId);
const intent = this.bridge.getIntentForUserId(memberState.mxUserId);
/* The intent class tries to be smart and deny a state update for <PL50 users.
Obviously a user can change their own state so we use the client instead. */
await intent.getClient().sendStateEvent(roomId, "m.room.member", {
await intent.underlyingClient.sendStateEvent(roomId, "m.room.member", memberState.mxUserId, {
"avatar_url": avatar,
"displayname": memberState.displayName,
"membership": "join",
@ -218,7 +220,7 @@ export class UserSyncroniser {
roles: memberState.roles,
username: memberState.username,
},
}, memberState.mxUserId);
});
if (remoteUser) {
if (guildId) {
@ -228,15 +230,18 @@ export class UserSyncroniser {
}
}
public async GetUserUpdateState(discordUser: User, webhookID?: string): Promise<IUserState> {
public async GetUserUpdateState(discordUser: User, isWebhook: boolean = false): Promise<IUserState> {
log.verbose(`State update requested for ${discordUser.id}`);
let mxidExtra = "";
if (webhookID) {
// no need to escape as this mxid is only used to create an intent
mxidExtra = `_${new MatrixUser(`@${webhookID}`).localpart}`;
if (isWebhook) {
// for webhooks we append the username to the mxid, as webhooks with the same
// id can have multiple different usernames set. This way we don't spam
// userstate changes
mxidExtra = `_${Util.ParseMxid(`@${discordUser.username}`).localpart}`;
}
const userState: IUserState = Object.assign({}, DEFAULT_USER_STATE, {
id: discordUser.id,
id: discordUser.id + mxidExtra,
mxUserId: `@_discord_${discordUser.id}${mxidExtra}:${this.config.bridge.domain}`,
});
const displayName = Util.ApplyPatternString(this.config.ghosts.usernamePattern, {
@ -251,8 +256,10 @@ export class UserSyncroniser {
log.verbose(`Could not find user in remote user store.`);
userState.createUser = true;
userState.displayName = displayName;
userState.avatarUrl = discordUser.avatarURL;
userState.avatarId = discordUser.avatar;
if (discordUser.avatar) {
userState.avatarUrl = discordUser.avatarURL();
userState.avatarId = discordUser.avatar;
}
return userState;
}
@ -263,13 +270,13 @@ export class UserSyncroniser {
}
const oldAvatarUrl = remoteUser.avatarurl;
if (oldAvatarUrl !== discordUser.avatarURL) {
if (oldAvatarUrl !== discordUser.avatarURL()) {
log.verbose(`User ${discordUser.id} avatarurl should be updated`);
if (discordUser.avatarURL !== null) {
userState.avatarUrl = discordUser.avatarURL;
if (discordUser.avatar) {
userState.avatarUrl = discordUser.avatarURL();
userState.avatarId = discordUser.avatar;
} else {
userState.removeAvatar = oldAvatarUrl !== null;
userState.removeAvatar = true;
}
}
@ -291,7 +298,7 @@ export class UserSyncroniser {
displayName: name,
id: newMember.id,
mxUserId: `@_discord_${newMember.id}:${this.config.bridge.domain}`,
roles: newMember.roles.map((role) => { return {
roles: newMember.roles.cache.map((role) => { return {
color: role.color,
name: role.name,
position: role.position,
@ -303,17 +310,19 @@ export class UserSyncroniser {
public async GetUserStateForDiscordUser(
user: User,
webhookID?: string,
isWebhook: boolean = false,
): Promise<IGuildMemberState> {
let mxidExtra = "";
if (webhookID) {
// no need to escape as this mxid is only used to create an Intent
mxidExtra = `_${new MatrixUser(`@${user.username}`).localpart}`;
if (isWebhook) {
// for webhooks we append the username to the mxid, as webhooks with the same
// id can have multiple different usernames set. This way we don't spam
// userstate changes
mxidExtra = "_" + Util.ParseMxid(`@${user.username}`, false).localpart;
}
const guildState: IGuildMemberState = Object.assign({}, DEFAULT_GUILD_STATE, {
bot: user.bot,
displayName: user.username,
id: user.id,
id: user.id + mxidExtra,
mxUserId: `@_discord_${user.id}${mxidExtra}:${this.config.bridge.domain}`,
roles: [],
username: user.tag,
@ -333,7 +342,7 @@ export class UserSyncroniser {
const intent = this.discord.GetIntentFromDiscordMember(member);
return Promise.all(
rooms.map(
async (roomId) => this.leave(intent, roomId, false),
async (roomId) => this.leave(intent, roomId),
),
);
}
@ -374,12 +383,12 @@ export class UserSyncroniser {
),
);
const userId = state.mxUserId;
const intent = this.bridge.getIntent(userId);
const intent = this.bridge.getIntentForUserId(userId);
await Promise.all(
leaveRooms.map(
async (roomId) => {
try {
await this.leave(intent, roomId, true);
await this.leave(intent, roomId);
} catch (e) { } // not in room
},
),
@ -391,9 +400,9 @@ export class UserSyncroniser {
log.info(`Got update for ${id}.`);
await Util.AsyncForEach(this.discord.GetGuilds(), async (guild) => {
if (guild.members.has(id)) {
if (guild.members.cache.has(id)) {
log.info(`Updating user ${id} in guild ${guild.id}.`);
const member = guild.members.get(id);
const member = guild.members.resolve(id);
try {
const state = await this.GetUserStateForGuildMember(member!);
const rooms = await this.discord.GetRoomIdsFromGuild(guild, member!);
@ -409,13 +418,7 @@ export class UserSyncroniser {
});
}
private async leave(intent: Intent, roomId: string, checkCache: boolean = true) {
const userId = intent.getClient().getUserId();
if (checkCache && ![null, "join", "invite"]
.includes(intent.opts.backingStore.getMembership(roomId, userId))) {
return;
}
await intent.leave(roomId);
intent.opts.backingStore.setMembership(roomId, userId, "leave");
private async leave(intent: Intent, roomId: string) {
await intent.underlyingClient.leaveRoom(roomId);
}
}

View file

@ -16,17 +16,15 @@ limitations under the License.
import * as http from "http";
import * as https from "https";
import { Intent } from "matrix-appservice-bridge";
import { Buffer } from "buffer";
import * as mime from "mime";
import { Permissions } from "discord.js";
import { Permissions } from "better-discord.js";
import { DiscordBridgeConfig } from "./config";
import { Client as MatrixClient } from "matrix-js-sdk";
import { IMatrixEvent } from "./matrixtypes";
const HTTP_OK = 200;
import { Log } from "./log";
import { Intent, MatrixClient } from "matrix-bot-sdk";
const log = new Log("Util");
type PERMISSIONTYPES = any | any[]; // tslint:disable-line no-any
@ -54,6 +52,11 @@ export interface ICommandParameters {
export type CommandPermissonCheck = (permission: PERMISSIONTYPES) => Promise<boolean | string>;
export interface IDownloadedFile {
buffer: Buffer;
mimeType?: string;
}
export interface IPatternMap {
[index: string]: string;
}
@ -63,15 +66,13 @@ export class Util {
* downloadFile - This function will take a URL and store the resulting data into
* a buffer.
*/
public static async DownloadFile(url: string): Promise<Buffer> {
public static async DownloadFile(url: string): Promise<IDownloadedFile> {
return new Promise((resolve, reject) => {
let ht;
let get = http.get;
if (url.startsWith("https")) {
ht = https;
} else {
ht = http;
get = https.get;
}
const req = ht.get((url), (res) => {
const req = get((url), (res) => {
let buffer = Buffer.alloc(0);
if (res.statusCode !== HTTP_OK) {
reject(`Non 200 status code (${res.statusCode})`);
@ -82,72 +83,16 @@ export class Util {
});
res.on("end", () => {
resolve(buffer);
resolve({
buffer,
mimeType: res.headers["content-type"],
});
});
});
req.on("error", (err) => {
reject(`Failed to download. ${err.code}`);
reject(`Failed to download. ${err}`);
});
}) as Promise<Buffer>;
}
/**
* uploadContentFromUrl - Upload content from a given URL to the homeserver
* and return a MXC URL.
*/
public static async UploadContentFromUrl(url: string, intent: Intent, name: string | null): Promise<IUploadResult> {
let contenttype;
name = name || null;
try {
const bufferRet = (await (new Promise((resolve, reject) => {
let ht;
if (url.startsWith("https")) {
ht = https;
} else {
ht = http;
}
const req = ht.get( url, (res) => {
let buffer = Buffer.alloc(0);
if (res.headers.hasOwnProperty("content-type")) {
contenttype = res.headers["content-type"];
} else {
log.verbose("No content-type given by server, guessing based on file name.");
contenttype = mime.lookup(url);
}
if (name === null) {
const names = url.split("/");
name = names[names.length - 1];
}
res.on("data", (d) => {
buffer = Buffer.concat([buffer, d]);
});
res.on("end", () => {
resolve(buffer);
});
});
req.on("error", (err) => {
reject(`Failed to download. ${err.code}`);
});
}))) as Buffer;
const size = bufferRet.length;
const contentUri = await intent.getClient().uploadContent(bufferRet, {
name,
onlyContentUri: true,
rawResponse: false,
type: contenttype,
});
log.verbose("Media uploaded to ", contentUri);
return {
mxcUrl: contentUri,
size,
};
} catch (reason) {
log.error("Failed to upload content:\n", reason);
throw reason;
}
}) as Promise<IDownloadedFile>;
}
/**
@ -163,8 +108,7 @@ export class Util {
public static GetBotLink(config: DiscordBridgeConfig): string {
/* tslint:disable:no-bitwise */
const perms = Permissions.FLAGS.READ_MESSAGES! |
Permissions.FLAGS.SEND_MESSAGES! |
const perms = Permissions.FLAGS.SEND_MESSAGES! |
Permissions.FLAGS.CHANGE_NICKNAME! |
Permissions.FLAGS.CONNECT! |
Permissions.FLAGS.SPEAK! |
@ -184,42 +128,30 @@ export class Util {
if (name[0] === "@" && name.includes(":")) {
return name;
}
const client = intent.getClient();
const client = intent.underlyingClient;
await intent.ensureRegistered();
const matrixUsers = {};
let matches = 0;
await Promise.all(channelMxids.map((chan) => {
// we would use this.bridge.getBot().getJoinedMembers()
// but we also want to be able to search through banned members
// so we gotta roll our own thing
return client._http.authedRequestWithPrefix(
undefined,
"GET",
`/rooms/${encodeURIComponent(chan)}/members`,
undefined,
undefined,
"/_matrix/client/r0",
).then((res) => {
res.chunk.forEach((member) => {
if (member.membership !== "join" && member.membership !== "ban") {
return;
}
const mxid = member.state_key;
if (mxid.startsWith("@_discord_")) {
return;
}
let displayName = member.content.displayname;
if (!displayName && member.unsigned && member.unsigned.prev_content &&
member.unsigned.prev_content.displayname) {
displayName = member.unsigned.prev_content.displayname;
}
if (!displayName) {
displayName = mxid.substring(1, mxid.indexOf(":"));
}
if (name.toLowerCase() === displayName.toLowerCase() || name === mxid) {
matrixUsers[mxid] = displayName;
matches++;
}
});
await Promise.all(channelMxids.map( async (chan) => {
(await client.getRoomMembers(chan, undefined, ["leave"])).forEach((member) => {
if (member.membership === "invite") {
return;
}
const mxid = member.stateKey;
if (mxid.startsWith("@_discord_")) {
return;
}
let displayName = member.content.displayname;
if (!displayName && member.previousContent.displayname) {
displayName = member.previousContent.displayname;
}
if (!displayName) {
displayName = mxid.substring(1, mxid.indexOf(":"));
}
if (name.toLowerCase() === displayName.toLowerCase() || name === mxid) {
matrixUsers[mxid] = displayName;
matches++;
}
});
}));
if (matches === 0) {
@ -268,7 +200,11 @@ export class Util {
}
return reply;
}
if (Object.keys(actions).length === 0) {
return "No commands found";
}
reply += "Available Commands:\n";
let commandsHavePermission = 0;
for (const actionKey of Object.keys(actions)) {
const action = actions[actionKey];
if (action.permission !== undefined && permissionCheck) {
@ -277,12 +213,16 @@ export class Util {
continue;
}
}
commandsHavePermission++;
reply += ` - \`${prefix} ${actionKey}`;
for (const param of action.params) {
reply += ` <${param}>`;
}
reply += `\`: ${action.description}\n`;
}
if (!commandsHavePermission) {
return "No commands found";
}
reply += "\nParameters:\n";
for (const parameterKey of Object.keys(parameters)) {
const parameter = parameters[parameterKey];
@ -313,7 +253,7 @@ export class Util {
return `**ERROR:** ${permCheck}`;
}
if (!permCheck) {
return `**ERROR:** insufficiant permissions to use this command! ` +
return `**ERROR:** insufficient permissions to use this command! ` +
`Try \`${prefix} help\` to see all available commands`;
}
}
@ -356,7 +296,7 @@ export class Util {
return {command, args};
}
public static async AsyncForEach(arr, callback) {
public static async AsyncForEach<T>(arr: T[], callback: (item: T, i: number, a: T[]) => Promise<void>) {
for (let i = 0; i < arr.length; i++) {
await callback(arr[i], i, arr);
}
@ -392,7 +332,7 @@ export class Util {
cat: string,
subcat?: string,
) {
const res: IMatrixEvent = await mxClient.getStateEvent(roomId, "m.room.power_levels");
const res: IMatrixEvent = await mxClient.getRoomStateEvent(roomId, "m.room.power_levels", "");
let requiredLevel = defaultLevel;
if (res && (res[cat] || !subcat)) {
if (subcat) {
@ -415,9 +355,79 @@ export class Util {
}
return haveLevel >= requiredLevel;
}
public static ParseMxid(unescapedMxid: string, escape: boolean = true) {
const RADIX = 16;
const parts = unescapedMxid.substr(1).split(":");
const domain = parts[1];
let localpart = parts[0];
if (escape) {
const badChars = new Set(localpart.replace(/([a-z0-9]|-|\.|=|_)+/g, ""));
badChars.forEach((c) => {
const hex = c.charCodeAt(0).toString(RADIX).toLowerCase();
localpart = localpart.replace(
new RegExp(`\\${c}`, "g"),
`=${hex}`,
);
});
}
return {
domain,
localpart,
mxid: `@${localpart}:${domain}`,
};
}
// Taken from https://github.com/matrix-org/matrix-appservice-bridge/blob/master/lib/models/users/matrix.js
public static EscapeStringForUserId(localpart: string) {
// NOTE: Currently Matrix accepts / in the userId, although going forward it will be removed.
const badChars = new Set(localpart.replace(/([a-z]|[0-9]|-|\.|=|_)+/g, ""));
let res = localpart;
badChars.forEach((c) => {
const hex = c.charCodeAt(0).toString(16).toLowerCase();
res = res.replace(
new RegExp(`\\x${hex}`, "g"),
`=${hex}`,
);
});
return res;
}
}
interface IUploadResult {
mxcUrl: string;
size: number;
// Type type
type Type = Function; // tslint:disable-line ban-types
/**
* Returns true if `obj` is subtype of at least one of the given types.
*/
export function isInstanceOfTypes(obj: object, types: Type[]): boolean {
return types.some((type) => obj instanceof type);
}
/**
* Append the old error message to the new one and keep its stack trace.
*
* @example
* throw wrapError(e, HighLevelError, "This error is more specific");
*
* @param oldError The original error to wrap.
* @param newErrorType Type of the error returned by this function.
* @returns A new error of type `newErrorType` containing the information of
* the original error (stacktrace and error message).
*/
export function wrapError<T extends Error>(
oldError: object|Error,
newErrorType: new (...args: any[]) => T, // tslint:disable-line no-any
...args: any[] // tslint:disable-line no-any trailing-comma
): T {
const newError = new newErrorType(...args);
let appendMsg;
if (oldError instanceof Error) {
appendMsg = oldError.message;
newError.stack = oldError.stack;
} else {
appendMsg = oldError.toString();
}
newError.message += ":\n" + appendMsg;
return newError;
}

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
import * as Chai from "chai";
// import * as Proxyquire from "proxyquire";
import { DiscordStore, CURRENT_SCHEMA } from "../../src/store";
import { RemoteStoreRoom, MatrixStoreRoom } from "../../src/db/roomstore";
@ -193,75 +192,3 @@ describe("RoomStore", () => {
});
});
});
describe("RoomStore.schema.v8", () => {
it("will successfully migrate rooms", async () => {
const SCHEMA_VERSION = 8;
store = new DiscordStore(":memory:");
const roomStore = {
select: () => {
return [
{
_id: "DGFUYs4hlXNDmmw0",
id: "123",
matrix: {extras: {}},
matrix_id: "!badroom:localhost",
},
{
_id: "Dd37MWDw57dAQz5p",
data: {},
id: "!xdnLTCNErGnwsGnmnm:localhost discord_282616294245662720_514843269599985674_bridged",
matrix: {
extras: {},
},
matrix_id: "!bridged1:localhost",
remote: {
discord_channel: "514843269599985674",
discord_guild: "282616294245662720",
discord_type: "text",
plumbed: false,
},
remote_id: "discord_282616294245662720_514843269599985674_bridged",
},
{
_id: "H3XEftQWj8BZYuCe",
data: {},
id: "!oGkfjmeNEkJdFasVRF:localhost discord_282616294245662720_520332167952334849",
matrix: {
extras: {},
},
matrix_id: "!bridged2:localhost",
remote: {
discord_channel: "514843269599985674",
discord_guild: "282616294245662720",
discord_type: "text",
plumbed: true,
update_icon: true,
update_name: false,
update_topic: true,
},
remote_id: "discord_282616294245662720_520332167952334849",
},
];
},
};
await store.init(SCHEMA_VERSION, roomStore);
expect(await store.roomStore.getEntriesByMatrixId("!badroom:localhost")).to.be.empty;
const bridge1 = (await store.roomStore.getEntriesByMatrixId("!bridged1:localhost"))[0];
expect(bridge1).to.exist;
expect(bridge1.remote).to.not.be.null;
expect(bridge1.remote!.data.discord_channel).to.be.equal("514843269599985674");
expect(bridge1.remote!.data.discord_guild).to.be.equal("282616294245662720");
expect(bridge1.remote!.data.discord_type).to.be.equal("text");
expect(!!bridge1.remote!.data.plumbed).to.be.false;
const bridge2 = (await store.roomStore.getEntriesByMatrixId("!bridged2:localhost"))[0];
expect(bridge2).to.exist;
expect(bridge2.remote).to.not.be.null;
expect(bridge2.remote!.data.discord_channel).to.be.equal("514843269599985674");
expect(bridge2.remote!.data.discord_guild).to.be.equal("282616294245662720");
expect(bridge2.remote!.data.discord_type).to.be.equal("text");
expect(!!bridge2.remote!.data.plumbed).to.be.true;
expect(!!bridge2.remote!.data.update_icon).to.be.true;
expect(!!bridge2.remote!.data.update_name).to.be.false;
expect(!!bridge2.remote!.data.update_topic).to.be.true;
});
});

View file

@ -1,7 +0,0 @@
--reporter list
--ui bdd
--require ts-node/register
--require source-map-support/register
--recursive
build/test/config.js
build/test

View file

@ -0,0 +1,279 @@
import { IMatrixEvent } from "../../src/matrixtypes";
import { expect } from "chai";
/* tslint:disable:no-unused-expression no-any */
interface IAppserviceMockOpts {
roommembers?: IMatrixEvent[];
stateEventFetcher?: (roomId, stateType, stateKey) => Promise<any>;
eventFetcher?: (roomId, eventId) => Promise<any>;
profileFetcher?: (userId) => Promise<any>;
botUserId?: string;
userIdPrefix?: string;
aliasPrefix?: string;
joinedrooms?: string[];
homeserverName?: string;
}
class AppserviceMockBase {
private calls: {[key: string]: [any[]]} = {};
public wasCalled(funcName: string, throwOnMissing: boolean = true, ...args: any[]): number {
const called = this.calls[funcName];
if (!called && throwOnMissing) {
throw Error(`${funcName} was not called`);
} else if (!called) {
return 0;
} else if (args.length === 0) {
return called.length;
}
const calls = called.filter((callArgs) => {
try {
expect(callArgs).to.deep.equal(args);
return true;
} catch {
return false;
}
}).length;
if (calls === 0 && throwOnMissing) {
let msg = `${funcName} was not called with the correct parameters`;
if (called.length) {
msg += `. Calls that were made:\n${JSON.stringify(called, undefined, 2)}`;
}
throw Error(msg);
}
return calls;
}
public wasNotCalled(funcName: string, throwOnFound: boolean = true, ...args: any[]): boolean {
if (this.wasCalled(funcName, false, args)) {
if (throwOnFound) {
throw Error(`${funcName} was called`);
}
return false;
}
return true;
}
protected funcCalled(funcName: string, ...args: any[]) {
this.calls[funcName] = this.calls[funcName] || [];
this.calls[funcName].push(args);
}
}
export class AppserviceMock extends AppserviceMockBase {
public botIntent: IntentMock;
public botClient: MatrixClientMock;
public intents: {[id: string]: IntentMock};
public get botUserId(): string {
return this.opts.botUserId || "@bot:localhost";
}
constructor(private opts: IAppserviceMockOpts = {}) {
super();
opts.roommembers = opts.roommembers || [];
this.cleanup();
}
public cleanup() {
this.intents = {};
this.botIntent = new IntentMock(this.opts, "BOT");
this.botClient = this.botIntent.underlyingClient;
}
public isNamespacedUser(userId: string) {
this.funcCalled("isNamespacedUser", userId);
if (this.opts.userIdPrefix) {
return userId.startsWith(this.opts.userIdPrefix);
}
throw Error("No prefix defined");
}
public isNamespacedAlias(alias: string) {
this.funcCalled("isNamespacedAlias", alias);
if (this.opts.aliasPrefix) {
return alias.startsWith(this.opts.aliasPrefix);
}
throw Error("No prefix defined");
}
public getIntent(userId: string) {
this.funcCalled("getIntent", userId);
if (!this.intents[userId]) {
this.intents[userId] = new IntentMock(this.opts, userId);
}
return this.intents[userId];
}
public getIntentForSuffix(suffix: string) {
this.funcCalled("getIntentForSuffix", suffix);
if (!this.intents[suffix]) {
this.intents[suffix] = new IntentMock(this.opts, suffix);
}
return this.intents[suffix];
}
public getAliasForSuffix(suffix: string) {
this.funcCalled("getAliasForSuffix", suffix);
if (this.opts.aliasPrefix) {
return `${this.opts.aliasPrefix}${suffix}:${this.opts.homeserverName}`;
}
throw Error("No prefix defined");
}
public getIntentForUserId(userId: string) {
this.funcCalled("getIntentForUserId", userId);
if (!this.intents[userId]) {
this.intents[userId] = new IntentMock(this.opts, userId);
}
return this.intents[userId];
}
public getSuffixForUserId(userId: string) {
this.funcCalled("getSuffixForUserId", userId);
const localpart = userId.split(":")[0];
if (this.opts.userIdPrefix) {
return localpart.replace(this.opts.userIdPrefix!, "");
}
throw Error("No prefix defined");
}
public async setRoomDirectoryVisibility(roomId: string, vis: string) {
this.funcCalled("setRoomDirectoryVisibility", roomId, vis);
}
}
class IntentMock extends AppserviceMockBase {
public readonly underlyingClient: MatrixClientMock;
constructor(private opts: IAppserviceMockOpts = {}, private id: string) {
super();
this.underlyingClient = new MatrixClientMock(opts);
}
public join() {
this.funcCalled("join");
}
public joinRoom(roomIdOrAlias: string) {
this.funcCalled("joinRoom", roomIdOrAlias);
}
public leave(roomIdOrAlias: string) {
this.funcCalled("leave", roomIdOrAlias);
}
public sendText(roomId: string, body: string) {
this.funcCalled("sendText", roomId, body);
}
public sendEvent(roomId: string, content: any) {
this.funcCalled("sendEvent", roomId, content);
}
public async ensureRegistered(): Promise<void> {
this.funcCalled("ensureRegistered");
}
}
class MatrixClientMock extends AppserviceMockBase {
constructor(private opts: IAppserviceMockOpts = {}) {
super();
}
public banUser(roomId: string, userId: string) {
this.funcCalled("banUser", roomId, userId);
}
public sendMessage(roomId: string, eventContent: IMatrixEvent) {
this.funcCalled("sendMessage", roomId, eventContent);
}
public sendEvent(roomId: string, body: string, msgtype: string) {
this.funcCalled("sendEvent", roomId, body, msgtype);
}
public getRoomMembers(roomId: string) {
this.funcCalled("getRoomMembers", roomId);
if (!this.opts.roommembers) {
throw Error("No roommembers defined");
}
return this.opts.roommembers;
}
public getJoinedRooms() {
this.funcCalled("getJoinedRooms");
if (!this.opts.joinedrooms) {
throw Error("No joinedrooms defined");
}
return this.opts.joinedrooms;
}
public leaveRoom(roomId: string) {
this.funcCalled("leaveRoom", roomId);
}
public kickUser(roomId: string, userId: string) {
this.funcCalled("kickUser", roomId, userId);
}
public sendStateEvent(roomId: string, type: string, stateKey: string, content: {}) {
this.funcCalled("sendStateEvent", roomId, type, stateKey, content);
}
public setAvatarUrl(avatarUrl: string) {
this.funcCalled("setAvatarUrl", avatarUrl);
}
public setDisplayName(displayName: string) {
this.funcCalled("setDisplayName", displayName);
}
public async getUserProfile(userId: string) {
this.funcCalled("getUserProfile", userId);
if (this.opts.profileFetcher) {
return await this.opts.profileFetcher(userId);
}
throw Error("No stateEventFetcher defined");
}
public async uploadContent(data: Buffer, contentType: string, filename: string = "noname") {
this.funcCalled("uploadContent", data, contentType, filename);
return "mxc://" + filename;
}
public mxcToHttp(mxcUrl: string) {
this.funcCalled("mxcToHttp", mxcUrl);
return mxcUrl.replace("mxc://", "https://");
}
public mxcToHttpThumbnail(mxcUrl: string) {
this.funcCalled("mxcToHttpThumbnail", mxcUrl);
return mxcUrl.replace("mxc://", "https://");
}
public async getRoomStateEvent(roomId: string, type: string, stateKey: string): Promise<any> {
this.funcCalled("getRoomStateEvent", roomId, type, stateKey);
if (this.opts.stateEventFetcher) {
return await this.opts.stateEventFetcher(roomId, type, stateKey);
}
throw Error("No stateEventFetcher defined");
}
public async getEvent(roomId: string, eventId: string): Promise<any> {
this.funcCalled("getEvent", roomId, eventId);
if (this.opts.eventFetcher) {
return await this.opts.eventFetcher(roomId, eventId);
}
throw Error("No getEvent defined");
}
public unbanUser(roomId: string, userId: string) {
this.funcCalled("unbanUser", roomId, userId);
}
public async setPresenceStatus(presence: string, status: string) {
this.funcCalled("setPresenceStatus", presence, status);
}
}

View file

@ -15,8 +15,9 @@ limitations under the License.
*/
import {MockMember} from "./member";
import {MockCollection} from "./collection";
import {Permissions, PermissionResolvable} from "discord.js";
import {MockCollection, MockCollectionManager} from "./collection";
import {Permissions, PermissionResolvable, TextChannel} from "better-discord.js";
import { MockGuild } from "./guild";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
@ -40,3 +41,16 @@ export class MockChannel {
return new Permissions(Permissions.FLAGS.MANAGE_WEBHOOKS as PermissionResolvable);
}
}
export class MockTextChannel extends TextChannel {
constructor(guild?: MockGuild, channelData: any = {}) {
// Mock the nessacery
super(guild || {
client: {
options: {
messageCacheMaxSize: -1,
},
},
} as any, channelData);
}
}

View file

@ -14,10 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Collection } from "discord.js";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
import { Collection } from "better-discord.js";
export class MockCollection<T1, T2> extends Collection<T1, T2> {
public array(): T2[] {
@ -28,3 +25,22 @@ export class MockCollection<T1, T2> extends Collection<T1, T2> {
return [...this.keys()];
}
}
export class MockCollectionManager<T1, T2> {
private innerCache = new MockCollection<T1, T2>();
public get cache() {
return this.innerCache;
}
public updateCache(c: MockCollection<T1, T2>) {
this.innerCache = c;
}
public resolve(id: T1) {
return this.innerCache.get(id);
}
public async fetch(id: T1) {
return this.innerCache.get(id);
}
}

View file

@ -14,16 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MockCollection} from "./collection";
import {MockCollectionManager} from "./collection";
import {MockGuild} from "./guild";
import {MockUser} from "./user";
import { EventEmitter } from "events";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
export class MockDiscordClient {
public guilds = new MockCollection<string, MockGuild>();
public guilds = new MockCollectionManager<string, MockGuild>();
public user: MockUser;
private testLoggedIn: boolean = false;
private testCallbacks: Map<string, (...data: any[]) => void> = new Map();
@ -46,9 +45,9 @@ export class MockDiscordClient {
type: "text",
},
];
this.guilds.set("123", new MockGuild("MyGuild", channels));
this.guilds.set("456", new MockGuild("My Spaces Gui", channels));
this.guilds.set("789", new MockGuild("My Dash-Guild", channels));
this.guilds.cache.set("123", new MockGuild("MyGuild", channels));
this.guilds.cache.set("456", new MockGuild("My Spaces Gui", channels));
this.guilds.cache.set("789", new MockGuild("My Dash-Guild", channels));
this.user = new MockUser("12345");
}
@ -56,6 +55,14 @@ export class MockDiscordClient {
this.testCallbacks.set(event, callback);
}
public once(event: string, callback: (...data: any[]) => void) {
this.testCallbacks.set(event, () => {
this.testCallbacks.delete(event);
callback();
});
}
public async emit(event: string, ...data: any[]) {
return await this.testCallbacks.get(event)!.apply(this, data);
}
@ -68,6 +75,9 @@ export class MockDiscordClient {
if (this.testCallbacks.has("ready")) {
this.testCallbacks.get("ready")!();
}
if (this.testCallbacks.has("shardReady")) {
this.testCallbacks.get("shardReady")!();
}
return;
}

View file

@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MockCollection} from "./collection";
import {MockCollectionManager} from "./collection";
import {MockMember} from "./member";
import {MockEmoji} from "./emoji";
import {Channel} from "discord.js";
import {Channel} from "better-discord.js";
import {MockRole} from "./role";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
export class MockGuild {
public channels = new MockCollection<string, Channel>();
public members = new MockCollection<string, MockMember>();
public emojis = new MockCollection<string, MockEmoji>();
public roles = new MockCollection<string, MockRole>();
public channels = new MockCollectionManager<string, Channel>();
public members = new MockCollectionManager<string, MockMember>();
public emojis = new MockCollectionManager<string, MockEmoji>();
public roles = new MockCollectionManager<string, MockRole>();
public id: string;
public name: string;
public icon: string;
@ -35,18 +35,26 @@ export class MockGuild {
this.id = id;
this.name = name || id;
channels.forEach((item) => {
this.channels.set(item.id, item);
this.channels.cache.set(item.id, item);
});
}
public get client() {
return {
options: {
messageCacheMaxSize: -1,
},
};
}
public async fetchMember(id: string): Promise<MockMember|Error> {
if (this.members.has(id)) {
return this.members.get(id)!;
if (this.members.cache.has(id)) {
return this.members.cache.get(id)!;
}
throw new Error("Member not in this guild");
}
public _mockAddMember(member: MockMember) {
this.members.set(member.id, member);
this.members.cache.set(member.id, member);
}
}

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MockCollection} from "./collection";
import {MockCollectionManager} from "./collection";
import {MockUser} from "./user";
import {MockRole} from "./role";
import * as Discord from "discord.js";
import * as Discord from "better-discord.js";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
@ -27,10 +27,14 @@ export class MockMember {
public presence: Discord.Presence;
public user: MockUser;
public nickname: string;
public roles = new MockCollection<string, MockRole>();
public roles = new MockCollectionManager<string, MockRole>();
constructor(id: string, username: string, public guild: any = null, public displayName: string = username) {
this.id = id;
this.presence = new Discord.Presence({}, {} as any);
this.presence = new Discord.Presence({} as any, {
user: {
id: this.id,
},
});
this.user = new MockUser(this.id, username);
this.nickname = displayName;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Discord from "discord.js";
import * as Discord from "better-discord.js";
import { MockUser } from "./user";
import { MockCollection } from "./collection";

16
test/mocks/presence.ts Normal file
View file

@ -0,0 +1,16 @@
import { MockUser } from "./user";
/* tslint:disable:no-unused-expression max-file-line-count no-any */
export class MockPresence {
constructor(public internalUser: MockUser, guild: string, public status?: string, public activities: any = []) {
}
public get user() {
return this.internalUser;
}
public get userID() {
return this.internalUser.id;
}
}

View file

@ -1,18 +0,0 @@
/*
Copyright 2018 matrix-appservice-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Presence } from "discord.js";
import { Presence } from "better-discord.js";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
@ -25,11 +25,15 @@ export class MockUser {
public id: string,
public username: string = "",
public discriminator: string = "",
public avatarURL: string | null = "",
public avatarUrl: string | null = "",
public avatar: string | null = "",
public bot: boolean = false,
) { }
public avatarURL() {
return this.avatarUrl;
}
public MockSetPresence(presence: Presence) {
this.presence = presence;
}

View file

@ -0,0 +1,45 @@
/*
Copyright 2019 matrix-appservice-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { expect } from "chai";
import { Lock } from "../../src/structures/lock";
import { Util } from "../../src/util";
const LOCKTIMEOUT = 300;
describe("Lock", () => {
it("should lock and unlock", async () => {
const lock = new Lock<string>(LOCKTIMEOUT);
const t = Date.now();
lock.set("bunny");
await lock.wait("bunny");
const diff = Date.now() - t;
expect(diff).to.be.greaterThan(LOCKTIMEOUT - 1);
});
it("should lock and unlock early, if unlocked", async () => {
const SHORTDELAY = 100;
const DELAY_ACCURACY = 5;
const lock = new Lock<string>(LOCKTIMEOUT);
setTimeout(() => lock.release("fox"), SHORTDELAY);
const t = Date.now();
lock.set("fox");
await lock.wait("fox");
const diff = Date.now() - t;
// accuracy can be off by a few ms soemtimes
expect(diff).to.be.greaterThan(SHORTDELAY - DELAY_ACCURACY);
expect(diff).to.be.lessThan(SHORTDELAY + DELAY_ACCURACY);
});
});

View file

@ -117,7 +117,7 @@ describe("TimedCache", () => {
}
i++;
}
await Util.DelayedPromise(LIVE_FOR);
await Util.DelayedPromise(LIVE_FOR * 5);
const vals = [...timedCache.entries()];
expect(vals).to.be.empty;
});

View file

@ -14,112 +14,56 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import * as Discord from "discord.js";
import { expect } from "chai";
import * as Proxyquire from "proxyquire";
import { ISingleChannelState, IChannelState, ChannelSyncroniser } from "../src/channelsyncroniser";
import { DiscordBot } from "../src/bot";
import { ChannelSyncroniser } from "../src/channelsyncroniser";
import { MockGuild } from "./mocks/guild";
import { MockMember } from "./mocks/member";
import { MatrixEventProcessor, MatrixEventProcessorOpts } from "../src/matrixeventprocessor";
import { DiscordBridgeConfig } from "../src/config";
import { Util } from "../src/util";
import { MockChannel } from "./mocks/channel";
import { Bridge, MatrixRoom, RemoteRoom } from "matrix-appservice-bridge";
import { MatrixStoreRoom, RemoteStoreRoom, IRoomStoreEntry } from "../src/db/roomstore";
import { Appservice } from "matrix-bot-sdk";
import { AppserviceMock } from "./mocks/appservicemock";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
let UTIL_UPLOADED_AVATAR: any = null;
let REMOTECHANNEL_SET: any = false;
let REMOTECHANNEL_REMOVED: any = false;
let ROOM_NAME_SET: any = null;
let ROOM_TOPIC_SET: any = null;
let ROOM_AVATAR_SET: any = null;
let STATE_EVENT_SENT: any = false;
let ALIAS_DELETED: any = false;
let ROOM_DIRECTORY_VISIBILITY: any = null;
const ChannelSync = (Proxyquire("../src/channelsyncroniser", {
"./util": {
Util: {
ApplyPatternString: Util.ApplyPatternString,
UploadContentFromUrl: async () => {
UTIL_UPLOADED_AVATAR = true;
return {mxcUrl: "avatarset"};
},
DownloadFile: () => ({buffer: "afile"}),
},
},
})).ChannelSyncroniser;
class Entry {
class Entry implements IRoomStoreEntry {
public id: any;
public matrix: MatrixRoom;
public remote: RemoteRoom;
public matrix: MatrixStoreRoom|null;
public remote: RemoteStoreRoom|null;
public data: any;
constructor(doc: any = {}) {
this.matrix = doc.matrix_id ? new MatrixRoom(doc.matrix_id, doc.matrix) : undefined;
this.remote = doc.remote_id ? new RemoteRoom(doc.remote_id, doc.remote) : undefined;
this.matrix = doc.matrix_id ? new MatrixStoreRoom(doc.matrix_id) : null;
this.remote = doc.remote_id ? new RemoteStoreRoom(doc.remote_id, doc.remote) : null;
this.data = doc.data;
}
}
function CreateChannelSync(remoteChannels: any[] = []): ChannelSyncroniser {
UTIL_UPLOADED_AVATAR = false;
const bridge: any = {
getIntent: (id) => {
ROOM_NAME_SET = null;
ROOM_TOPIC_SET = null;
ROOM_AVATAR_SET = null;
STATE_EVENT_SENT = false;
ALIAS_DELETED = false;
ROOM_DIRECTORY_VISIBILITY = null;
return {
getClient: () => {
return {
deleteAlias: async (alias) => {
ALIAS_DELETED = true;
},
getStateEvent: async (mxid, event) => {
if (event === "m.room.canonical_alias") {
if (mxid === "!valid:localhost") {
return {
alias: "#alias:localhost",
};
} else {
return null;
}
}
return event;
},
sendStateEvent: async (mxid, event, data) => {
STATE_EVENT_SENT = true;
},
setRoomDirectoryVisibility: async (mxid, visibility) => {
ROOM_DIRECTORY_VISIBILITY = visibility;
},
setRoomName: async (mxid, name) => {
ROOM_NAME_SET = name;
},
setRoomTopic: async (mxid, topic) => {
ROOM_TOPIC_SET = topic;
},
};
},
setRoomAvatar: async (mxid, mxc) => {
ROOM_AVATAR_SET = mxc;
},
setRoomName: async (mxid, name) => {
ROOM_NAME_SET = name;
},
setRoomTopic: async (mxid, topic) => {
ROOM_TOPIC_SET = topic;
},
};
function CreateChannelSync(remoteChannels: any[] = []) {
const bridge = new AppserviceMock({
aliasPrefix: "#_discord_",
homeserverName: "localhost",
stateEventFetcher: async (roomId: string, type: string, key: string) => {
if (roomId === "!valid:localhost" && type === "m.room.canonical_alias" && key === "") {
return { alias: "#alias:localhost"};
}
throw Error("Event not found");
},
};
});
REMOTECHANNEL_REMOVED = false;
REMOTECHANNEL_SET = false;
const roomStore = {
@ -156,10 +100,10 @@ function CreateChannelSync(remoteChannels: any[] = []): ChannelSyncroniser {
return true;
});
},
removeEntriesByMatrixRoomId: (room) => {
removeEntriesByMatrixRoomId: () => {
REMOTECHANNEL_REMOVED = true;
},
upsertEntry: (room) => {
upsertEntry: () => {
REMOTECHANNEL_SET = true;
},
};
@ -169,8 +113,9 @@ function CreateChannelSync(remoteChannels: any[] = []): ChannelSyncroniser {
const config = new DiscordBridgeConfig();
config.bridge.domain = "localhost";
config.channel.namePattern = "[Discord] :guild :name";
const cs = new ChannelSync(bridge as Bridge, config, discordbot, roomStore) as ChannelSyncroniser;
return cs;
const fakedBridge = bridge as any;
const channelSync = new ChannelSync(fakedBridge as Appservice, config, discordbot, roomStore) as ChannelSyncroniser;
return {channelSync, bridge};
}
describe("ChannelSyncroniser", () => {
@ -190,7 +135,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
await channelSync.OnDelete(chan as any);
expect(REMOTECHANNEL_REMOVED).is.false;
@ -210,7 +155,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
await channelSync.OnDelete(chan as any);
expect(REMOTECHANNEL_REMOVED).is.true;
@ -231,7 +176,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const chans = await channelSync.GetRoomIdsFromChannel(chan as any);
expect(chans.length).equals(1);
@ -267,7 +212,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const chans = await channelSync.GetRoomIdsFromChannel(chan as any);
/* tslint:disable:no-magic-numbers */
expect(chans.length).equals(2);
@ -278,7 +223,7 @@ describe("ChannelSyncroniser", () => {
it("should reject on no rooms", async () => {
const chan = new MockChannel();
chan.id = "blah";
const channelSync = CreateChannelSync();
const {channelSync} = CreateChannelSync();
try {
await channelSync.GetRoomIdsFromChannel(chan as any);
throw new Error("didn't fail");
@ -297,19 +242,38 @@ describe("ChannelSyncroniser", () => {
it("Should get one canonical alias for a room", async () => {
const chan = new MockChannel();
chan.id = "678";
const channelSync = CreateChannelSync();
const {channelSync} = CreateChannelSync();
channelSync.GetRoomIdsFromChannel = getIds;
const alias = await channelSync.GetAliasFromChannel(chan as any);
expect(alias).to.equal("#alias:localhost");
});
it("Should prefer non-discord canonical aliases", async () => {
const {channelSync} = CreateChannelSync();
channelSync.GetRoomIdsFromChannel = async (_) => {
return ["!discord:localhost", "!valid:localhost"];
};
const alias = await channelSync.GetAliasFromChannel({} as any);
expect(alias).to.equal("#alias:localhost");
});
it("Should use discord canonical alias if none other present", async () => {
const {channelSync} = CreateChannelSync();
channelSync.GetRoomIdsFromChannel = async (_) => {
return ["!discord:localhost"];
};
const alias = await channelSync.GetAliasFromChannel({
guild: { id: "123" },
id: "123",
} as any);
expect(alias).to.equal("#_discord_123_123:localhost");
});
it("Should return null if no alias found and no guild present", async () => {
const chan = new MockChannel();
chan.id = "123";
const channelSync = CreateChannelSync();
const {channelSync} = CreateChannelSync();
channelSync.GetRoomIdsFromChannel = getIds;
const alias = await channelSync.GetAliasFromChannel(chan as any);
expect(alias).to.equal(null);
});
it("Should return a #_discord_ alias if a guild is present", async () => {
@ -317,10 +281,9 @@ describe("ChannelSyncroniser", () => {
const guild = new MockGuild("123");
chan.id = "123";
chan.guild = guild;
const channelSync = CreateChannelSync();
const {channelSync} = CreateChannelSync();
channelSync.GetRoomIdsFromChannel = getIds;
const alias = await channelSync.GetAliasFromChannel(chan as any);
expect(alias).to.equal("#_discord_123_123:localhost");
});
});
@ -330,7 +293,7 @@ describe("ChannelSyncroniser", () => {
chan.type = "text";
chan.id = "blah";
const channelSync = CreateChannelSync();
const {channelSync} = CreateChannelSync();
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.id).equals(chan.id);
expect(state.mxChannels.length).equals(0);
@ -359,7 +322,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.mxChannels.length).equals(1);
expect(state.mxChannels[0].name).equals("[Discord] newGuild #newName");
@ -387,7 +350,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.mxChannels.length).equals(1);
expect(state.mxChannels[0].name).is.null;
@ -417,7 +380,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.mxChannels.length).equals(1);
expect(state.mxChannels[0].name).is.null;
@ -444,7 +407,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.mxChannels.length).equals(1);
expect(state.mxChannels[0].iconUrl).equals("https://cdn.discordapp.com/icons/654321/new_icon.png");
@ -471,7 +434,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const { channelSync } = CreateChannelSync(testStore);
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.mxChannels.length).equals(1);
expect(state.mxChannels[0].iconUrl).equals("https://cdn.discordapp.com/icons/654321/a_new_icon.gif");
@ -498,7 +461,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.mxChannels.length).equals(1);
expect(state.mxChannels[0].iconUrl).is.null;
@ -525,7 +488,7 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const {channelSync} = CreateChannelSync(testStore);
const state = await channelSync.GetChannelUpdateState(chan as any);
expect(state.mxChannels.length).equals(1);
expect(state.mxChannels[0].removeIcon).is.true;
@ -559,13 +522,19 @@ describe("ChannelSyncroniser", () => {
}),
];
const channelSync = CreateChannelSync(testStore);
const state = await channelSync.OnUpdate(chan as any);
expect(ROOM_NAME_SET).equals("[Discord] newGuild #newName");
expect(ROOM_TOPIC_SET).equals("newTopic");
expect(ROOM_AVATAR_SET).equals("avatarset");
const {channelSync, bridge} = CreateChannelSync(testStore);
await channelSync.OnUpdate(chan as any);
bridge.botIntent.underlyingClient.wasCalled("sendStateEvent", true, "!1:localhost", "m.room.name", "", {
name: "[Discord] newGuild #newName",
});
bridge.botIntent.underlyingClient.wasCalled("sendStateEvent", true, "!1:localhost", "m.room.topic", "", {
topic: "newTopic",
});
bridge.botIntent.underlyingClient.wasCalled("sendStateEvent", true, "!1:localhost", "m.room.avatar", "",
{
url: "mxc://new_icon",
});
expect(REMOTECHANNEL_SET).is.true;
expect(UTIL_UPLOADED_AVATAR).is.true;
});
});
});

View file

@ -14,18 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import * as Proxyquire from "proxyquire";
import {DiscordBridgeConfigAuth} from "../src/config";
import {MockDiscordClient} from "./mocks/discordclient";
import { DiscordBridgeConfigAuth } from "../src/config";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
const DiscordClientFactory = Proxyquire("../src/clientfactory", {
"discord.js": { Client: require("./mocks/discordclient").MockDiscordClient },
"better-discord.js": { Client: require("./mocks/discordclient").MockDiscordClient },
}).DiscordClientFactory;
const STORE = {

View file

@ -14,14 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import { DiscordBridgeConfig } from "../src/config";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
describe("DiscordBridgeConfig.applyConfig", () => {
it("should merge configs correctly", () => {
const config = new DiscordBridgeConfig();
@ -29,6 +27,7 @@ describe("DiscordBridgeConfig.applyConfig", () => {
bridge: {
disableDeletionForwarding: true,
disableDiscordMentions: false,
disableInviteNotifications: true,
disableJoinLeaveNotifications: true,
disableTypingNotifications: true,
enableSelfServiceBridging: false,
@ -44,6 +43,7 @@ describe("DiscordBridgeConfig.applyConfig", () => {
expect(config.bridge.disableDeletionForwarding).to.be.true;
expect(config.bridge.enableSelfServiceBridging).to.be.false;
expect(config.bridge.disableJoinLeaveNotifications).to.be.true;
expect(config.bridge.disableInviteNotifications).to.be.true;
expect(config.logging.console).to.equal("warn");
});
it("should merge environment overrides correctly", () => {
@ -60,10 +60,12 @@ describe("DiscordBridgeConfig.applyConfig", () => {
});
config.applyEnvironmentOverrides({
APPSERVICE_DISCORD_BRIDGE_DISABLE_DELETION_FORWARDING: false,
APPSERVICE_DISCORD_BRIDGE_DISABLE_INVITE_NOTIFICATIONS: true,
APPSERVICE_DISCORD_BRIDGE_DISABLE_JOIN_LEAVE_NOTIFICATIONS: true,
APPSERVICE_DISCORD_LOGGING_CONSOLE: "debug",
});
expect(config.bridge.disableJoinLeaveNotifications).to.be.true;
expect(config.bridge.disableInviteNotifications).to.be.true;
expect(config.bridge.disableDeletionForwarding).to.be.false;
expect(config.bridge.disableDiscordMentions).to.be.false;
expect(config.bridge.homeserverUrl).to.equal("blah");

View file

@ -1,41 +0,0 @@
/*
Copyright 2018 matrix-appservice-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as yaml from "js-yaml";
import * as Chai from "chai";
import { ConfigValidator } from "matrix-appservice-bridge";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
describe("ConfigSchema", () => {
const validator = new ConfigValidator("./config/config.schema.yaml");
it("should successfully validate a minimal config", () => {
const yamlConfig = yaml.safeLoad(`
bridge:
domain: localhost
homeserverUrl: "http://localhost:8008"
auth:
clientID: foo
botToken: foobar`);
validator.validate(yamlConfig);
});
it("should successfully validate the sample config", () => {
validator.validate("./config/config.sample.yaml");
});
});

View file

@ -14,53 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import * as Proxyquire from "proxyquire";
import * as Discord from "discord.js";
import { Log } from "../src/log";
import { MockGuild } from "./mocks/guild";
import { MockMember } from "./mocks/member";
import { DiscordBot } from "../src/bot";
import { MockDiscordClient } from "./mocks/discordclient";
import { MockMessage } from "./mocks/message";
import { Util } from "../src/util";
import { MockChannel } from "./mocks/channel";
import { AppserviceMock } from "./mocks/appservicemock";
import { MockUser } from "./mocks/user";
import { MockTextChannel } from "./mocks/channel";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
const assert = Chai.assert;
// const should = Chai.should as any;
const mockBridge = {
getIntentFromLocalpart: (localpart: string) => {
return {
sendTyping: (room: string, isTyping: boolean) => {
return;
},
};
},
getRoomStore: () => {
return {
getEntriesByRemoteRoomData: async (data) => {
if (data.discord_channel === "321") {
return [{
matrix: {
getId: () => "foobar:example.com",
},
}];
}
return [];
},
};
},
getUserStore: () => {
return {};
},
};
const mockBridge = new AppserviceMock({});
const modDiscordBot = Proxyquire("../src/bot", {
"./clientfactory": require("./mocks/discordclientfactory"),
@ -68,6 +37,9 @@ const modDiscordBot = Proxyquire("../src/bot", {
Util: {
AsyncForEach: Util.AsyncForEach,
DelayedPromise: Util.DelayedPromise,
DownloadFile: async () => {
return {buffer: Buffer.alloc(1000)};
},
UploadContentFromUrl: async () => {
return {mxcUrl: "uploaded"};
},
@ -91,7 +63,6 @@ describe("DiscordBot", () => {
describe("run()", () => {
it("should resolve when ready.", async () => {
discordBot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
@ -103,7 +74,6 @@ describe("DiscordBot", () => {
describe("LookupRoom()", () => {
beforeEach( async () => {
discordBot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
@ -133,42 +103,27 @@ describe("DiscordBot", () => {
});
});
describe("OnMessage()", () => {
let SENT_MESSAGE = false;
const channel = new MockTextChannel();
const msg = new MockMessage(channel);
const author = new MockUser("11111");
let HANDLE_COMMAND = false;
let ATTACHMENT = {} as any;
let MSGTYPE = "";
function getDiscordBot() {
SENT_MESSAGE = false;
HANDLE_COMMAND = false;
ATTACHMENT = {};
MSGTYPE = "";
mockBridge.cleanup();
const discord = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
);
discord.bot = { user: { id: "654" } };
discord.GetIntentFromDiscordMember = (_) => {return {
sendMessage: async (room, msg) => {
SENT_MESSAGE = true;
if (msg.info) {
ATTACHMENT = msg.info;
}
MSGTYPE = msg.msgtype;
return {
event_id: "$fox:localhost",
};
},
}; };
discord.userSync = {
OnUpdateUser: async (user) => { },
OnUpdateUser: async () => { },
};
discord.channelSync = {
GetRoomIdsFromChannel: async (chan) => ["!asdf:localhost"],
GetRoomIdsFromChannel: async () => ["!asdf:localhost"],
};
discord.discordCommandHandler = {
Process: async (msg) => { HANDLE_COMMAND = true; },
Process: async () => { HANDLE_COMMAND = true; },
};
discord.store = {
Insert: async (_) => { },
@ -178,104 +133,157 @@ describe("DiscordBot", () => {
it("ignores own messages", async () => {
discordBot = getDiscordBot();
const guild: any = new MockGuild("123", []);
const author = new MockMember("654", "TestUsername");
const ownAuthor = new MockUser("654", "TestUsername");
guild._mockAddMember(author);
const channel = new Discord.TextChannel(guild, {} as any);
const msg = new MockMessage(channel) as any;
msg.author = author;
msg.author = ownAuthor;
msg.content = "Hi!";
await discordBot.OnMessage(msg);
Chai.assert.equal(SENT_MESSAGE, false);
expect(mockBridge.getIntent(author.id).wasCalled("sendEvent", false)).to.equal(0);
});
it("Passes on !matrix commands", async () => {
discordBot = getDiscordBot();
const channel = new Discord.TextChannel({} as any, {} as any);
const msg = new MockMessage(channel) as any;
msg.author = author;
msg.content = "!matrix test";
await discordBot.OnMessage(msg);
Chai.assert.equal(HANDLE_COMMAND, true);
expect(HANDLE_COMMAND).to.be.true;
});
it("skips empty messages", async () => {
discordBot = getDiscordBot();
const channel = new Discord.TextChannel({} as any, {} as any);
const msg = new MockMessage(channel) as any;
msg.content = "";
await discordBot.OnMessage(msg);
Chai.assert.equal(SENT_MESSAGE, false);
msg.author = author;
await discordBot.OnMessage(msg as any);
expect(mockBridge.getIntent(author.id).wasCalled("sendEvent", false)).to.equal(0);
});
it("sends normal messages", async () => {
discordBot = getDiscordBot();
const channel = new Discord.TextChannel({} as any, {} as any);
const msg = new MockMessage(channel) as any;
msg.author = author;
msg.content = "Foxies are amazing!";
await discordBot.OnMessage(msg);
Chai.assert.equal(SENT_MESSAGE, true);
await discordBot.OnMessage(msg as any);
mockBridge.getIntent(author.id).wasCalled("sendEvent");
});
it("sends edit messages", async () => {
discordBot = getDiscordBot();
msg.author = author;
msg.content = "Foxies are super amazing!";
await discordBot.OnMessage(msg, "editevent");
mockBridge.getIntent(author.id).wasCalled("sendEvent", true, "!asdf:localhost", {
"body": "* Foxies are super amazing!",
"format": "org.matrix.custom.html",
"formatted_body": "* Foxies are super amazing!",
"m.new_content": {
body: "Foxies are super amazing!",
format: "org.matrix.custom.html",
formatted_body: "Foxies are super amazing!",
msgtype: "m.text",
},
"m.relates_to": { event_id: "editevent", rel_type: "m.replace" },
"msgtype": "m.text",
});
});
it("uploads images", async () => {
discordBot = getDiscordBot();
const channel = new Discord.TextChannel({} as any, {} as any);
const msg = new MockMessage(channel) as any;
msg.author = author;
msg.attachments.set("1234", {
filename: "someimage.png",
filesize: 42,
name: "someimage.png",
size: 42,
height: 0,
url: "asdf",
width: 0,
});
await discordBot.OnMessage(msg);
Chai.assert.equal(MSGTYPE, "m.image");
Chai.assert.equal(ATTACHMENT.mimetype, "image/png");
const intent = mockBridge.getIntent(author.id);
intent.underlyingClient.wasCalled("uploadContent");
intent.wasCalled("sendEvent", true, "!asdf:localhost", {
body: "someimage.png",
external_url: "asdf",
info: {
h: 0,
mimetype: "image/png",
size: 42,
w: 0,
},
msgtype: "m.image",
url: "mxc://someimage.png",
});
});
it("uploads videos", async () => {
discordBot = getDiscordBot();
const channel = new Discord.TextChannel({} as any, {} as any);
const msg = new MockMessage(channel) as any;
msg.author = author;
msg.attachments.set("1234", {
filename: "foxes.mov",
filesize: 42,
name: "foxes.mov",
size: 42,
height: 0,
url: "asdf",
width: 0,
});
await discordBot.OnMessage(msg);
Chai.assert.equal(MSGTYPE, "m.video");
Chai.assert.equal(ATTACHMENT.mimetype, "video/quicktime");
const intent = mockBridge.getIntent(author.id);
intent.underlyingClient.wasCalled("uploadContent");
intent.wasCalled("sendEvent", true, "!asdf:localhost", {
body: "foxes.mov",
external_url: "asdf",
info: {
h: 0,
mimetype: "video/quicktime",
size: 42,
w: 0,
},
msgtype: "m.video",
url: "mxc://foxes.mov",
});
});
it("uploads audio", async () => {
discordBot = getDiscordBot();
const channel = new Discord.TextChannel({} as any, {} as any);
const msg = new MockMessage(channel) as any;
msg.author = author;
msg.attachments.set("1234", {
filename: "meow.mp3",
filesize: 42,
name: "meow.mp3",
size: 42,
height: 0,
url: "asdf",
width: 0,
});
await discordBot.OnMessage(msg);
Chai.assert.equal(MSGTYPE, "m.audio");
Chai.assert.equal(ATTACHMENT.mimetype, "audio/mpeg");
const intent = mockBridge.getIntent(author.id);
intent.underlyingClient.wasCalled("uploadContent");
intent.wasCalled("sendEvent", true, "!asdf:localhost", {
body: "meow.mp3",
external_url: "asdf",
info: {
mimetype: "audio/mpeg",
size: 42,
},
msgtype: "m.audio",
url: "mxc://meow.mp3",
});
});
it("uploads other files", async () => {
discordBot = getDiscordBot();
const channel = new Discord.TextChannel({} as any, {} as any);
const msg = new MockMessage(channel) as any;
msg.author = author;
msg.attachments.set("1234", {
filename: "meow.zip",
filesize: 42,
name: "meow.zip",
size: 42,
height: 0,
url: "asdf",
width: 0,
});
await discordBot.OnMessage(msg);
Chai.assert.equal(MSGTYPE, "m.file");
Chai.assert.equal(ATTACHMENT.mimetype, "application/zip");
const intent = mockBridge.getIntent(author.id);
intent.underlyingClient.wasCalled("uploadContent");
intent.wasCalled("sendEvent", true, "!asdf:localhost", {
body: "meow.zip",
external_url: "asdf",
info: {
mimetype: "application/zip",
size: 42,
},
msgtype: "m.file",
url: "mxc://meow.zip",
});
});
});
describe("OnMessageUpdate()", () => {
it("should return on an unchanged message", async () => {
discordBot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
@ -283,7 +291,7 @@ describe("DiscordBot", () => {
const guild: any = new MockGuild("123", []);
guild._mockAddMember(new MockMember("12345", "TestUsername"));
const channel = new Discord.TextChannel(guild, {} as any);
const channel = new MockTextChannel(guild);
const oldMsg = new MockMessage(channel) as any;
const newMsg = new MockMessage(channel) as any;
oldMsg.embeds = [];
@ -298,11 +306,10 @@ describe("DiscordBot", () => {
discordBot.SendMatrixMessage = (...args) => checkMsgSent = true;
await discordBot.OnMessageUpdate(oldMsg, newMsg);
Chai.assert.equal(checkMsgSent, false);
expect(checkMsgSent).to.be.false;
});
it("should send a matrix message on an edited discord message", async () => {
it("should send a matrix edit on an edited discord message", async () => {
discordBot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
@ -311,7 +318,7 @@ describe("DiscordBot", () => {
const guild: any = new MockGuild("123", []);
guild._mockAddMember(new MockMember("12345", "TestUsername"));
const channel = new Discord.TextChannel(guild, {} as any);
const channel = new MockTextChannel(guild);
const oldMsg = new MockMessage(channel) as any;
const newMsg = new MockMessage(channel) as any;
oldMsg.embeds = [];
@ -321,16 +328,27 @@ describe("DiscordBot", () => {
oldMsg.content = "a";
newMsg.content = "b";
// Mock the SendMatrixMessage method to check if it is called
let checkMsgSent = false;
discordBot.SendMatrixMessage = (...args) => checkMsgSent = true;
let storeMockResults = 1;
discordBot.store = {
Get: (a, b) => {
return {
MatrixId: "editedid",
Next: () => storeMockResults--,
Result: true,
};
},
};
let checkEditEventSent = "";
discordBot.OnMessage = (str, event) => {
checkEditEventSent = event;
};
await discordBot.OnMessageUpdate(oldMsg, newMsg);
Chai.assert.equal(checkMsgSent, true);
expect(checkEditEventSent).to.equal("editedid");
});
it("should delete and re-send if it is the newest message", async () => {
it("should send a new message if no store event found", async () => {
discordBot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
@ -344,7 +362,7 @@ describe("DiscordBot", () => {
const guild: any = new MockGuild("123", []);
guild._mockAddMember(new MockMember("12345", "TestUsername"));
const channel = new Discord.TextChannel(guild, {} as any);
const channel = new MockTextChannel(guild, {} as any);
const oldMsg = new MockMessage(channel) as any;
const newMsg = new MockMessage(channel) as any;
oldMsg.embeds = [];
@ -354,27 +372,36 @@ describe("DiscordBot", () => {
oldMsg.content = "a";
newMsg.content = "b";
let deletedMessage = false;
discordBot.DeleteDiscordMessage = async (_) => { deletedMessage = true; };
let sentMessage = false;
discordBot.OnMessage = async (_) => { sentMessage = true; };
let storeMockResults = 0;
discordBot.store = {
Get: (a, b) => {
return {
MatrixId: "editedid",
Next: () => storeMockResults--,
Result: true,
};
},
};
let checkEditEventSent = "wrong";
discordBot.OnMessage = (str, event) => {
checkEditEventSent = event;
};
await discordBot.OnMessageUpdate(oldMsg, newMsg);
Chai.assert.equal(deletedMessage, true);
Chai.assert.equal(sentMessage, true);
expect(checkEditEventSent).to.be.undefined;
});
});
describe("event:message", () => {
it("should delay messages so they arrive in order", async () => {
discordBot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
);
let expected = 0;
discordBot.OnMessage = async (msg: any) => {
assert.equal(msg.n, expected);
expect(msg.n).to.eq(expected);
expected++;
};
const client: MockDiscordClient = (await discordBot.ClientFactory.getClient()) as MockDiscordClient;
@ -389,7 +416,6 @@ describe("DiscordBot", () => {
});
it("should handle messages that reject in the queue", async () => {
discordBot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
@ -397,7 +423,7 @@ describe("DiscordBot", () => {
let expected = 0;
const THROW_EVERY = 5;
discordBot.OnMessage = async (msg: any) => {
assert.equal(msg.n, expected);
expect(msg.n).to.eq(expected);
expected++;
if (expected % THROW_EVERY === 0) {
return Promise.reject("Deliberate throw in test");
@ -413,74 +439,7 @@ describe("DiscordBot", () => {
await client.emit("message", { n, channel: { guild: { id: CHANID }, id: CHANID} });
}
await discordBot.discordMessageQueue[CHANID];
assert.equal(expected, ITERATIONS);
expect(expected).to.eq(ITERATIONS);
});
});
describe("locks", () => {
it("should lock and unlock a channel", async () => {
const bot = new modDiscordBot.DiscordBot(
"",
config,
mockBridge,
{},
) as DiscordBot;
const chan = new MockChannel("123") as any;
const t = Date.now();
bot.lockChannel(chan);
await bot.waitUnlock(chan);
const diff = Date.now() - t;
expect(diff).to.be.greaterThan(config.limits.discordSendDelay - 1);
});
it("should lock and unlock a channel early, if unlocked", async () => {
const discordSendDelay = 500;
const SHORTDELAY = 100;
const MINEXPECTEDDELAY = 95;
const bot = new modDiscordBot.DiscordBot(
"",
{
bridge: {
domain: "localhost",
},
limits: {
discordSendDelay,
},
},
mockBridge,
{},
) as DiscordBot;
const chan = new MockChannel("123") as any;
setTimeout(() => bot.unlockChannel(chan), SHORTDELAY);
const t = Date.now();
bot.lockChannel(chan);
await bot.waitUnlock(chan);
const diff = Date.now() - t;
// Date accuracy can be off by a few ms sometimes.
expect(diff).to.be.greaterThan(MINEXPECTEDDELAY);
});
});
// });
// describe("ProcessMatrixMsgEvent()", () => {
//
// });
// describe("UpdateRoom()", () => {
//
// });
// describe("UpdateUser()", () => {
//
// });
// describe("UpdatePresence()", () => {
//
// });
// describe("OnTyping()", () => {
// const discordBot = new modDiscordBot.DiscordBot(
// config,
// );
// discordBot.run();
// it("should reject an unknown room.", () => {
// return assert.isRejected(discordBot.OnTyping( {id: "512"}, {id: "12345"}, true));
// });
// it("should resolve a known room.", () => {
// return assert.isFulfilled(discordBot.OnTyping( {id: "321"}, {id: "12345"}, true));
// });
// });
});

View file

@ -16,45 +16,23 @@ limitations under the License.
import * as Chai from "chai";
import * as Proxyquire from "proxyquire";
import { DiscordCommandHandler } from "../src/discordcommandhandler";
import { MockChannel } from "./mocks/channel";
import { MockMember } from "./mocks/member";
import { MockGuild } from "./mocks/guild";
import { Util } from "../src/util";
import { AppserviceMock } from "./mocks/appservicemock";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
let USERSJOINED = 0;
let USERSKICKED = 0;
let USERSBANNED = 0;
let USERSUNBANNED = 0;
let ROOMSUNBRIDGED = 0;
let MESSAGESENT: any = {};
let MARKED = -1;
function createCH(opts: any = {}) {
USERSJOINED = 0;
USERSKICKED = 0;
USERSBANNED = 0;
USERSUNBANNED = 0;
ROOMSUNBRIDGED = 0;
MESSAGESENT = {};
MARKED = -1;
const bridge = {
getIntent: () => {
return {
ban: async () => { USERSBANNED++; },
getEvent: () => ({ content: { } }),
join: () => { USERSJOINED++; },
kick: async () => { USERSKICKED++; },
leave: () => { },
sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; },
unban: async () => { USERSUNBANNED++; },
};
},
};
const bridge = new AppserviceMock();
const cs = {
GetRoomIdsFromChannel: async (chan) => {
return [`#${chan.id}:localhost`];
@ -83,12 +61,12 @@ function createCH(opts: any = {}) {
},
},
})).DiscordCommandHandler;
return new discordCommandHndlr(bridge as any, discord as any);
return {handler: new discordCommandHndlr(bridge as any, discord as any), bridge};
}
describe("DiscordCommandHandler", () => {
it("will kick a member", async () => {
const handler: any = createCH();
const {handler, bridge} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel]);
channel.guild = guild;
@ -102,10 +80,10 @@ describe("DiscordCommandHandler", () => {
member,
};
await handler.Process(message);
expect(USERSKICKED).equals(1);
bridge.botIntent.underlyingClient.wasCalled("kickUser", true, "#123:localhost", "@123456:localhost");
});
it("will kick a member in all guild rooms", async () => {
const handler: any = createCH();
const {handler, bridge} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel, (new MockChannel("456"))]);
channel.guild = guild;
@ -120,10 +98,10 @@ describe("DiscordCommandHandler", () => {
};
await handler.Process(message);
// tslint:disable-next-line:no-magic-numbers
expect(USERSKICKED).equals(2);
expect(bridge.botIntent.underlyingClient.wasCalled("kickUser")).to.equal(2);
});
it("will deny permission", async () => {
const handler: any = createCH();
const {handler, bridge} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel]);
channel.guild = guild;
@ -137,10 +115,10 @@ describe("DiscordCommandHandler", () => {
member,
};
await handler.Process(message);
expect(USERSKICKED).equals(0);
expect(bridge.botIntent.underlyingClient.wasCalled("kickUser", false)).to.equal(0);
});
it("will ban a member", async () => {
const handler: any = createCH();
const {handler, bridge} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel]);
channel.guild = guild;
@ -154,10 +132,10 @@ describe("DiscordCommandHandler", () => {
member,
};
await handler.Process(message);
expect(USERSBANNED).equals(1);
expect(bridge.botIntent.underlyingClient.wasCalled("banUser")).to.equal(1);
});
it("will unban a member", async () => {
const handler: any = createCH();
const {handler, bridge} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel]);
channel.guild = guild;
@ -171,10 +149,10 @@ describe("DiscordCommandHandler", () => {
member,
};
await handler.Process(message);
expect(USERSUNBANNED).equals(1);
expect(bridge.botIntent.underlyingClient.wasCalled("unbanUser")).to.equal(1);
});
it("handles !matrix approve", async () => {
const handler: any = createCH();
const {handler, bridge} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel]);
channel.guild = guild;
@ -191,7 +169,7 @@ describe("DiscordCommandHandler", () => {
expect(MARKED).equals(1);
});
it("handles !matrix deny", async () => {
const handler: any = createCH();
const {handler} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel]);
channel.guild = guild;
@ -208,7 +186,7 @@ describe("DiscordCommandHandler", () => {
expect(MARKED).equals(0);
});
it("handles !matrix unbridge", async () => {
const handler: any = createCH();
const {handler} = createCH();
const channel = new MockChannel("123");
const guild = new MockGuild("456", [channel]);
channel.guild = guild;

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import * as Discord from "discord.js";
import { DiscordMessageProcessor, DiscordMessageProcessorOpts } from "../src/discordmessageprocessor";
import * as Chai from "chai"; // TODO: Use expect
import * as Discord from "better-discord.js";
import { DiscordMessageProcessor } from "../src/discordmessageprocessor";
import { DiscordBot } from "../src/bot";
import { MockGuild } from "./mocks/guild";
import { MockMember } from "./mocks/member";
import { MockMessage } from "./mocks/message";
import { MockRole } from "./mocks/role";
import { MockTextChannel } from "./mocks/channel";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
@ -45,15 +45,10 @@ const bot = {
};
describe("DiscordMessageProcessor", () => {
describe("init", () => {
it("constructor", () => {
const mp = new DiscordMessageProcessor(new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
});
});
describe("FormatMessage", () => {
describe("FormatMessage", async () => {
it("processes plain text messages correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "Hello World!";
@ -63,7 +58,7 @@ describe("DiscordMessageProcessor", () => {
});
it("processes markdown messages correctly.", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "Hello *World*!";
@ -73,13 +68,13 @@ describe("DiscordMessageProcessor", () => {
});
it("processes non-discord markdown correctly.", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "> inb4 tests";
msg.content = ">inb4 tests";
let result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "> inb4 tests");
Chai.assert.equal(result.formattedBody, "&gt; inb4 tests");
Chai.assert.equal(result.body, ">inb4 tests");
Chai.assert.equal(result.formattedBody, "&gt;inb4 tests");
msg.embeds = [];
msg.content = "[test](http://example.com)";
@ -90,7 +85,7 @@ describe("DiscordMessageProcessor", () => {
});
it("processes discord-specific markdown correctly.", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "_ italic _";
@ -100,7 +95,7 @@ describe("DiscordMessageProcessor", () => {
});
it("replaces @everyone correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "hey @everyone!";
@ -115,7 +110,7 @@ describe("DiscordMessageProcessor", () => {
});
it("replaces @here correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "hey @here!";
@ -129,610 +124,117 @@ describe("DiscordMessageProcessor", () => {
Chai.assert.equal(result.formattedBody, "hey @room!");
});
});
describe("FormatEmbeds", () => {
it("should format embeds correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
{
author: {} as any,
client: {} as any,
color: {} as any,
createdAt: {} as any,
createdTimestamp: {} as any,
description: "Description",
fields: [] as any,
footer: undefined as any,
hexColor: {} as any,
image: undefined as any,
message: {} as any,
provider: {} as any,
thumbnail: {} as any,
title: "Title",
type: {} as any,
url: "http://example.com",
video: {} as any,
},
];
msg.content = "message";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "message\n\n----\n##### [Title](http://example.com)\nDescription");
Chai.assert.equal(result.formattedBody, "message<hr><h5><a href=\"http://example.com\">Title</a>" +
"</h5><p>Description</p>");
});
it("should ignore same-url embeds", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
{
author: {} as any,
client: {} as any,
color: {} as any,
createdAt: {} as any,
createdTimestamp: {} as any,
description: "Description",
fields: [] as any,
footer: {} as any,
hexColor: {} as any,
image: {} as any,
message: {} as any,
provider: {} as any,
thumbnail: {} as any,
title: "Title",
type: {} as any,
url: "http://example.com",
video: {} as any,
},
];
msg.content = "message http://example.com";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "message http://example.com");
Chai.assert.equal(result.formattedBody, "message <a href=\"http://example.com\">" +
"http://example.com</a>");
});
it("should ignore same-url embeds with trailing slash", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
{
author: {} as any,
client: {} as any,
color: {} as any,
createdAt: {} as any,
createdTimestamp: {} as any,
description: "Description",
fields: [] as any,
footer: {} as any,
hexColor: {} as any,
image: {} as any,
message: {} as any,
provider: {} as any,
thumbnail: {} as any,
title: "Title",
type: {} as any,
url: "http://example.com/",
video: {} as any,
},
];
msg.content = "message http://example.com";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "message http://example.com");
Chai.assert.equal(result.formattedBody, "message <a href=\"http://example.com\">" +
"http://example.com</a>");
});
});
describe("FormatEdit", () => {
it("should format basic edits appropriately", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const oldMsg = new MockMessage() as any;
const newMsg = new MockMessage() as any;
oldMsg.embeds = [];
newMsg.embeds = [];
// Content updated but not changed
oldMsg.content = "a";
newMsg.content = "b";
const result = await processor.FormatEdit(oldMsg, newMsg);
Chai.assert.equal(result.body, "*edit:* ~~a~~ -> b");
Chai.assert.equal(result.formattedBody, "<em>edit:</em> <del>a</del> -&gt; b");
});
it("should format markdown heavy edits apropriately", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const oldMsg = new MockMessage() as any;
const newMsg = new MockMessage() as any;
oldMsg.embeds = [];
newMsg.embeds = [];
// Content updated but not changed
oldMsg.content = "a slice of **cake**";
newMsg.content = "*a* slice of cake";
const result = await processor.FormatEdit(oldMsg, newMsg);
Chai.assert.equal(result.body, "*edit:* ~~a slice of **cake**~~ -> *a* slice of cake");
Chai.assert.equal(result.formattedBody, "<em>edit:</em> <del>a slice of <strong>" +
"cake</strong></del> -&gt; <em>a</em> slice of cake");
});
it("should format discord fail edits correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const oldMsg = new MockMessage() as any;
const newMsg = new MockMessage() as any;
oldMsg.embeds = [];
newMsg.embeds = [];
// Content updated but not changed
oldMsg.content = "~~fail~";
newMsg.content = "~~fail~~";
const result = await processor.FormatEdit(oldMsg, newMsg);
Chai.assert.equal(result.body, "*edit:* ~~~~fail~~~ -> ~~fail~~");
Chai.assert.equal(result.formattedBody, "<em>edit:</em> <del>~~fail~</del> -&gt; <del>fail</del>");
});
it("should format multiline edits correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const oldMsg = new MockMessage() as any;
const newMsg = new MockMessage() as any;
oldMsg.embeds = [];
newMsg.embeds = [];
// Content updated but not changed
oldMsg.content = "multi\nline";
newMsg.content = "multi\nline\nfoxies";
const result = await processor.FormatEdit(oldMsg, newMsg);
Chai.assert.equal(result.body, "*edit:* ~~multi\nline~~ -> multi\nline\nfoxies");
Chai.assert.equal(result.formattedBody, "<p><em>edit:</em></p><p><del>multi<br>line</del></p><hr>" +
"<p>multi<br>line<br>foxies</p>");
});
it("should add old message link", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const oldMsg = new MockMessage() as any;
const newMsg = new MockMessage() as any;
oldMsg.embeds = [];
newMsg.embeds = [];
// Content updated but not changed
oldMsg.content = "fox";
newMsg.content = "foxies";
const result = await processor.FormatEdit(oldMsg, newMsg, "https://matrix.to/#/old");
Chai.assert.equal(result.body, "*edit:* ~~fox~~ -> foxies");
Chai.assert.equal(result.formattedBody, "<a href=\"https://matrix.to/#/old\"><em>edit:</em></a>" +
" <del>fox</del> -&gt; foxies");
});
});
describe("InsertUser / HTML", () => {
it("processes members missing from the guild correctly", () => {
it("processes members missing from the guild correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {});
const channel = new MockTextChannel(guild);
const msg = new MockMessage(channel) as any;
const content = { id: "12345" };
let reply = processor.InsertUser(content, msg);
Chai.assert.equal(reply, "@_discord_12345:localhost");
reply = processor.InsertUser(content, msg, true);
Chai.assert.equal(reply,
"<a href=\"https://matrix.to/#/@_discord_12345:localhost\">@_discord_12345:localhost</a>");
msg.embeds = [];
msg.content = "<@12345>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "@_discord_12345:localhost");
Chai.assert.equal(result.formattedBody, "<a href=\"https://matrix.to/#/@_discord_12345:l" +
"ocalhost\">@_discord_12345:localhost</a>");
});
it("processes members with usernames correctly", () => {
it("processes members with usernames correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
guild._mockAddMember(new MockMember("12345", "TestUsername"));
const channel = new Discord.TextChannel(guild, {});
const channel = new MockTextChannel(guild);
const msg = new MockMessage(channel) as any;
const content = { id: "12345" };
let reply = processor.InsertUser(content, msg);
Chai.assert.equal(reply, "TestUsername");
reply = processor.InsertUser(content, msg, true);
Chai.assert.equal(reply,
"<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestUsername</a>");
msg.embeds = [];
msg.content = "<@12345>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "TestUsername");
Chai.assert.equal(result.formattedBody, "<a href=\"https://matrix.to/#/@_discord_123" +
"45:localhost\">TestUsername</a>");
});
it("processes members with nickname correctly", () => {
it("processes members with nickname correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
guild._mockAddMember(new MockMember("12345", "TestUsername", null, "TestNickname"));
const channel = new Discord.TextChannel(guild, {});
const channel = new MockTextChannel(guild);
const msg = new MockMessage(channel) as any;
const content = { id: "12345" };
let reply = processor.InsertUser(content, msg);
Chai.assert.equal(reply, "TestNickname");
reply = processor.InsertUser(content, msg, true);
Chai.assert.equal(reply,
"<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestNickname</a>");
});
});
describe("InsertRole / HTML", () => {
it("ignores unknown roles", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.set("456", channel);
const role = new MockRole("5678", "role");
guild.roles.set("5678", role);
const msg = new MockMessage(channel) as any;
const content = { id: "1234" };
let reply = processor.InsertRole(content, msg);
Chai.assert.equal(reply, "<@&1234>");
reply = processor.InsertRole(content, msg, true);
Chai.assert.equal(reply, "&lt;@&amp;1234&gt;");
});
it("parses known roles", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.set("456", channel);
const ROLE_COLOR = 0xDEAD88;
const role = new MockRole("1234", "role", ROLE_COLOR);
guild.roles.set("1234", role);
const msg = new MockMessage(channel) as any;
const content = { id: "1234" };
let reply = processor.InsertRole(content, msg);
Chai.assert.equal(reply, "@role");
reply = processor.InsertRole(content, msg, true);
Chai.assert.equal(reply, "<span data-mx-color=\"#dead88\"><strong>@role</strong></span>");
});
});
describe("InsertEmoji", () => {
it("inserts static emojis to their post-parse flag", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const content = {
animated: false,
id: "1234",
name: "blah",
};
const reply = processor.InsertEmoji(content);
Chai.assert.equal(reply, "\x01emoji\x01blah\x010\x011234\x01");
});
it("inserts animated emojis to their post-parse flag", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const content = {
animated: true,
id: "1234",
name: "blah",
};
const reply = processor.InsertEmoji(content);
Chai.assert.equal(reply, "\x01emoji\x01blah\x011\x011234\x01");
});
});
describe("InsertChannel", () => {
it("inserts channels to their post-parse flag", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const content = {
id: "1234",
};
const reply = processor.InsertChannel(content);
Chai.assert.equal(reply, "\x01chan\x011234\x01");
msg.embeds = [];
msg.content = "<@12345>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "TestNickname");
Chai.assert.equal(result.formattedBody, "<a href=\"https://matrix.to/#/@_disc" +
"ord_12345:localhost\">TestNickname</a>");
});
});
describe("InsertMxcImages / HTML", () => {
it("processes unknown emoji correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
const channel = new MockTextChannel(guild, {id: "456", name: "TestChannel"});
const msg = new MockMessage(channel) as any;
const content = "Hello \x01emoji\x01hello\x010\x01123456789\x01";
let reply = await processor.InsertMxcImages(content, msg);
Chai.assert.equal(reply, "Hello <:hello:123456789>");
reply = await processor.InsertMxcImages(content, msg, true);
Chai.assert.equal(reply, "Hello &lt;:hello:123456789&gt;");
msg.embeds = [];
msg.content = "Hello <:hello:123456789>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "Hello <:hello:123456789>");
Chai.assert.equal(result.formattedBody, "Hello &lt;:hello:123456789&gt;");
});
it("processes emoji correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.set("456", channel);
const channel = new MockTextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.cache.set("456", channel);
const msg = new MockMessage(channel) as any;
const content = "Hello \x01emoji\x01hello\x010\x013333333\x01";
let reply = await processor.InsertMxcImages(content, msg);
Chai.assert.equal(reply, "Hello :hello:");
reply = await processor.InsertMxcImages(content, msg, true);
Chai.assert.equal(reply, "Hello <img alt=\"hello\" title=\"hello\" height=\"32\" src=\"mxc://image\" />");
});
it("processes double-emoji correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.set("456", channel);
const msg = new MockMessage(channel) as any;
const content = "Hello \x01emoji\x01hello\x010\x013333333\x01 \x01emoji\x01hello\x010\x013333333\x01";
let reply = await processor.InsertMxcImages(content, msg);
Chai.assert.equal(reply, "Hello :hello: :hello:");
reply = await processor.InsertMxcImages(content, msg, true);
Chai.assert.equal(reply, "Hello <img alt=\"hello\" title=\"hello\" height=\"32\" src=\"mxc://image\" /> " +
"<img alt=\"hello\" title=\"hello\" height=\"32\" src=\"mxc://image\" />");
msg.embeds = [];
msg.content = "Hello <:hello:3333333>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "Hello :hello:");
Chai.assert.equal(result.formattedBody, "Hello <img alt=\":hello:\" ti" +
"tle=\":hello:\" height=\"32\" src=\"mxc://image\" data-mx-emoticon />");
});
});
describe("InsertChannelPills / HTML", () => {
it("processes unknown channel correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.set("456", channel);
const channel = new MockTextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.cache.set("456", channel);
const msg = new MockMessage(channel) as any;
const content = "Hello \x01chan\x013333333\x01";
let reply = await processor.InsertChannelPills(content, msg);
Chai.assert.equal(reply, "Hello <#3333333>");
reply = await processor.InsertChannelPills(content, msg, true);
Chai.assert.equal(reply, "Hello &lt;#3333333&gt;");
msg.embeds = [];
msg.content = "Hello <#3333333>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "Hello <#3333333>");
Chai.assert.equal(result.formattedBody, "Hello &lt;#3333333&gt;");
});
it("processes channels correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.set("456", channel);
const channel = new MockTextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.cache.set("456", channel);
const msg = new MockMessage(channel) as any;
const content = "Hello \x01chan\x01456\x01";
let reply = await processor.InsertChannelPills(content, msg);
Chai.assert.equal(reply, "Hello #TestChannel");
reply = await processor.InsertChannelPills(content, msg, true);
Chai.assert.equal(reply, "Hello <a href=\"https://matrix.to/#/#_discord_123" +
"_456:localhost\">#TestChannel</a>");
});
it("processes multiple channels correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"});
guild.channels.set("456", channel);
const msg = new MockMessage(channel) as any;
const content = "Hello \x01chan\x01456\x01 \x01chan\x01456\x01";
let reply = await processor.InsertChannelPills(content, msg);
Chai.assert.equal(reply, "Hello #TestChannel #TestChannel");
reply = await processor.InsertChannelPills(content, msg, true);
Chai.assert.equal(reply, "Hello <a href=\"https://matrix.to/#/#_discord_123" +
"_456:localhost\">#TestChannel</a> <a href=\"https://matrix.to/#/#_discord_123" +
msg.embeds = [];
msg.content = "Hello <#456>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.body, "Hello #TestChannel");
Chai.assert.equal(result.formattedBody, "Hello <a href=\"https://matrix.to/#/#_discord_123" +
"_456:localhost\">#TestChannel</a>");
});
it("processes channels without alias correctly", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
"localhost", bot as DiscordBot);
const guild: any = new MockGuild("123", []);
const channel = new Discord.TextChannel(guild, {id: "678", name: "TestChannel"});
guild.channels.set("678", channel);
const channel = new MockTextChannel(guild, {id: "678", name: "TestChannel"});
guild.channels.cache.set("678", channel);
const msg = new MockMessage(channel) as any;
const content = "Hello \x01chan\x01678\x01";
let reply = await processor.InsertChannelPills(content, msg);
Chai.assert.equal(reply, "Hello <#678>");
reply = await processor.InsertChannelPills(content, msg, true);
Chai.assert.equal(reply, "Hello &lt;#678&gt;");
});
});
describe("InsertEmbeds", () => {
it("processes titleless embeds properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
}),
];
const inContent = "";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(content, "\n\n----\nTestDescription");
});
it("processes urlless embeds properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
title: "TestTitle",
}),
];
const inContent = "";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(content, "\n\n----\n##### TestTitle\nTestDescription");
});
it("processes linked embeds properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
title: "TestTitle",
url: "testurl",
}),
];
const inContent = "";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(content, "\n\n----\n##### [TestTitle](testurl)\nTestDescription");
});
it("rejects titleless and descriptionless embeds", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
url: "testurl",
}),
];
const inContent = "Some content...";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(content, "Some content...");
});
it("processes multiple embeds properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
title: "TestTitle",
url: "testurl",
}),
new Discord.MessageEmbed(msg, {
description: "TestDescription2",
title: "TestTitle2",
url: "testurl2",
}),
];
const inContent = "";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(
content,
"\n\n----\n##### [TestTitle](testurl)\nTestDescription\n\n----\n##### [TestTitle2](testurl2)\nTestDescription2",
);
});
it("inserts embeds properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
title: "TestTitle",
url: "testurl",
}),
];
const inContent = "Content that goes in the message";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(
content,
`Content that goes in the message
----
##### [TestTitle](testurl)
TestDescription`,
);
});
it("adds fields properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
title: "TestTitle",
url: "testurl",
}),
];
msg.embeds[0].fields = [
{
embed: msg.embeds[0],
inline: false,
name: "fox",
value: "floof",
},
] as any;
const inContent = "Content that goes in the message";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(
content,
`Content that goes in the message
----
##### [TestTitle](testurl)
TestDescription
**fox**
floof`,
);
});
it("adds images properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
title: "TestTitle",
url: "testurl",
}),
];
msg.embeds[0].image = { url: "http://example.com" } as any;
const inContent = "Content that goes in the message";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(
content,
`Content that goes in the message
----
##### [TestTitle](testurl)
TestDescription
Image: http://example.com`,
);
});
it("adds a footer properly", () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [
new Discord.MessageEmbed(msg, {
description: "TestDescription",
title: "TestTitle",
url: "testurl",
}),
];
msg.embeds[0].footer = { text: "footer" } as any;
const inContent = "Content that goes in the message";
const content = processor.InsertEmbeds(inContent, msg);
Chai.assert.equal(
content,
`Content that goes in the message
----
##### [TestTitle](testurl)
TestDescription
footer`,
);
});
});
describe("Message Type", () => {
it("sets non-bot messages as m.text", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "no bot";
msg.author.bot = false;
msg.content = "Hello <#678>";
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.msgtype, "m.text");
});
it("sets bot messages as m.notice", async () => {
const processor = new DiscordMessageProcessor(
new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot);
const msg = new MockMessage() as any;
msg.embeds = [];
msg.content = "a bot";
msg.author.bot = true;
const result = await processor.FormatMessage(msg);
Chai.assert.equal(result.msgtype, "m.notice");
Chai.assert.equal(result.body, "Hello <#678>");
Chai.assert.equal(result.formattedBody, "Hello &lt;#678&gt;");
});
});
});

View file

@ -14,25 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import * as Proxyquire from "proxyquire";
import * as RealLog from "../src/log";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
let createdLogger: any = null;
let loggerClosed: any = false;
let loggedMessages: any[] = [];
const WinstonMock = {
createLogger: (format, transports) => {
return createdLogger = {
close: () => {
loggerClosed = true;
},
close: () => { },
format,
log: (type, ...msg) => {
loggedMessages = loggedMessages.concat(msg);
@ -50,7 +44,6 @@ const Log = (Proxyquire("../src/log", {
describe("Log", () => {
beforeEach(() => {
loggerClosed = false;
loggedMessages = [];
});

View file

@ -14,52 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import { Util } from "../src/util";
import { DiscordBridgeConfig } from "../src/config";
import { MockChannel } from "./mocks/channel";
import { AppserviceMock } from "./mocks/appservicemock";
import * as Proxyquire from "proxyquire";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
function createCH(opts: any = {}, shouldBeJoined = true) {
let USERSJOINED = 0;
let USERSKICKED = 0;
let USERSBANNED = 0;
let USERSUNBANNED = 0;
let MESSAGESENT: any = {};
function createCH(opts: any = {}) {
USERSJOINED = 0;
USERSKICKED = 0;
USERSBANNED = 0;
USERSUNBANNED = 0;
MESSAGESENT = {};
const bridge = {
getBot: () => {
return {
getJoinedRooms: () => ["!123:localhost"],
isRemoteUser: (id) => {
return id !== undefined && id.startsWith("@_discord_");
},
};
},
getIntent: () => {
return {
ban: async () => { USERSBANNED++; },
getClient: () => mxClient,
join: () => { USERSJOINED++; },
joinRoom: async () => { USERSJOINED++; },
kick: async () => { USERSKICKED++; },
leave: () => { },
sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; },
unban: async () => { USERSUNBANNED++; },
};
},
};
const bridge = new AppserviceMock({
joinedrooms: shouldBeJoined ? ["!123:localhost"] : [],
});
const config = new DiscordBridgeConfig();
config.limits.roomGhostJoinDelay = 0;
@ -68,14 +37,6 @@ function createCH(opts: any = {}) {
} else {
config.bridge.enableSelfServiceBridging = true;
}
const mxClient = {
getUserId: () => "@user:localhost",
joinRoom: async () => {
USERSJOINED++;
},
sendReadReceipt: async () => { },
setRoomDirectoryVisibilityAppService: async () => { },
};
const provisioner = {
AskBridgePermission: async () => {
if (opts.denyBridgePermission) {
@ -118,7 +79,7 @@ function createCH(opts: any = {}) {
},
},
})).MatrixCommandHandler;
return new MatrixCommandHndl(bot as any, bridge, config);
return {handler: new MatrixCommandHndl(bot as any, bridge, config), bridge};
}
function createEvent(msg: string, room?: string, userId?: string) {
@ -133,75 +94,130 @@ function createEvent(msg: string, room?: string, userId?: string) {
function createContext(remoteData?: any) {
return {
rooms: {
remote: remoteData,
},
remote: remoteData,
};
}
describe("MatrixCommandHandler", () => {
describe("Process", () => {
it("should not process command if not in room", async () => {
const handler: any = createCH({disableSS: true});
const {handler, bridge} = createCH({}, false);
await handler.Process(createEvent("", "!666:localhost"), createContext());
expect(MESSAGESENT.body).to.equal(undefined);
bridge.botIntent.wasNotCalled("sendText", true);
bridge.botIntent.wasNotCalled("sendMessage", true);
});
it("should warn if self service is disabled", async () => {
const handler: any = createCH({disableSS: true});
const {handler, bridge} = createCH({disableSS: true});
await handler.Process(createEvent("!discord bridge"), createContext());
expect(MESSAGESENT.body).to.equal("**ERROR:** The owner of this bridge does " +
"not permit self-service bridging.");
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: "**ERROR:** The owner of this bridge does not permit self-service bridging.",
format: "org.matrix.custom.html",
// tslint:disable prefer-template
formatted_body: `<p><strong>ERROR:</strong> The owner of this bridge` +
` does not permit self-service bridging.</p>\n`,
msgtype: "m.notice",
});
});
it("should warn if user is not powerful enough", async () => {
const handler: any = createCH({
power: false,
});
const {handler, bridge} = createCH({power: false});
await handler.Process(createEvent("!discord bridge"), createContext());
expect(MESSAGESENT.body).to.equal("**ERROR:** insufficiant permissions to use this " +
"command! Try `!discord help` to see all available commands");
const expected = "**ERROR:** insufficient permissions to use this " +
"command! Try `!discord help` to see all available commands";
// tslint:disable prefer-template
const htmlExpected = `<p><strong>ERROR:</strong> insufficient permissions to use this command!` +
` Try <code>!discord help</code> to see all available commands</p>\n`;
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: htmlExpected,
msgtype: "m.notice",
});
});
describe("!discord bridge", () => {
it("will bridge a new room, and ask for permissions", async () => {
const handler: any = createCH();
const {handler, bridge} = createCH();
await handler.Process(createEvent("!discord bridge 123 456"), createContext());
expect(MESSAGESENT.body).to.equal("I have bridged this room to your channel");
const expected = "I have bridged this room to your channel";
const expectedHtml = "<p>I have bridged this room to your channel</p>\n";
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will fail to bridge if permissions were denied", async () => {
const handler: any = createCH({
const {handler, bridge} = createCH({
denyBridgePermission: true,
});
await handler.Process(createEvent("!discord bridge 123 456"), createContext());
expect(MESSAGESENT.body).to.equal("The bridge has been declined by the Discord guild");
const expected = "The bridge has been declined by the Discord guild";
const expectedHtml = "<p>The bridge has been declined by the Discord guild</p>\n";
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will fail to bridge if permissions were failed", async () => {
const handler: any = createCH({
const {handler, bridge} = createCH({
failBridgeMatrix: true,
});
const evt = await handler.Process(createEvent("!discord bridge 123 456"), createContext());
expect(MESSAGESENT.body).to.equal("There was a problem bridging that channel - has " +
"the guild owner approved the bridge?");
await handler.Process(createEvent("!discord bridge 123 456"), createContext());
const expected = "There was a problem bridging that channel - has the guild owner approved the bridge?";
const expectedHtml = "<p>There was a problem bridging that channel - has the guild owner approved the bridge?</p>\n";
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will not bridge if a link already exists", async () => {
const handler: any = createCH();
const evt = await handler.Process(createEvent("!discord bridge 123 456"), createContext(true));
expect(MESSAGESENT.body).to.equal("This room is already bridged to a Discord guild.");
const {handler, bridge} = createCH();
await handler.Process(createEvent("!discord bridge 123 456"), createContext(true));
const expected = "This room is already bridged to a Discord guild.";
const expectedHtml = "<p>This room is already bridged to a Discord guild.</p>\n";
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will not bridge without required args", async () => {
const handler: any = createCH();
const evt = await handler.Process(createEvent("!discord bridge"), createContext());
expect(MESSAGESENT.body).to.contain("Invalid syntax");
const {handler, bridge} = createCH();
await handler.Process(createEvent("!discord bridge"), createContext());
const expected = "Invalid syntax. For more information try `!discord help bridge`";
const expectedHtml = "<p>Invalid syntax. For more information try <code>!discord help bridge</code></p>\n";
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will bridge with x/y syntax", async () => {
const handler: any = createCH({powerLevels: {
const {handler, bridge} = createCH({powerLevels: {
users_default: 100,
}});
const evt = await handler.Process(createEvent("!discord bridge 123/456"), createContext());
expect(MESSAGESENT.body).equals("I have bridged this room to your channel");
await handler.Process(createEvent("!discord bridge 123/456"), createContext());
const expected = "I have bridged this room to your channel";
const expectedHtml = "<p>I have bridged this room to your channel</p>\n";
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
});
describe("!discord unbridge", () => {
it("will unbridge", async () => {
const handler: any = createCH();
const expected = "This room has been unbridged";
const expectedHtml = "<p>This room has been unbridged</p>\n";
const {handler, bridge} = createCH();
await handler.Process(createEvent("!discord unbridge"), createContext(
{
data: {
@ -211,15 +227,29 @@ describe("MatrixCommandHandler", () => {
},
},
));
expect(MESSAGESENT.body).equals("This room has been unbridged");
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will not unbridge if a link does not exist", async () => {
const handler: any = createCH();
const expected = "This room is not bridged.";
const expectedHtml = "<p>This room is not bridged.</p>\n";
const {handler, bridge} = createCH();
await handler.Process(createEvent("!discord unbridge"), createContext());
expect(MESSAGESENT.body).equals("This room is not bridged.");
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will not unbridge non-plumbed rooms", async () => {
const handler: any = createCH();
const expected = "This room cannot be unbridged.";
const expectedHtml = "<p>This room cannot be unbridged.</p>\n";
const {handler, bridge} = createCH();
await handler.Process(createEvent("!discord unbridge"), createContext(
{
data: {
@ -229,10 +259,20 @@ describe("MatrixCommandHandler", () => {
},
},
));
expect(MESSAGESENT.body).equals("This room cannot be unbridged.");
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
it("will show error if unbridge fails", async () => {
const handler: any = createCH({
const expected = "There was an error unbridging this room. Please " +
"try again later or contact the bridge operator.";
// tslint:disable prefer-template
const expectedHtml = `<p>There was an error unbridging this room. Please` +
` try again later or contact the bridge operator.</p>\n`;
const {handler, bridge} = createCH({
failUnbridge: true,
});
await handler.Process(createEvent("!discord unbridge"), createContext(
@ -244,24 +284,27 @@ describe("MatrixCommandHandler", () => {
},
},
));
expect(MESSAGESENT.body).to.contain("There was an error unbridging this room.");
bridge.botIntent.underlyingClient.wasCalled("sendMessage", true, "!123:localhost", {
body: expected,
format: "org.matrix.custom.html",
formatted_body: expectedHtml,
msgtype: "m.notice",
});
});
});
});
describe("HandleInvite", () => {
it("should accept invite for bot user", async () => {
const handler: any = createCH();
let joinedRoom = false;
const { handler, bridge } = createCH();
handler.joinRoom = async () => {
joinedRoom = true;
};
await handler.HandleInvite({
state_key: "@botuser:localhost",
});
expect(USERSJOINED).to.equal(1);
bridge.botIntent.wasCalled("joinRoom", true);
});
it("should deny invite for other users", async () => {
const handler: any = createCH();
const { handler, bridge } = createCH();
let joinedRoom = false;
handler.joinRoom = async () => {
joinedRoom = true;
@ -269,6 +312,7 @@ describe("MatrixCommandHandler", () => {
await handler.HandleInvite({
state_key: "@user:localhost",
});
bridge.getIntent("@user:localhost").wasNotCalled("joinRoom", true);
expect(joinedRoom).to.be.false;
});
});

File diff suppressed because it is too large Load diff

View file

@ -14,21 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import * as Discord from "discord.js";
import { expect } from "chai";
import { MockGuild } from "./mocks/guild";
import { MockMember } from "./mocks/member";
import { MockChannel } from "./mocks/channel";
import { MockEmoji } from "./mocks/emoji";
import { DiscordBot } from "../src/bot";
import { DbEmoji } from "../src/db/dbdataemoji";
import { MatrixMessageProcessor } from "../src/matrixmessageprocessor";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
const bot = {
GetChannelFromRoomId: async (roomId: string): Promise<MockChannel> => {
if (roomId !== "!bridged:localhost") {
@ -49,6 +45,12 @@ const bot = {
},
} as any;
const config = {
bridge: {
determineCodeLanguage: false,
},
} as any;
function getPlainMessage(msg: string, msgtype: string = "m.text") {
return {
body: msg,
@ -67,28 +69,28 @@ function getHtmlMessage(msg: string, msgtype: string = "m.text") {
describe("MatrixMessageProcessor", () => {
describe("FormatMessage / body / simple", () => {
it("leaves blank stuff untouched", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("hello world!");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("hello world!");
});
it("escapes simple stuff", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("hello *world* how __are__ you?");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("hello \\*world\\* how \\_\\_are\\_\\_ you?");
});
it("escapes more complex stuff", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("wow \\*this\\* is cool");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("wow \\\\\\*this\\\\\\* is cool");
});
it("escapes ALL the stuff", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("\\ * _ ~ ` |");
const result = await mp.FormatMessage(msg, guild as any);
@ -97,285 +99,185 @@ describe("MatrixMessageProcessor", () => {
});
describe("FormatMessage / formatted_body / simple", () => {
it("leaves blank stuff untouched", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("hello world!");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("hello world!");
});
it("un-escapes simple stuff", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("foxes &amp; foxes");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("foxes & foxes");
});
it("converts italic formatting", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("this text is <em>italic</em> and so is <i>this one</i>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("this text is *italic* and so is *this one*");
});
it("converts bold formatting", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("wow some <b>bold</b> and <strong>more</strong> boldness!");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("wow some **bold** and **more** boldness!");
});
it("converts underline formatting", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("to be <u>underlined</u> or not to be?");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("to be __underlined__ or not to be?");
});
it("converts strike formatting", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("does <del>this text</del> exist?");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("does ~~this text~~ exist?");
});
it("converts code", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("WOW this is <code>some awesome</code> code");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("WOW this is `some awesome` code");
});
it("converts multiline-code", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<p>here</p><pre><code>is\ncode\n</code></pre><p>yay</p>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("here```\nis\ncode\n```yay");
});
it("converts multiline language code", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<p>here</p>
<pre><code class="language-js">is
code
</code></pre>
<p>yay</p>`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("here```js\nis\ncode\n```yay");
});
it("handles linebreaks", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("line<br>break");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("line\nbreak");
});
it("handles <hr>", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("test<hr>foxes");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("test\n----------\nfoxes");
});
it("handles headings", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<h1>fox</h1>
<h2>floof</h2>
<h3>pony</h3>
<h4>hooves</h4>
<h5>tail</h5>
<h6>foxies</h6>`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal(`**# fox**
**## floof**
**### pony**
**#### hooves**
**##### tail**
**###### foxies**`);
});
});
describe("FormatMessage / formatted_body / complex", () => {
it("html unescapes stuff inside of code", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<code>is &lt;em&gt;italic&lt;/em&gt;?</code>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("`is <em>italic</em>?`");
});
it("html unescapes inside of pre", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<pre><code>wow &amp;</code></pre>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("```\nwow &```");
});
it("doesn't parse inside of code", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<code>*yay*</code>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("`*yay*`");
});
it("doesn't parse inside of pre", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<pre><code>*yay*</code></pre>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("```\n*yay*```");
});
it("parses new lines", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<em>test</em><br><strong>ing</strong>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("*test*\n**ing**");
});
it("drops mx-reply", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<mx-reply><blockquote>message</blockquote></mx-reply>test reply");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("test reply");
});
it("parses links", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a href=\"http://example.com\">link</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("[link](http://example.com)");
});
it("parses links with same content", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a href=\"http://example.com\">http://example.com</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("http://example.com");
});
it("doesn't discord-escape links", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a href=\"http://example.com/_blah_/\">link</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("[link](http://example.com/_blah_/)");
});
it("doesn't discord-escape links with same content", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a href=\"http://example.com/_blah_/\">http://example.com/_blah_/</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("http://example.com/_blah_/");
});
});
describe("FormatMessage / formatted_body / discord", () => {
it("Parses user pills", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const member = new MockMember("12345", "TestUsername", guild);
guild.members.set("12345", member);
guild.members.cache.set("12345", member);
const msg = getHtmlMessage("<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestUsername</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("<@12345>");
});
it("Ignores invalid user pills", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const member = new MockMember("12345", "TestUsername", guild);
guild.members.set("12345", member);
guild.members.cache.set("12345", member);
const msg = getHtmlMessage("<a href=\"https://matrix.to/#/@_discord_789:localhost\">TestUsername</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("[TestUsername](https://matrix.to/#/@_discord_789:localhost)");
});
it("Parses channel pills", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const channel = new MockChannel("12345", guild, "text", "SomeChannel");
guild.channels.set("12345", channel as any);
guild.channels.cache.set("12345", channel as any);
const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#_discord_1234_12345:" +
"localhost\">#SomeChannel</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("<#12345>");
});
it("Handles invalid channel pills", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const channel = new MockChannel("12345", guild, "text", "SomeChannel");
guild.channels.set("12345", channel as any);
guild.channels.cache.set("12345", channel as any);
const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#_discord_1234_789:localhost\">#SomeChannel</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("[#SomeChannel](https://matrix.to/#/#_discord_1234_789:localhost)");
});
it("Handles external channel pills", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#matrix:matrix.org\">#SomeChannel</a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("[#SomeChannel](https://matrix.to/#/#matrix:matrix.org)");
});
it("Handles external channel pills of rooms that are actually bridged", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#matrix:matrix.org\">#SomeChannel</a>");
const result = await mp.FormatMessage(msg, guild as any, {
mxClient: {
getRoomIdForAlias: async () => {
return {
room_id: "!bridged:localhost",
};
},
lookupRoomAlias: async () => ({
residentServers: [],
roomId: "!bridged:localhost",
}),
} as any,
},
});
);
expect(result).is.equal("<#1234>");
});
it("Ignores links without href", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a><em>yay?</em></a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("*yay?*");
});
it("Ignores links with non-matrix href", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<a href=\"http://example.com\"><em>yay?</em></a>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("[*yay?*](http://example.com)");
});
it("Handles spoilers", async () => {
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<span data-mx-spoiler>foxies</span>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("||foxies||");
});
it("Handles spoilers with reason", async () => {
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<span data-mx-spoiler=\"floof\">foxies</span>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("(floof)||foxies||");
});
});
describe("FormatMessage / formatted_body / emoji", () => {
it("Inserts emoji by name", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const emoji = new MockEmoji("123456", "test_emoji");
guild.emojis.set("123456", emoji);
guild.emojis.cache.set("123456", emoji);
const msg = getHtmlMessage("<img alt=\"test_emoji\">");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("<:test_emoji:123456>");
expect(result).is.equal("test\\_emoji");
});
it("Inserts emojis by mxc url", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const emoji = new MockEmoji("123456", "test_emoji");
guild.emojis.set("123456", emoji);
guild.emojis.cache.set("123456", emoji);
const msg = getHtmlMessage("<img src=\"mxc://real_emote:localhost\">");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("<:test_emoji:123456>");
});
it("parses unknown mxc urls", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const emoji = new MockEmoji("123456", "test_emoji");
guild.emojis.set("123456", emoji);
guild.emojis.cache.set("123456", emoji);
const msg = getHtmlMessage("<img alt=\"yay\" src=\"mxc://unreal_emote:localhost\">");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("[yay](mxc://unreal_emote:localhost)");
expect(result).is.equal("[yay mxc://unreal_emote:localhost ]");
});
it("ignores with no alt / title, too", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const emoji = new MockEmoji("123456", "test_emoji");
guild.emojis.set("123456", emoji);
guild.emojis.cache.set("123456", emoji);
const msg = getHtmlMessage("<img>");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("");
@ -392,7 +294,7 @@ code
*/
function getMxClient(roomNotificationLevel?: number) {
return {
getStateEvent: async (roomId, stateType, _) => {
getRoomStateEvent: async (roomId, stateType, _) => {
if (stateType === "m.room.power_levels") {
return {
// Only include notifications.room when
@ -424,21 +326,21 @@ code
const ROOM_NOTIFICATION_LEVEL = 50;
it("escapes @everyone", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("hey @everyone");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("hey @\u200Beveryone");
});
it("escapes @here", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("hey @here");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("hey @\u200Bhere");
});
it("converts @room to @here, if sufficient power", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("hey @room");
let params = {
@ -461,7 +363,7 @@ code
expect(result).is.equal("hey @here");
});
it("ignores @room to @here conversion, if insufficient power", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("hey @room");
let params = {
@ -484,7 +386,7 @@ code
expect(result).is.equal("hey @room");
});
it("handles /me for normal names", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("floofs", "m.emote");
const params = {
@ -494,7 +396,7 @@ code
expect(result).is.equal("_fox floofs_");
});
it("handles /me for short names", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("floofs", "m.emote");
const params = {
@ -504,7 +406,7 @@ code
expect(result).is.equal("_floofs_");
});
it("handles /me for long names", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("floofs", "m.emote");
const params = {
@ -514,7 +416,7 @@ code
expect(result).is.equal("_floofs_");
});
it("discord escapes nicks in /me", async () => {
const mp = new MatrixMessageProcessor(bot);
const mp = new MatrixMessageProcessor(bot, config);
const guild = new MockGuild("1234");
const msg = getPlainMessage("floofs", "m.emote");
const params = {
@ -524,165 +426,4 @@ code
expect(result).is.equal("_fox\\_floof floofs_");
});
});
describe("FormatMessage / formatted_body / blockquotes", () => {
it("parses single blockquotes", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<blockquote>hey</blockquote>there");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("> hey\n\nthere");
});
it("parses double blockquotes", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<blockquote><blockquote>hey</blockquote>you</blockquote>there");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("> > hey\n> \n> you\n\nthere");
});
it("parses blockquotes with <p>", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage("<blockquote>\n<p>spoky</p>\n</blockquote>\n<p>test</p>\n");
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("> spoky\n\ntest");
});
it("parses double blockquotes with <p>", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<blockquote>
<blockquote>
<p>spoky</p>
</blockquote>
<p>testing</p>
</blockquote>
<p>test</p>
`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("> > spoky\n> \n> testing\n\ntest");
});
});
describe("FormatMessage / formatted_body / lists", () => {
it("parses simple unordered lists", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<p>soru</p>
<ul>
<li>test</li>
<li>ing</li>
</ul>
<p>more</p>
`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("soru\n● test\n● ing\n\nmore");
});
it("parses nested unordered lists", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<p>foxes</p>
<ul>
<li>awesome</li>
<li>floofy
<ul>
<li>fur</li>
<li>tail</li>
</ul>
</li>
</ul>
<p>yay!</p>
`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("foxes\n● awesome\n● floofy\n ○ fur\n ○ tail\n\nyay!");
});
it("parses more nested unordered lists", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<p>foxes</p>
<ul>
<li>awesome</li>
<li>floofy
<ul>
<li>fur</li>
<li>tail</li>
</ul>
</li>
<li>cute</li>
</ul>
<p>yay!</p>
`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("foxes\n● awesome\n● floofy\n ○ fur\n ○ tail\n● cute\n\nyay!");
});
});
it("parses simple ordered lists", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<p>oookay</p>
<ol>
<li>test</li>
<li>test more</li>
</ol>
<p>ok?</p>
`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("oookay\n1. test\n2. test more\n\nok?");
});
it("parses nested ordered lists", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<p>and now</p>
<ol>
<li>test</li>
<li>test more
<ol>
<li>and more</li>
<li>more?</li>
</ol>
</li>
<li>done!</li>
</ol>
<p>ok?</p>
`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("and now\n1. test\n2. test more\n 1. and more\n 2. more?\n3. done!\n\nok?");
});
it("parses ordered lists with different start", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<ol start="5">
<li>test</li>
<li>test more</li>
</ol>`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("\n5. test\n6. test more");
});
it("parses ul in ol", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<ol>
<li>test</li>
<li>test more
<ul>
<li>asdf</li>
<li>jklö</li>
</ul>
</li>
</ol>`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("\n1. test\n2. test more\n ○ asdf\n ○ jklö");
});
it("parses ol in ul", async () => {
const mp = new MatrixMessageProcessor(bot);
const guild = new MockGuild("1234");
const msg = getHtmlMessage(`<ul>
<li>test</li>
<li>test more
<ol>
<li>asdf</li>
<li>jklö</li>
</ol>
</li>
</ul>`);
const result = await mp.FormatMessage(msg, guild as any);
expect(result).is.equal("\n● test\n● test more\n 1. asdf\n 2. jklö");
});
});

View file

@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import * as Proxyquire from "proxyquire";
import { DiscordBridgeConfig } from "../src/config";
import { MockChannel } from "./mocks/channel";
import { MockMember } from "./mocks/member";
import { MockGuild } from "./mocks/guild";
import { Util } from "../src/util";
import { AppserviceMock } from "./mocks/appservicemock";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
const RoomHandler = (Proxyquire("../src/matrixroomhandler", {
"./util": {
Util: {
@ -41,50 +40,15 @@ const RoomHandler = (Proxyquire("../src/matrixroomhandler", {
})).MatrixRoomHandler;
let USERSJOINED = 0;
let USERSKICKED = 0;
let USERSBANNED = 0;
let USERSUNBANNED = 0;
let MESSAGESENT: any = {};
let USERSYNC_HANDLED = false;
let KICKBAN_HANDLED = false;
let MESSAGE_PROCCESS = "";
function createRH(opts: any = {}) {
USERSJOINED = 0;
USERSKICKED = 0;
USERSBANNED = 0;
USERSUNBANNED = 0;
MESSAGESENT = {};
USERSYNC_HANDLED = false;
KICKBAN_HANDLED = false;
MESSAGE_PROCCESS = "";
const bridge = {
getBot: () => {
return {
getJoinedRooms: () => ["!123:localhost"],
isRemoteUser: (id) => {
return id !== undefined && id.startsWith("@_discord_");
},
};
},
getIntent: () => {
return {
ban: async () => { USERSBANNED++; },
getClient: () => mxClient,
getEvent: () => ({ content: { } }),
join: () => { USERSJOINED++; },
kick: async () => { USERSKICKED++; },
leave: () => { },
sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; },
unban: async () => { USERSUNBANNED++; },
};
},
};
const bridge = new AppserviceMock({
joinedrooms: ["!123:localhost"],
userIdPrefix: "@_discord_",
});
const us = {
JoinRoom: async () => { USERSJOINED++; },
OnMemberState: async () => {
USERSYNC_HANDLED = true;
},
OnUpdateUser: async () => { },
};
const cs = {
@ -106,18 +70,18 @@ function createRH(opts: any = {}) {
chan.members.set("54321", new MockMember("54321", "testuser2"));
chan.members.set("bot12345", new MockMember("bot12345", "botuser"));
}
guild.members = chan.members;
guild.members.updateCache(chan.members);
return chan;
} else {
throw new Error("Roomid not found");
}
},
GetGuilds: () => [new MockGuild("123", [])],
GetIntentFromDiscordMember: () => {
return bridge.getIntent();
GetIntentFromDiscordMember: (member: {id: string}) => {
return bridge.getIntentForSuffix(member.id);
},
HandleMatrixKickBan: async () => {
KICKBAN_HANDLED = true;
},
LookupRoom: async (guildid, discordid) => {
if (guildid !== "123") {
@ -125,41 +89,22 @@ function createRH(opts: any = {}) {
} else if (discordid !== "456") {
throw new Error("Channel not found");
}
const channel = new MockChannel();
const channel = new MockChannel(discordid, new MockGuild(guildid));
return {channel, botUser: true };
},
ProcessMatrixMsgEvent: async () => {
MESSAGE_PROCCESS = "processed";
},
ProcessMatrixRedact: async () => {
MESSAGE_PROCCESS = "redacted";
},
ProcessMatrixStateEvent: async () => {
MESSAGE_PROCCESS = "stateevent";
},
ThirdpartySearchForChannels: () => {
return [];
},
UserSyncroniser: us,
};
const config = new DiscordBridgeConfig();
config.room.defaultVisibility = "public";
config.limits.roomGhostJoinDelay = 0;
if (opts.disableSS) {
config.bridge.enableSelfServiceBridging = false;
} else {
config.bridge.enableSelfServiceBridging = true;
}
const mxClient = {
getStateEvent: async () => {
return opts.powerLevels || {};
},
getUserId: () => "@user:localhost",
joinRoom: async () => {
USERSJOINED++;
},
sendReadReceipt: async () => { },
setRoomDirectoryVisibilityAppService: async () => { },
};
const provisioner = {
AskBridgePermission: async () => {
if (opts.denyBridgePermission) {
@ -192,23 +137,23 @@ function createRH(opts: any = {}) {
},
};
const handler = new RoomHandler(bot as any, config, provisioner as any, bridge as any, store);
return handler;
return { handler, bridge };
}
describe("MatrixRoomHandler", () => {
describe("OnAliasQueried", () => {
it("should join successfully", async () => {
const handler = createRH();
const {handler} = createRH();
await handler.OnAliasQueried("#accept:localhost", "!accept:localhost");
});
it("should join successfully and create ghosts", async () => {
const EXPECTEDUSERS = 2;
const handler = createRH({createMembers: true});
const {handler} = createRH({createMembers: true});
await handler.OnAliasQueried("#accept:localhost", "!accept:localhost");
expect(USERSJOINED).to.equal(EXPECTEDUSERS);
});
it("should not join successfully", async () => {
const handler = createRH();
const {handler} = createRH();
try {
await handler.OnAliasQueried("#reject:localhost", "!reject:localhost");
throw new Error("didn't fail");
@ -219,123 +164,117 @@ describe("MatrixRoomHandler", () => {
});
describe("OnAliasQuery", () => {
it("will create room", async () => {
const handler: any = createRH({});
handler.createMatrixRoom = () => true;
const ret = await handler.OnAliasQuery(
"_discord_123_456:localhost",
"_discord_123_456");
expect(ret).to.be.true;
const {handler} = createRH({});
const ret = await handler.OnAliasQuery("#_discord_123_456:localhost");
expect(ret).to.be.deep.equal({
initial_state: [
{
content: {
join_rule: "public",
},
state_key: "",
type: "m.room.join_rules",
},
],
room_alias_name: "_discord_123_456",
visibility: "public",
});
});
it("will not create room if guild cannot be found", async () => {
const handler: any = createRH({});
const {handler} = createRH({});
handler.createMatrixRoom = () => true;
const ret = await handler.OnAliasQuery(
"_discord_111_456:localhost",
"_discord_111_456");
"#_discord_111_456:localhost");
expect(ret).to.be.undefined;
});
it("will not create room if channel cannot be found", async () => {
const handler: any = createRH({});
const {handler} = createRH({});
handler.createMatrixRoom = () => true;
const ret = await handler.OnAliasQuery(
"_discord_123_444:localhost",
"_discord_123_444");
"#_discord_123_444:localhost");
expect(ret).to.be.undefined;
});
it("will not create room if alias is wrong", async () => {
const handler: any = createRH({});
const {handler} = createRH({});
handler.createMatrixRoom = () => true;
const ret = await handler.OnAliasQuery(
"_discord_123:localhost",
"_discord_123");
"#_discord_123:localhost");
expect(ret).to.be.undefined;
});
});
describe("tpGetProtocol", () => {
it("will return an object", async () => {
const handler: any = createRH({});
const protocol = await handler.tpGetProtocol("");
expect(protocol).to.not.be.null;
expect(protocol.instances[0].network_id).to.equal("123");
expect(protocol.instances[0].bot_user_id).to.equal("@botuser:localhost");
expect(protocol.instances[0].desc).to.equal("123");
expect(protocol.instances[0].network_id).to.equal("123");
});
});
describe("tpGetLocation", () => {
it("will return an array", async () => {
const handler: any = createRH({});
const channels = await handler.tpGetLocation("", {
channel_name: "",
guild_id: "",
});
expect(channels).to.be.a("array");
});
});
describe("tpParseLocation", () => {
it("will reject", async () => {
const handler: any = createRH({});
try {
await handler.tpParseLocation("alias");
throw new Error("didn't fail");
} catch (e) {
expect(e.message).to.not.equal("didn't fail");
}
});
});
describe("tpGetUser", () => {
it("will reject", async () => {
const handler: any = createRH({});
try {
await handler.tpGetUser("", {});
throw new Error("didn't fail");
} catch (e) {
expect(e.message).to.not.equal("didn't fail");
}
});
});
describe("tpParseUser", () => {
it("will reject", async () => {
const handler: any = createRH({});
try {
await handler.tpParseUser("alias");
throw new Error("didn't fail");
} catch (e) {
expect(e.message).to.not.equal("didn't fail");
}
});
});
// Currently not supported on matrix-js-bot-sdk
//
// describe("tpGetProtocol", () => {
// it("will return an object", async () => {
// const {handler} = createRH({});
// const protocol = await handler.tpGetProtocol("");
// expect(protocol).to.not.be.null;
// expect(protocol.instances[0].network_id).to.equal("123");
// expect(protocol.instances[0].bot_user_id).to.equal("@botuser:localhost");
// expect(protocol.instances[0].desc).to.equal("123");
// expect(protocol.instances[0].network_id).to.equal("123");
// });
// });
// describe("tpGetLocation", () => {
// it("will return an array", async () => {
// const {handler} = createRH({});
// const channels = await handler.tpGetLocation("", {
// channel_name: "",
// guild_id: "",
// });
// expect(channels).to.be.a("array");
// });
// });
// describe("tpParseLocation", () => {
// it("will reject", async () => {
// const {handler} = createRH({});
// try {
// await handler.tpParseLocation("alias");
// throw new Error("didn't fail");
// } catch (e) {
// expect(e.message).to.not.equal("didn't fail");
// }
// });
// });
// describe("tpGetUser", () => {
// it("will reject", async () => {
// const {handler} = createRH({});
// try {
// await handler.tpGetUser("", {});
// throw new Error("didn't fail");
// } catch (e) {
// expect(e.message).to.not.equal("didn't fail");
// }
// });
// });
// describe("tpParseUser", () => {
// it("will reject", async () => {
// const {handler} = createRH({});
// try {
// await handler.tpParseUser("alias");
// throw new Error("didn't fail");
// } catch (e) {
// expect(e.message).to.not.equal("didn't fail");
// }
// });
// });
describe("joinRoom", () => {
it("will join immediately", async () => {
const handler: any = createRH({});
const intent = {
getClient: () => {
return {
joinRoom: async () => { },
};
},
};
const startTime = Date.now();
const MAXTIME = 1000;
const {handler, bridge} = createRH({});
const intent = bridge.botIntent;
await handler.joinRoom(intent, "#test:localhost");
expect(1).to.satisfy(() => {
return (Date.now() - startTime) < MAXTIME;
});
intent.wasCalled("joinRoom", true, "#test:localhost");
});
it("will fail first, join after", async () => {
const handler: any = createRH({});
const {handler, bridge} = createRH({});
let shouldFail = true;
const intent = {
getClient: () => {
return {
getUserId: () => "@test:localhost",
joinRoom: async () => {
if (shouldFail) {
shouldFail = false;
throw new Error("Test failed first time");
}
},
};
getUserId: () => "@test:localhost",
joinRoom: async () => {
if (shouldFail) {
shouldFail = false;
throw new Error("Test failed first time");
}
},
};
const startTime = Date.now();
@ -349,10 +288,10 @@ describe("MatrixRoomHandler", () => {
});
describe("createMatrixRoom", () => {
it("will return an object", async () => {
const handler: any = createRH({});
const {handler} = createRH({});
const channel = new MockChannel("123", new MockGuild("456"));
const roomOpts = await handler.createMatrixRoom(channel, "#test:localhost");
expect(roomOpts.creationOpts).to.exist;
expect(roomOpts).to.exist;
});
});
});

View file

@ -14,42 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import * as Discord from "discord.js";
import * as Proxyquire from "proxyquire";
import { expect } from "chai";
import * as Discord from "better-discord.js";
import { PresenceHandler } from "../src/presencehandler";
import { DiscordBot } from "../src/bot";
import { MockUser } from "./mocks/user";
import { AppserviceMock } from "./mocks/appservicemock";
import { MockPresence } from "./mocks/presence";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
const INTERVAL = 250;
let lastStatus = null;
// const assert = Chai.assert;
const bot = {
const appservice = new AppserviceMock();
const bot: any = {
GetBotId: () => {
return "1234";
},
GetIntentFromDiscordMember: (member) => {
return {
getClient: () => {
return {
setPresence: async (status) => {
lastStatus = status;
},
};
},
};
GetIntentFromDiscordMember: (member: MockUser) => {
return appservice.getIntentForSuffix(member.id);
},
};
describe("PresenceHandler", () => {
describe("init", () => {
it("constructor", () => {
const handler = new PresenceHandler(bot as DiscordBot);
new PresenceHandler(bot as DiscordBot);
});
});
describe("Stop", () => {
@ -63,124 +55,90 @@ describe("PresenceHandler", () => {
it("adds a user properly", () => {
const handler = new PresenceHandler(bot as DiscordBot);
const COUNT = 2;
handler.EnqueueUser(new MockUser("abc", "def") as any);
handler.EnqueueUser(new MockUser("123", "ghi") as any);
Chai.assert.equal(handler.QueueCount, COUNT);
handler.EnqueueUser(new MockPresence(new MockUser("abc", "alice"), "def") as any);
handler.EnqueueUser(new MockPresence(new MockUser("123", "bob"), "ghi") as any);
expect(handler.QueueCount).to.be.equal(COUNT);
});
it("does not add duplicate users", () => {
const handler = new PresenceHandler(bot as DiscordBot);
handler.EnqueueUser(new MockUser("abc", "def") as any);
handler.EnqueueUser(new MockUser("abc", "def") as any);
Chai.assert.equal(handler.QueueCount, 1);
handler.EnqueueUser(new MockPresence(new MockUser("123", "alice"), "def") as any);
handler.EnqueueUser(new MockPresence(new MockUser("123", "alice"), "def") as any);
expect(handler.QueueCount).to.be.equal(1);
});
it("does not add the bot user", () => {
const handler = new PresenceHandler(bot as DiscordBot);
handler.EnqueueUser(new MockUser("1234", "def") as any);
Chai.assert.equal(handler.QueueCount, 0);
handler.EnqueueUser(new MockPresence(new MockUser("1234", "bob"), "ghi") as any);
expect(handler.QueueCount).to.be.equal(0);
});
});
describe("DequeueUser", () => {
it("removes users properly", () => {
const handler = new PresenceHandler(bot as DiscordBot);
const members = [
new MockUser("abc", "def") as any,
new MockUser("def", "ghi") as any,
new MockUser("ghi", "wew") as any,
new MockPresence(new MockUser("abc", "alice"), "def") as any,
new MockPresence(new MockUser("def", "bob"), "ghi") as any,
new MockPresence(new MockUser("ghi", "foo"), "wew") as any,
];
handler.EnqueueUser(members[0]);
handler.EnqueueUser(members[1]);
handler.EnqueueUser(members[members.length - 1]);
handler.EnqueueUser(members[2]);
handler.DequeueUser(members[members.length - 1]);
Chai.assert.equal(handler.QueueCount, members.length - 1);
handler.DequeueUser(members[1]);
Chai.assert.equal(handler.QueueCount, 1);
handler.DequeueUser(members[0]);
Chai.assert.equal(handler.QueueCount, 0);
handler.DequeueUser(members[2].user);
expect(handler.QueueCount).to.be.equal(members.length - 1);
handler.DequeueUser(members[1].user);
expect(handler.QueueCount).to.be.equal(1);
handler.DequeueUser(members[0].user);
expect(handler.QueueCount).to.be.equal(0);
});
});
describe("ProcessUser", () => {
it("processes an online user", async () => {
lastStatus = null;
const handler = new PresenceHandler(bot as DiscordBot);
const member = new MockUser("abc", "def") as any;
member.MockSetPresence(new Discord.Presence({
status: "online",
}, {} as any));
await handler.ProcessUser(member);
Chai.assert.deepEqual(lastStatus, {
presence: "online",
});
const member = new MockPresence(new MockUser("ghi", "alice"), "def", "online");
await handler.ProcessUser(member as any);
appservice.getIntentForSuffix(member.userID)
.underlyingClient.wasCalled("setPresenceStatus", true, "online", undefined);
});
it("processes an offline user", async () => {
lastStatus = null;
const handler = new PresenceHandler(bot as DiscordBot);
const member = new MockUser("abc", "def") as any;
member.MockSetPresence(new Discord.Presence({
status: "offline",
}, {} as any));
await handler.ProcessUser(member);
Chai.assert.deepEqual(lastStatus, {
presence: "offline",
});
const member = new MockPresence(new MockUser("abc", "alice"), "def", "offline");
await handler.ProcessUser(member as any);
appservice.getIntentForSuffix(member.userID)
.underlyingClient.wasCalled("setPresenceStatus", true, "offline", undefined);
});
it("processes an idle user", async () => {
lastStatus = null;
const handler = new PresenceHandler(bot as DiscordBot);
const member = new MockUser("abc", "def") as any;
member.MockSetPresence(new Discord.Presence({
status: "idle",
}, {} as any));
await handler.ProcessUser(member);
Chai.assert.deepEqual(lastStatus, {
presence: "unavailable",
});
const member = new MockPresence(new MockUser("abc", "alice"), "def", "idle");
await handler.ProcessUser(member as any);
appservice.getIntentForSuffix(member.userID)
.underlyingClient.wasCalled("setPresenceStatus", true, "unavailable", undefined);
});
it("processes an dnd user", async () => {
lastStatus = null;
const handler = new PresenceHandler(bot as DiscordBot);
const member = new MockUser("abc", "def") as any;
member.MockSetPresence(new Discord.Presence({
status: "dnd",
}, {} as any));
await handler.ProcessUser(member);
Chai.assert.deepEqual(lastStatus, {
presence: "online",
status_msg: "Do not disturb",
});
member.MockSetPresence(new Discord.Presence({
game: new Discord.Game({name: "Test Game"}, {} as any),
status: "dnd",
}, {} as any));
await handler.ProcessUser(member);
Chai.assert.deepEqual(lastStatus, {
presence: "online",
status_msg: "Do not disturb | Playing Test Game",
});
const member = new MockPresence(new MockUser("abc", "alice"), "def", "dnd");
await handler.ProcessUser(member as any);
appservice.getIntentForSuffix(member.userID)
.underlyingClient.wasCalled("setPresenceStatus", true, "online", "Do not disturb");
const member2 = new MockPresence(new MockUser("abc", "alice"), "def", "dnd", [{name: "Test Game", type: "PLAYING"}]);
await handler.ProcessUser(member2 as any);
appservice.getIntentForSuffix(member.userID)
.underlyingClient.wasCalled("setPresenceStatus", true, "online", "Do not disturb | Playing Test Game");
});
it("processes a user playing games", async () => {
lastStatus = null;
const handler = new PresenceHandler(bot as DiscordBot);
const member = new MockUser("abc", "def") as any;
member.MockSetPresence(new Discord.Presence({
game: new Discord.Game({name: "Test Game"}, {} as any),
status: "online",
}, {} as any));
await handler.ProcessUser(member);
Chai.assert.deepEqual(lastStatus, {
presence: "online",
status_msg: "Playing Test Game",
});
member.MockSetPresence(new Discord.Presence({
game: new Discord.Game({name: "Test Game", type: 1}, {} as any),
status: "online",
}, {} as any));
await handler.ProcessUser(member);
Chai.assert.deepEqual(lastStatus, {
presence: "online",
status_msg: "Streaming Test Game",
});
const member = new MockPresence(new MockUser("abc", "alice"), "def", "online", [{name: "Test Game", type: "PLAYING"}]);
await handler.ProcessUser(member as any);
appservice.getIntentForSuffix(member.userID)
.underlyingClient.wasCalled("setPresenceStatus", true, "online", "Playing Test Game");
const member2 = new MockPresence(new MockUser("abc", "alice"), "def", "online", [{name: "Test Game", type: "STREAMING"}]);
await handler.ProcessUser(member2 as any);
appservice.getIntentForSuffix(member.userID)
.underlyingClient.wasCalled("setPresenceStatus", true, "online", "Streaming Test Game");
});
});
});

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import { Provisioner } from "../src/provisioner";
import { MockChannel } from "./mocks/channel";
import { MockMember } from "./mocks/member";
@ -22,8 +22,6 @@ import { MockMember } from "./mocks/member";
// we are a test file and thus need those
/* tslint:disable:no-any */
const expect = Chai.expect;
const TIMEOUT_MS = 1000;
describe("Provisioner", () => {

View file

@ -15,21 +15,13 @@ limitations under the License.
*/
import * as Chai from "chai";
// import * as Proxyquire from "proxyquire";
import { DiscordStore, CURRENT_SCHEMA } from "../src/store";
import { DiscordStore } from "../src/store";
import { DbEmoji } from "../src/db/dbdataemoji";
import { DbEvent } from "../src/db/dbdataevent";
import { Log } from "../src/log";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
const TEST_SCHEMA = CURRENT_SCHEMA;
// const assert = Chai.assert;
describe("DiscordStore", () => {
describe("init", () => {
it("can create a db", async () => {

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { Bridge } from "matrix-appservice-bridge";
import { expect } from "chai";
import {IGuildMemberState, IUserState, UserSyncroniser} from "../src/usersyncroniser";
import {MockUser} from "./mocks/user";
import {DiscordBridgeConfig} from "../src/config";
@ -24,30 +23,17 @@ import {MockMember} from "./mocks/member";
import {MockGuild} from "./mocks/guild";
import { MockChannel } from "./mocks/channel";
import { MockRole } from "./mocks/role";
import { IMatrixEvent } from "../src/matrixtypes";
import { Util } from "../src/util";
import { RemoteUser } from "../src/db/userstore";
import { AppserviceMock } from "./mocks/appservicemock";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
let DISPLAYNAME_SET: any = null;
let AVATAR_SET: any = null;
let REMOTEUSER_SET: any = null;
let INTENT_ID: any = null;
let LINK_MX_USER: any = null;
let LINK_RM_USER: any = null;
let UTIL_UPLOADED_AVATAR: any = false;
let SEV_ROOM_ID: any = null;
let SEV_CONTENT: any = null;
let SEV_KEY: any = null;
let JOIN_ROOM_ID: any = null;
let LEAVE_ROOM_ID: any = null;
let JOINS: any = 0;
let LEAVES: any = 0;
let SEV_COUNT: any = 0;
let REMOTEUSER_SET: any = null;
const GUILD_ROOM_IDS = ["!abc:localhost", "!def:localhost", "!ghi:localhost"];
const GUILD_ROOM_IDS_WITH_ROLE = ["!abc:localhost", "!def:localhost"];
@ -57,70 +43,25 @@ const UserSync = (Proxyquire("../src/usersyncroniser", {
Util: {
ApplyPatternString: Util.ApplyPatternString,
AsyncForEach: Util.AsyncForEach,
UploadContentFromUrl: async () => {
DownloadFile: async () => {
UTIL_UPLOADED_AVATAR = true;
return {mxcUrl: "avatarset"};
return {buffer: Buffer.from([])};
},
ParseMxid: Util.ParseMxid,
},
},
})).UserSyncroniser;
function CreateUserSync(remoteUsers: RemoteUser[] = [], ghostConfig: any = {}): UserSyncroniser {
function CreateUserSync(remoteUsers: RemoteUser[] = [], ghostConfig: any = {}) {
UTIL_UPLOADED_AVATAR = false;
SEV_ROOM_ID = null;
SEV_CONTENT = null;
SEV_KEY = null;
SEV_COUNT = 0;
const bridge: any = {
getIntent: (id) => {
DISPLAYNAME_SET = null;
AVATAR_SET = null;
INTENT_ID = id;
JOIN_ROOM_ID = null;
JOINS = 0;
LEAVES = 0;
return {
getClient: () => {
return {
getUserId: () => "@user:localhost",
sendStateEvent: (roomId, type, content, key) => {
SEV_ROOM_ID = roomId;
SEV_CONTENT = content;
SEV_KEY = key;
SEV_COUNT++;
},
};
},
join: (roomId) => {
JOIN_ROOM_ID = roomId;
JOINS++;
},
leave: (roomId) => {
LEAVE_ROOM_ID = roomId;
LEAVES++;
},
opts: {
backingStore: {
getMembership: (roomId, userId) => "join",
setMembership: (roomId, userId, membership) => { },
},
},
setAvatarUrl: async (ava) => {
AVATAR_SET = ava;
},
setDisplayName: async (dn) => {
DISPLAYNAME_SET = dn;
},
};
},
};
const bridge = new AppserviceMock();
const discordbot: any = {
GetChannelFromRoomId: (id) => {
if (id === "!found:localhost") {
const guild = new MockGuild("666666");
guild.members.set("123456", new MockMember("123456", "fella", guild));
guild.members.cache.set("123456", new MockMember("123456", "fella", guild));
const chan = new MockChannel("543345", guild);
guild.channels.set("543345", chan as any);
guild.channels.cache.set("543345", chan as any);
return chan;
}
throw new Error("Channel not found");
@ -128,11 +69,11 @@ function CreateUserSync(remoteUsers: RemoteUser[] = [], ghostConfig: any = {}):
GetGuilds: () => {
return [];
},
GetIntentFromDiscordMember: (id) => {
return bridge.getIntent(id);
GetIntentFromDiscordMember: (member) => {
return bridge.getIntentForSuffix(member.id);
},
GetRoomIdsFromGuild: async (guild, member?) => {
if (member && member.roles.get("1234")) {
GetRoomIdsFromGuild: async (guild: MockGuild, member: MockMember) => {
if (member && member.roles.cache.get("1234")) {
return GUILD_ROOM_IDS_WITH_ROLE;
}
return GUILD_ROOM_IDS;
@ -155,13 +96,14 @@ function CreateUserSync(remoteUsers: RemoteUser[] = [], ghostConfig: any = {}):
const config = new DiscordBridgeConfig();
config.bridge.domain = "localhost";
config.ghosts = Object.assign({}, config.ghosts, ghostConfig);
return new UserSync(bridge as Bridge, config, discordbot, userStore as any);
const userSync: UserSyncroniser = new UserSync(bridge as any, config, discordbot, userStore as any);
return {bridge, userSync};
}
describe("UserSyncroniser", () => {
describe("GetUserUpdateState", () => {
it("Will create a new user", async () => {
const userSync = CreateUserSync();
const {userSync} = CreateUserSync();
const user = new MockUser(
"123456",
"TestUsername",
@ -182,7 +124,7 @@ describe("UserSyncroniser", () => {
remoteUser.avatarurl = "test.jpg";
remoteUser.displayname = "TestUsername";
const userSync = CreateUserSync([remoteUser]);
const {userSync} = CreateUserSync([remoteUser]);
const user = new MockUser(
"123456",
"TestUsername",
@ -203,7 +145,7 @@ describe("UserSyncroniser", () => {
remoteUser.avatarurl = "test.jpg";
remoteUser.displayname = "TestUsername";
const userSync = CreateUserSync([remoteUser], {usernamePattern: ":username#:tag (Discord)"});
const {userSync} = CreateUserSync([remoteUser], {usernamePattern: ":username#:tag (Discord)"});
const user = new MockUser(
"123456",
"TestUsername",
@ -224,7 +166,7 @@ describe("UserSyncroniser", () => {
remoteUser.avatarurl = "test.jpg";
remoteUser.displayname = "TestUsername#6969";
const userSync = CreateUserSync([remoteUser]);
const {userSync} = CreateUserSync([remoteUser]);
const user = new MockUser(
"123456",
"TestUsername",
@ -245,7 +187,7 @@ describe("UserSyncroniser", () => {
remoteUser.avatarurl = "test.jpg";
remoteUser.displayname = "TestUsername#6969";
const userSync = CreateUserSync([remoteUser]);
const {userSync} = CreateUserSync([remoteUser]);
const user = new MockUser(
"123456",
"TestUsername",
@ -264,7 +206,7 @@ describe("UserSyncroniser", () => {
});
describe("ApplyStateToProfile", () => {
it("Will create a new user", async () => {
const userSync = CreateUserSync();
const {userSync} = CreateUserSync();
const state: IUserState = {
avatarId: "",
avatarUrl: null, // Nullable
@ -280,7 +222,7 @@ describe("UserSyncroniser", () => {
expect(REMOTEUSER_SET).is.null;
});
it("Will set a display name", async () => {
const userSync = CreateUserSync();
const {userSync, bridge} = CreateUserSync();
const state: IUserState = {
avatarId: "",
avatarUrl: null, // Nullable
@ -294,15 +236,17 @@ describe("UserSyncroniser", () => {
expect(LINK_MX_USER).is.not.null;
expect(LINK_RM_USER).is.not.null;
expect(REMOTEUSER_SET).is.not.null;
expect(DISPLAYNAME_SET).equal("123456");
expect(REMOTEUSER_SET.displayname).equal("123456");
expect(AVATAR_SET).is.null;
expect(REMOTEUSER_SET.avatarurl).is.null;
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasCalled("setDisplayName", true, "123456");
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasNotCalled("setAvatarUrl", true);
});
it("Will set an avatar", async () => {
const userSync = CreateUserSync();
const {userSync, bridge} = CreateUserSync();
const state: IUserState = {
avatarId: "",
avatarId: "avatarurl",
avatarUrl: "654321", // Nullable
createUser: true,
displayName: null, // Nullable
@ -313,15 +257,17 @@ describe("UserSyncroniser", () => {
await userSync.ApplyStateToProfile(state);
expect(LINK_MX_USER).is.not.null;
expect(LINK_RM_USER).is.not.null;
expect(AVATAR_SET).equal("avatarset");
expect(UTIL_UPLOADED_AVATAR).to.be.true;
expect(REMOTEUSER_SET).is.not.null;
expect(REMOTEUSER_SET.avatarurl).equal("654321");
expect(REMOTEUSER_SET.displayname).is.null;
expect(DISPLAYNAME_SET).is.null;
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasCalled("setAvatarUrl", true, "mxc://avatarurl");
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasNotCalled("setDisplayName", true);
});
it("Will remove an avatar", async () => {
const userSync = CreateUserSync();
const {userSync, bridge} = CreateUserSync();
const state: IUserState = {
avatarId: "",
avatarUrl: null, // Nullable
@ -334,15 +280,17 @@ describe("UserSyncroniser", () => {
await userSync.ApplyStateToProfile(state);
expect(LINK_MX_USER).is.not.null;
expect(LINK_RM_USER).is.not.null;
expect(AVATAR_SET).is.null;
expect(UTIL_UPLOADED_AVATAR).to.be.false;
expect(REMOTEUSER_SET).is.not.null;
expect(REMOTEUSER_SET.avatarurl).is.null;
expect(REMOTEUSER_SET.displayname).is.null;
expect(DISPLAYNAME_SET).is.null;
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasNotCalled("setAvatarUrl", true);
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasNotCalled("setDisplayName", true);
});
it("will do nothing if nothing needs to be done", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const state: IUserState = {
avatarId: "",
avatarUrl: null, // Nullable
@ -355,14 +303,16 @@ describe("UserSyncroniser", () => {
await userSync.ApplyStateToProfile(state);
expect(LINK_MX_USER).is.null;
expect(LINK_RM_USER).is.null;
expect(AVATAR_SET).is.null;
expect(REMOTEUSER_SET).is.null;
expect(DISPLAYNAME_SET).is.null;
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasNotCalled("setAvatarUrl", true);
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasNotCalled("setDisplayName", true);
});
});
describe("ApplyStateToRoom", () => {
it("Will apply a new nick", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const state: IGuildMemberState = {
bot: false,
displayColor: 0,
@ -375,12 +325,24 @@ describe("UserSyncroniser", () => {
await userSync.ApplyStateToRoom(state, "!abc:localhost", "123456");
expect(REMOTEUSER_SET).is.not.null;
expect(REMOTEUSER_SET.guildNicks.get("123456")).is.equal("Good Boy");
expect(SEV_ROOM_ID).is.equal("!abc:localhost");
expect(SEV_CONTENT.displayname).is.equal("Good Boy");
expect(SEV_KEY).is.equal("@_discord_123456:localhost");
bridge.getIntentForUserId("@_discord_123456:localhost").underlyingClient.wasCalled(
"sendStateEvent", true, "!abc:localhost",
"m.room.member", "@_discord_123456:localhost", {
"avatar_url": "",
"displayname": "Good Boy",
"membership": "join",
"uk.half-shot.discord.member": {
bot: false,
displayColor: 0,
id: "123456",
roles: [],
username: "",
},
},
);
});
it("Will not apply unchanged nick", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const state: IGuildMemberState = {
bot: false,
displayColor: 0,
@ -392,12 +354,11 @@ describe("UserSyncroniser", () => {
};
await userSync.ApplyStateToRoom(state, "!abc:localhost", "123456");
expect(REMOTEUSER_SET).is.null;
expect(SEV_ROOM_ID).is.null;
expect(SEV_CONTENT).is.null;
expect(SEV_KEY).is.null;
bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasNotCalled("sendStateEvent", true);
});
it("Will apply roles", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const TESTROLE_NAME = "testrole";
const TESTROLE_COLOR = 1337;
const TESTROLE_POSITION = 42;
@ -417,16 +378,21 @@ describe("UserSyncroniser", () => {
username: "",
};
await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678");
const custKey = SEV_CONTENT["uk.half-shot.discord.member"];
const roles = custKey.roles;
expect(custKey.id).is.equal("123456");
expect(roles.length).is.equal(1);
expect(roles[0].name).is.equal(TESTROLE_NAME);
expect(roles[0].color).is.equal(TESTROLE_COLOR);
expect(roles[0].position).is.equal(TESTROLE_POSITION);
bridge.getIntentForUserId("@_discord_123456:localhost").underlyingClient.sendStateEvent(
"!abc:localhost",
"m.room.member",
"@_discord_123456:localhost", {
"displayColor": 0,
"displayname": state.displayName,
"uk.half-shot.discord.member": {
id: "123456",
roles: state.roles,
},
},
);
});
it("Will set bot correctly", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const state: IGuildMemberState = {
bot: false,
displayColor: 0,
@ -437,17 +403,39 @@ describe("UserSyncroniser", () => {
username: "",
};
await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678");
let custKey = SEV_CONTENT["uk.half-shot.discord.member"];
expect(custKey.bot).is.false;
bridge.getIntentForUserId("@_discord_123456:localhost").underlyingClient.sendStateEvent(
"!abc:localhost",
"m.room.member",
"@_discord_123456:localhost", {
"bot": false,
"displayColor": 0,
"displayname": state.displayName,
"uk.half-shot.discord.member": {
id: "123456",
roles: state.roles,
},
},
);
const sync2 = CreateUserSync([new RemoteUser("123456")]);
state.bot = true;
await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678");
custKey = SEV_CONTENT["uk.half-shot.discord.member"];
expect(custKey.bot).is.true;
await sync2.userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678");
sync2.bridge.getIntentForUserId("@_discord_123456:localhost").underlyingClient.sendStateEvent(
"!abc:localhost",
"m.room.member",
"@_discord_123456:localhost", {
"bot": true,
"displayname": state.displayName,
"uk.half-shot.discord.member": {
id: "123456",
roles: state.roles,
},
},
);
});
it("Will set the displayColor correctly", async () => {
const TEST_COLOR = 1234;
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const state: IGuildMemberState = {
bot: false,
displayColor: TEST_COLOR,
@ -458,11 +446,22 @@ describe("UserSyncroniser", () => {
username: "",
};
await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678");
const custKey = SEV_CONTENT["uk.half-shot.discord.member"];
expect(custKey.displayColor).is.equal(TEST_COLOR);
bridge.getIntentForUserId("@_discord_123456:localhost").underlyingClient.sendStateEvent(
"!abc:localhost",
"m.room.member",
"@_discord_123456:localhost", {
"bot": false,
"displayColor": TEST_COLOR,
"displayname": state.displayName,
"uk.half-shot.discord.member": {
id: "123456",
roles: state.roles,
},
},
);
});
it("Will set username correctly", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const state: IGuildMemberState = {
bot: false,
displayColor: 0,
@ -473,13 +472,25 @@ describe("UserSyncroniser", () => {
username: "user#1234",
};
await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678");
const custKey = SEV_CONTENT["uk.half-shot.discord.member"];
expect(custKey.username).is.equal("user#1234");
bridge.getIntentForUserId("@_discord_123456:localhost").underlyingClient.sendStateEvent(
"!abc:localhost",
"m.room.member",
"@_discord_123456:localhost", {
"bot": false,
"displayColor": 0,
"displayname": state.displayName,
"uk.half-shot.discord.member": {
id: "123456",
roles: state.roles,
},
"username": "user#1234",
},
);
});
});
describe("GetUserStateForGuildMember", () => {
it("Will apply a new nick", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const guild = new MockGuild(
"654321");
const member = new MockMember(
@ -491,7 +502,7 @@ describe("UserSyncroniser", () => {
expect(state.displayName).to.be.equal("BestDog");
});
it("Will will obay nick pattern", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")], { nickPattern: ":nick (Discord)" });
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")], { nickPattern: ":nick (Discord)" });
const guild = new MockGuild(
"654321");
const member = new MockMember(
@ -503,7 +514,7 @@ describe("UserSyncroniser", () => {
expect(state.displayName).to.be.equal("BestDog (Discord)");
});
it("Will correctly add roles", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const guild = new MockGuild(
"654321");
const member = new MockMember(
@ -515,7 +526,7 @@ describe("UserSyncroniser", () => {
const TESTROLE_COLOR = 1337;
const TESTROLE_POSITION = 42;
const role = new MockRole("123", TESTROLE_NAME, TESTROLE_COLOR, TESTROLE_POSITION);
member.roles.set("123", role);
member.roles.cache.set("123", role);
const state = await userSync.GetUserStateForGuildMember(member as any);
expect(state.roles.length).to.be.equal(1);
expect(state.roles[0].name).to.be.equal(TESTROLE_NAME);
@ -525,7 +536,7 @@ describe("UserSyncroniser", () => {
});
describe("GetUserStateForDiscordUser", () => {
it("Will apply a new nick", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const member = new MockUser(
"123456",
"username",
@ -534,19 +545,19 @@ describe("UserSyncroniser", () => {
expect(state.displayName).to.be.equal("username");
});
it("Will handle webhooks", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const member = new MockUser(
"123456",
"username",
"1234");
const state = await userSync.GetUserStateForDiscordUser(member as any, "654321");
const state = await userSync.GetUserStateForDiscordUser(member as any, true);
expect(state.displayName).to.be.equal("username");
expect(state.mxUserId).to.be.equal("@_discord_123456_username:localhost");
});
});
describe("OnAddGuildMember", () => {
it("will update user and join to rooms", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const guild = new MockGuild(
"654321");
const member = new MockMember(
@ -554,12 +565,13 @@ describe("UserSyncroniser", () => {
"username",
guild);
await userSync.OnAddGuildMember(member as any);
expect(SEV_COUNT).to.equal(GUILD_ROOM_IDS.length);
expect(bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasCalled("sendStateEvent")).to.equal(GUILD_ROOM_IDS.length);
});
});
describe("OnRemoveGuildMember", () => {
it("will leave users from rooms", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const guild = new MockGuild(
"654321");
const member = new MockMember(
@ -567,12 +579,13 @@ describe("UserSyncroniser", () => {
"username",
guild);
await userSync.OnRemoveGuildMember(member as any);
expect(LEAVES).to.equal(GUILD_ROOM_IDS.length);
expect(bridge.getIntentForSuffix("123456")
.underlyingClient.wasCalled("leaveRoom")).to.equal(GUILD_ROOM_IDS.length);
});
});
describe("OnUpdateGuildMember", () => {
it("will update state for rooms", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const guild = new MockGuild(
"654321");
const newMember = new MockMember(
@ -581,10 +594,11 @@ describe("UserSyncroniser", () => {
guild,
"FiddleDee");
await userSync.OnUpdateGuildMember(newMember as any);
expect(SEV_COUNT).to.equal(GUILD_ROOM_IDS.length);
expect(bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasCalled("sendStateEvent")).to.equal(GUILD_ROOM_IDS.length);
});
it("will part rooms based on role removal", async () => {
const userSync = CreateUserSync([new RemoteUser("123456")]);
const {userSync, bridge} = CreateUserSync([new RemoteUser("123456")]);
const role = new MockRole("1234", "role");
const guild = new MockGuild(
"654321");
@ -593,11 +607,13 @@ describe("UserSyncroniser", () => {
"username",
guild,
"FiddleDee");
newMember.roles.set("1234", role);
newMember.roles.cache.set("1234", role);
await userSync.OnUpdateGuildMember(newMember as any);
expect(SEV_COUNT).to.equal(GUILD_ROOM_IDS_WITH_ROLE.length);
expect(LEAVES).to.equal(GUILD_ROOM_IDS.length - GUILD_ROOM_IDS_WITH_ROLE.length);
expect(LEAVE_ROOM_ID).to.equal("!ghi:localhost");
expect(bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasCalled("sendStateEvent")).to.equal(GUILD_ROOM_IDS_WITH_ROLE.length);
expect(bridge.getIntentForUserId("@_discord_123456:localhost")
.underlyingClient.wasCalled("leaveRoom", true, "!ghi:localhost"))
.to.equal(GUILD_ROOM_IDS.length - GUILD_ROOM_IDS_WITH_ROLE.length);
});
});
});

View file

@ -14,50 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Chai from "chai";
import { expect } from "chai";
import { Util, ICommandActions, ICommandParameters } from "../src/util";
import { AppserviceMock } from "./mocks/appservicemock";
// we are a test file and thus need those
/* tslint:disable:no-unused-expression max-file-line-count no-any */
const expect = Chai.expect;
function CreateMockIntent(members) {
return {
getClient: () => {
return {
_http: {
authedRequestWithPrefix: async (_, __, url, ___, ____, _____) => {
const ret: any[] = [];
for (const member of members[url]) {
ret.push({
content: {
displayname: member.displayname,
},
membership: member.membership,
state_key: member.mxid,
});
}
return {
chunk: ret,
};
},
function CreateMockIntent(members): any {
const as = new AppserviceMock({
roommembers: members.map((member) =>
({
content: {
displayname: member.displayname,
},
};
},
};
membership: member.membership,
stateKey: member.mxid,
}),
),
});
return as.botIntent;
}
describe("Util", () => {
describe("MsgToArgs", () => {
it("parses arguments", () => {
const {command, args} = Util.MsgToArgs("!matrix command arg1 arg2", "!matrix");
Chai.assert.equal(command, "command");
// tslint:disable-next-line:no-magic-numbers
Chai.assert.equal(args.length, 2);
Chai.assert.equal(args[0], "arg1");
Chai.assert.equal(args[1], "arg2");
expect(command).to.be.eq("command");
expect(args.length).to.be.eq(2);
expect(args[0]).to.be.eq("arg1");
expect(args[1]).to.be.eq("arg2");
});
});
describe("Command Stuff", () => {
@ -86,6 +73,16 @@ describe("Util", () => {
},
};
describe("HandleHelpCommand", () => {
it("handles empty commands", async () => {
const {command, args} = Util.MsgToArgs("!fox help", "!fox");
const retStr = await Util.HandleHelpCommand(
"!fox",
{} as any,
{} as any,
args,
);
expect(retStr).to.equal("No commands found");
});
it("parses general help message", async () => {
const {command, args} = Util.MsgToArgs("!fox help", "!fox");
const retStr = await Util.HandleHelpCommand(
@ -130,34 +127,30 @@ Fox goes floof!`);
});
describe("GetMxidFromName", () => {
it("Finds a single member", async () => {
const mockRooms = {
"/rooms/abc/members": [
{
displayname: "GoodBoy",
membership: "join",
mxid: "@123:localhost",
},
],
};
const intent = CreateMockIntent(mockRooms);
const mockUsers = [
{
displayname: "GoodBoy",
membership: "join",
mxid: "@123:localhost",
},
];
const intent = CreateMockIntent(mockUsers);
const mxid = await Util.GetMxidFromName(intent, "goodboy", ["abc"]);
expect(mxid).equal("@123:localhost");
});
it("Errors on multiple members", async () => {
const mockRooms = {
"/rooms/abc/members": [
{
displayname: "GoodBoy",
membership: "join",
mxid: "@123:localhost",
},
{
displayname: "GoodBoy",
membership: "join",
mxid: "@456:localhost",
},
],
};
const mockRooms = [
{
displayname: "GoodBoy",
membership: "join",
mxid: "@123:localhost",
},
{
displayname: "GoodBoy",
membership: "join",
mxid: "@456:localhost",
},
];
const intent = CreateMockIntent(mockRooms);
try {
await Util.GetMxidFromName(intent, "goodboy", ["abc"]);
@ -167,15 +160,13 @@ Fox goes floof!`);
}
});
it("Errors on no member", async () => {
const mockRooms = {
"/rooms/abc/members": [
{
displayname: "GoodBoy",
membership: "join",
mxid: "@123:localhost",
},
],
};
const mockRooms = [
{
displayname: "GoodBoy",
membership: "join",
mxid: "@123:localhost",
},
];
const intent = CreateMockIntent(mockRooms);
try {
await Util.GetMxidFromName(intent, "badboy", ["abc"]);
@ -236,7 +227,7 @@ Fox goes floof!`);
it("should deny", async () => {
const ret = await Util.CheckMatrixPermission(
{
getStateEvent: async () => {
getRoomStateEvent: async () => {
return {
blah: {
blubb: PERM_LEVEL,
@ -255,7 +246,7 @@ Fox goes floof!`);
it("should allow cat/subcat", async () => {
const ret = await Util.CheckMatrixPermission(
{
getStateEvent: async () => {
getRoomStateEvent: async () => {
return {
blah: {
blubb: PERM_LEVEL,
@ -277,7 +268,7 @@ Fox goes floof!`);
it("should allow cat", async () => {
const ret = await Util.CheckMatrixPermission(
{
getStateEvent: async () => {
getRoomStateEvent: async () => {
return {
blah: PERM_LEVEL,
users: {
@ -296,7 +287,7 @@ Fox goes floof!`);
it("should allow based on default", async () => {
const ret = await Util.CheckMatrixPermission(
{
getStateEvent: async () => {
getRoomStateEvent: async () => {
return {
blah: PERM_LEVEL,
users_default: PERM_LEVEL,
@ -311,4 +302,18 @@ Fox goes floof!`);
expect(ret).to.be.true;
});
});
describe("EscapeStringForUserId", () => {
it("should encode a string properly", () => {
expect(Util.EscapeStringForUserId("ThisIsAString")).to
.equal("=54his=49s=41=53tring");
expect(Util.EscapeStringForUserId('1!2"3£4$5%6^7&8*9(0)')).to
.equal("1=212=223=a34=245=256=5e7=268=2a9=280=29");
});
it("should not-reencode a string", () => {
expect(Util.EscapeStringForUserId("=54his=49s=41=53tring")).to
.equal("=54his=49s=41=53tring");
expect(Util.EscapeStringForUserId("1=212=223=a34=245=256=5e7=268=2a9=280=29")).to
.equal("1=212=223=a34=245=256=5e7=268=2a9=280=29");
});
});
});

View file

@ -19,15 +19,11 @@ limitations under the License.
* Allows you to become an admin for a room the bot is in control of.
*/
import { AppServiceRegistration, ClientFactory, Bridge } from "matrix-appservice-bridge";
import * as yaml from "js-yaml";
import * as fs from "fs";
import * as args from "command-line-args";
import * as usage from "command-line-usage";
import { DiscordBridgeConfig } from "../src/config";
import { Log } from "../src/log";
import { Util } from "../src/util";
import { DiscordStore } from "../src/store";
import { ToolsHelper } from "./toolshelper";
const log = new Log("AddRoomsToDirectory");
const optionDefinitions = [
{
@ -45,11 +41,12 @@ const optionDefinitions = [
typeLabel: "<config.yaml>",
},
{
alias: "s",
defaultValue: "room-store.db",
description: "The location of the room store.",
name: "store",
alias: "r",
defaultValue: "discord-registration.yaml",
description: "The AS registration file.",
name: "registration",
type: String,
typeLabel: "<discord-registration.yaml>",
},
];
@ -69,50 +66,26 @@ if (options.help) {
]));
process.exit(0);
}
const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8"));
const registration = AppServiceRegistration.fromObject(yamlConfig);
const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig;
if (registration === null) {
throw new Error("Failed to parse registration file");
}
const clientFactory = new ClientFactory({
appServiceUserId: `@${registration.sender_localpart}:${config.bridge.domain}`,
token: registration.as_token,
url: config.bridge.homeserverUrl,
});
const bridge = new Bridge({
controller: {
onEvent: () => { },
},
domain: "rubbish",
homeserverUrl: true,
registration: true,
});
const discordstore = new DiscordStore(config.database ? config.database.filename : "discord.db");
const {store, appservice} = ToolsHelper.getToolDependencies(options.config, options.registration);
async function run() {
try {
await discordstore.init();
await store!.init();
} catch (e) {
log.error(`Failed to load database`, e);
}
let rooms = await discordstore.roomStore.getEntriesByRemoteRoomData({
let rooms = await store!.roomStore.getEntriesByRemoteRoomData({
discord_type: "text",
});
rooms = rooms.filter((r) => r.remote && r.remote.get("plumbed") !== true );
const client = clientFactory.getClientAs();
log.info(`Got ${rooms.length} rooms to set`);
try {
await Util.AsyncForEach(rooms, async (room) => {
const guild = room.remote.get("discord_guild");
const roomId = room.matrix.getId();
const guild = room.remote!.get("discord_guild");
const roomId = room.matrix!.getId();
try {
await client.setRoomDirectoryVisibilityAppService(
guild,
await appservice.botIntent.underlyingClient.setDirectoryVisibility(
roomId,
"public",
);

View file

@ -20,12 +20,48 @@ limitations under the License.
*/
import * as yaml from "js-yaml";
import * as fs from "fs";
import * as args from "command-line-args";
import * as usage from "command-line-usage";
import { Util } from "../src/util";
import { DiscordBridgeConfig } from "../src/config";
const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8"));
if (yamlConfig === null) {
console.error("You have an error in your discord config.");
const optionDefinitions = [
{
alias: "h",
description: "Display this usage guide.",
name: "help",
type: Boolean,
},
{
alias: "c",
defaultValue: "config.yaml",
description: "The AS config file.",
name: "config",
type: String,
typeLabel: "<config.yaml>",
},
];
const options = args(optionDefinitions);
if (options.help) {
/* tslint:disable:no-console */
console.log(usage([
{
content: "A tool to obtain the Discord bot invitation URL.",
header: "Add bot",
},
{
header: "Options",
optionList: optionDefinitions,
},
]));
process.exit(0);
}
const url = Util.GetBotLink(yamlConfig);
const yamlConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8"));
if (yamlConfig === null || typeof yamlConfig !== "object") {
throw Error("You have an error in your discord config.");
}
const url = Util.GetBotLink(yamlConfig as DiscordBridgeConfig);
console.log(`Go to ${url} to invite the bot into a guild.`);

View file

@ -16,15 +16,12 @@ limitations under the License.
/* tslint:disable:no-console */
/**
* Allows you to become an admin for a room the bot is in control of.
* Allows you to become an admin for a room that the bot is in control of.
*/
import { AppServiceRegistration, ClientFactory, Intent } from "matrix-appservice-bridge";
import * as yaml from "js-yaml";
import * as fs from "fs";
import * as args from "command-line-args";
import * as usage from "command-line-usage";
import { DiscordBridgeConfig } from "../src/config";
import { ToolsHelper } from "./toolshelper";
const optionDefinitions = [
{
@ -43,6 +40,14 @@ const optionDefinitions = [
},
{
alias: "r",
defaultValue: "discord-registration.yaml",
description: "The AS registration file.",
name: "registration",
type: String,
typeLabel: "<discord-registration.yaml>",
},
{
alias: "m",
description: "The roomid to modify",
name: "roomid",
type: String,
@ -90,25 +95,18 @@ if (!options.userid) {
process.exit(1);
}
const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8"));
const registration = AppServiceRegistration.fromObject(yamlConfig);
const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig;
if (registration === null) {
throw new Error("Failed to parse registration file");
}
const clientFactory = new ClientFactory({
appServiceUserId: `@${registration.sender_localpart}:${config.bridge.domain}`,
token: registration.as_token,
url: config.bridge.homeserverUrl,
});
const client = clientFactory.getClientAs();
const intent = new Intent(client, client, {registered: true});
const {appservice} = ToolsHelper.getToolDependencies(options.config, options.reg, false);
async function run() {
try {
await intent.setPowerLevel(options.roomid, options.userid, options.power);
const powerLevels = (await appservice.botIntent.underlyingClient.getRoomStateEvent(
options.roomid, "m.room.power_levels", "",
));
powerLevels.users[options.userid] = options.power;
await appservice.botIntent.underlyingClient.sendStateEvent(
options.roomid, "m.room.power_levels", "", powerLevels,
);
console.log("Power levels set");
process.exit(0);
} catch (err) {

View file

@ -14,18 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AppServiceRegistration, ClientFactory, Bridge, Intent } from "matrix-appservice-bridge";
import * as yaml from "js-yaml";
import * as fs from "fs";
import * as args from "command-line-args";
import * as usage from "command-line-usage";
import { ChannelSyncroniser } from "../src/channelsyncroniser";
import { DiscordBridgeConfig } from "../src/config";
import { DiscordBot } from "../src/bot";
import { DiscordStore } from "../src/store";
import { Provisioner } from "../src/provisioner";
import { Log } from "../src/log";
import { Util } from "../src/util";
import { ToolsHelper } from "./toolshelper";
const log = new Log("ChanFix");
@ -44,6 +38,14 @@ const optionDefinitions = [
type: String,
typeLabel: "<config.yaml>",
},
{
alias: "r",
defaultValue: "discord-registration.yaml",
description: "The AS registration file.",
name: "registration",
type: String,
typeLabel: "<discord-registration.yaml>",
},
];
const options = args(optionDefinitions);
@ -64,70 +66,31 @@ if (options.help) {
process.exit(0);
}
const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8"));
const registration = AppServiceRegistration.fromObject(yamlConfig);
const config = new DiscordBridgeConfig();
config.applyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig);
config.applyEnvironmentOverrides(process.env);
if (registration === null) {
throw new Error("Failed to parse registration file");
}
const botUserId = `@${registration.sender_localpart}:${config.bridge.domain}`;
const clientFactory = new ClientFactory({
appServiceUserId: botUserId,
token: registration.as_token,
url: config.bridge.homeserverUrl,
});
const bridge = new Bridge({
clientFactory,
controller: {
onEvent: () => { },
},
domain: config.bridge.domain,
homeserverUrl: config.bridge.homeserverUrl,
intentOptions: {
clients: {
dontJoin: true, // handled manually
},
},
registration,
roomStore: config.database.roomStorePath,
userStore: config.database.userStorePath,
});
async function run() {
await bridge.loadDatabases();
const store = new DiscordStore(config.database);
await store.init(undefined, bridge.getRoomStore());
const discordbot = new DiscordBot(botUserId, config, bridge, store);
const {store, appservice, config} = ToolsHelper.getToolDependencies(options.config);
await store!.init();
const discordbot = new DiscordBot(config, appservice, store!);
await discordbot.init();
bridge._clientFactory = clientFactory;
bridge._botClient = bridge._clientFactory.getClientAs();
bridge._botIntent = new Intent(bridge._botClient, bridge._botClient, { registered: true });
await discordbot.ClientFactory.init();
const client = await discordbot.ClientFactory.getClient();
// first set update_icon to true if needed
const mxRoomEntries = await bridge.getRoomStore().getEntriesByRemoteRoomData({
const mxRoomEntries = await store!.roomStore.getEntriesByRemoteRoomData({
update_name: true,
update_topic: true,
});
const promiseList: Promise<void>[] = [];
mxRoomEntries.forEach((entry) => {
if (entry.remote.get("plumbed")) {
if (entry.remote!.get("plumbed")) {
return; // skipping plumbed rooms
}
const updateIcon = entry.remote.get("update_icon");
const updateIcon = entry.remote!.get("update_icon");
if (updateIcon !== undefined && updateIcon !== null) {
return; // skipping because something was set manually
}
entry.remote.set("update_icon", true);
promiseList.push(bridge.getRoomStore().upsertEntry(entry));
entry.remote!.set("update_icon", true);
promiseList.push(store!.roomStore.upsertEntry(entry));
});
await Promise.all(promiseList);
@ -135,7 +98,7 @@ async function run() {
const promiseList2: Promise<void>[] = [];
let curDelay = config.limits.roomGhostJoinDelay; // we'll just re-use this
client.guilds.forEach((guild) => {
client.guilds.cache.forEach((guild) => {
promiseList2.push((async () => {
await Util.DelayedPromise(curDelay);
try {

View file

@ -14,16 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AppServiceRegistration, ClientFactory, Bridge } from "matrix-appservice-bridge";
import * as yaml from "js-yaml";
import * as fs from "fs";
import * as args from "command-line-args";
import * as usage from "command-line-usage";
import { DiscordBridgeConfig } from "../src/config";
import { Log } from "../src/log";
import { Util } from "../src/util";
import { DiscordBot } from "../src/bot";
import { DiscordStore } from "../src/store";
import { ToolsHelper } from "./toolshelper";
const log = new Log("GhostFix");
@ -53,6 +49,14 @@ const optionDefinitions = [
type: String,
typeLabel: "<config.yaml>",
},
{
alias: "r",
defaultValue: "discord-registration.yaml",
description: "The AS registration file.",
name: "registration",
type: String,
typeLabel: "<discord-registration.yaml>",
},
];
const options = args(optionDefinitions);
@ -73,55 +77,19 @@ if (options.help) {
process.exit(0);
}
const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8"));
const registration = AppServiceRegistration.fromObject(yamlConfig);
const config = new DiscordBridgeConfig();
config.applyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig);
config.applyEnvironmentOverrides(process.env);
if (registration === null) {
throw new Error("Failed to parse registration file");
}
const botUserId = `@${registration.sender_localpart}:${config.bridge.domain}`;
const clientFactory = new ClientFactory({
appServiceUserId: botUserId,
token: registration.as_token,
url: config.bridge.homeserverUrl,
});
const bridge = new Bridge({
clientFactory,
controller: {
onEvent: () => { },
},
domain: config.bridge.domain,
homeserverUrl: config.bridge.homeserverUrl,
intentOptions: {
clients: {
dontJoin: true, // handled manually
},
},
registration,
roomStore: config.database.roomStorePath,
userStore: config.database.userStorePath,
});
async function run() {
await bridge.loadDatabases();
const store = new DiscordStore(config.database);
await store.init(undefined, bridge.getRoomStore());
const discordbot = new DiscordBot(botUserId, config, bridge, store);
const {store, appservice, config} = ToolsHelper.getToolDependencies(options.config);
await store!.init();
const discordbot = new DiscordBot(config, appservice, store!);
await discordbot.init();
bridge._clientFactory = clientFactory;
const client = await discordbot.ClientFactory.getClient();
const promiseList: Promise<void>[] = [];
let curDelay = config.limits.roomGhostJoinDelay;
try {
client.guilds.forEach((guild) => {
guild.members.forEach((member) => {
if (member.id === client.user.id) {
client.guilds.cache.forEach((guild) => {
guild.members.cache.forEach((member) => {
if (member.id === client.user?.id) {
return;
}
promiseList.push((async () => {

38
tools/toolshelper.ts Normal file
View file

@ -0,0 +1,38 @@
import { DiscordBridgeConfig } from "../src/config";
import { Appservice, IAppserviceRegistration } from "matrix-bot-sdk";
import { DiscordStore } from "../src/store";
import * as yaml from "js-yaml";
import * as fs from "fs";
export class ToolsHelper {
public static getToolDependencies(
configFile: string, regFile: string = "./discord-registration.yaml", needsStore: boolean = true): {
store: DiscordStore|null,
appservice: Appservice,
config: DiscordBridgeConfig,
} {
const registration = yaml.safeLoad(fs.readFileSync(regFile, "utf8"));
const config: DiscordBridgeConfig = Object.assign(
new DiscordBridgeConfig(), yaml.safeLoad(fs.readFileSync(configFile, "utf8")));
config.applyEnvironmentOverrides(process.env);
if (registration === null || typeof registration !== "object") {
throw Error("Failed to parse registration file");
}
const appservice = new Appservice({
bindAddress: "notathing",
homeserverName: config.bridge.domain,
homeserverUrl: config.bridge.homeserverUrl,
port: 0,
// We assume the registration is well formed
registration: registration as IAppserviceRegistration,
});
const store = needsStore ? new DiscordStore(config.database ? config.database.filename : "discord.db") : null;
return {
appservice,
config,
store,
};
}
}

View file

@ -71,7 +71,7 @@ if (options.help || (options.add && options.remove) || !(options.add || options.
process.exit(0);
}
const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8"));
const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig;
const discordstore = new DiscordStore(config.database ? config.database : "discord.db");
discordstore.init().then(() => {
log.info("Loaded database.");

View file

@ -13,7 +13,6 @@
"compileOnSave": true,
"include": [
"src/**/*",
"test/**/*",
"tools/**/*"
"tools/**/*",
]
}

View file

@ -9,7 +9,6 @@
"object-literal-sort-keys": "off",
"no-any": true,
"arrow-return-shorthand": true,
"no-magic-numbers": [true, -1, 0, 1, 1000],
"prefer-for-of": true,
"typedef": {
"severity": "warning"

3718
yarn.lock Normal file

File diff suppressed because it is too large Load diff