mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
Merge pull request #136 from matrix-org/gnuxie/mx-tester
An Integration test setup that can be used with mx-tester
This commit is contained in:
commit
725d400650
52
README.md
52
README.md
@ -133,3 +133,55 @@ to restart Synapse to install the plugin.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
TODO. It's a TypeScript project with a linter.
|
TODO. It's a TypeScript project with a linter.
|
||||||
|
|
||||||
|
### Development and testing with mx-tester
|
||||||
|
|
||||||
|
WARNING: mx-tester is currently work in progress, but it can still save you some time and is better than struggling with nothing.
|
||||||
|
|
||||||
|
If you have docker installed you can quickly get setup with a development environment by using
|
||||||
|
[mx-tester](https://github.com/matrix-org/mx-tester).
|
||||||
|
|
||||||
|
To use mx-tester you will need to have rust installed. You can do that at [rustup](https://rustup.rs/) or [here](https://rust-lang.github.io/rustup/installation/other.html), you should probably also check your distro's documentation first to see if they have specific instructions for installing rust.
|
||||||
|
|
||||||
|
Once rust is installed you can install mx-tester like so.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cargo install mx-tester
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you have mx-tester installed you we will want to build a synapse image with synapse_antispam from the mjolnir project root.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ mx-tester build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then we can start a container that uses that image and the config in `mx-tester.yml`.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ mx-tester up
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you have called `mx-tester up` you can run the integration tests.
|
||||||
|
```
|
||||||
|
$ yarn test:integration
|
||||||
|
```
|
||||||
|
|
||||||
|
After calling `mx-tester up`, if we want to play with mojlnir locally we can run the following and then point a matrix client to http://localhost:9999.
|
||||||
|
You should then be able to join the management room at `#moderators:localhost:9999`.
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn test:manual
|
||||||
|
```
|
||||||
|
|
||||||
|
Once we are finished developing we can stop the synapse container.
|
||||||
|
|
||||||
|
```
|
||||||
|
mx-tester down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running integration tests
|
||||||
|
|
||||||
|
The integration tests can be run with `yarn test:integration`.
|
||||||
|
The config that the tests use is in `config/harness.yaml`
|
||||||
|
and by default this is configured to work with the server specified in `mx-tester.yml`,
|
||||||
|
but you can configure it however you like to run against your own setup.
|
||||||
|
161
config/harness.yaml
Normal file
161
config/harness.yaml
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
# This configuration file is for the integration tests run by yarn:integration.
|
||||||
|
# Editing this will do nothing and you shouldn't use it as a template.
|
||||||
|
# For a template use default.yaml
|
||||||
|
# Where the homeserver is located (client-server URL). This should point at
|
||||||
|
# pantalaimon if you're using that.
|
||||||
|
homeserverUrl: "http://localhost:9999"
|
||||||
|
|
||||||
|
# Pantalaimon options (https://github.com/matrix-org/pantalaimon)
|
||||||
|
pantalaimon:
|
||||||
|
# If true, accessToken above is ignored and the username/password below will be
|
||||||
|
# used instead. The access token of the bot will be stored in the dataPath.
|
||||||
|
use: true
|
||||||
|
|
||||||
|
# The username to login with.
|
||||||
|
username: mjolnir
|
||||||
|
|
||||||
|
# The password to login with. Can be removed after the bot has logged in once and
|
||||||
|
# stored the access token.
|
||||||
|
password: mjolnir
|
||||||
|
|
||||||
|
# The directory the bot should store various bits of information in
|
||||||
|
dataPath: "./test/harness/mjolnir-data/"
|
||||||
|
|
||||||
|
# If true (the default), only users in the `managementRoom` can invite the bot
|
||||||
|
# to new rooms.
|
||||||
|
autojoinOnlyIfManager: true
|
||||||
|
|
||||||
|
# If `autojoinOnlyIfManager` is false, only the members in this group can invite
|
||||||
|
# the bot to new rooms.
|
||||||
|
acceptInvitesFromGroup: '+example:example.org'
|
||||||
|
|
||||||
|
# If the bot is invited to a room and it won't accept the invite (due to the
|
||||||
|
# conditions above), report it to the management room. Defaults to disabled (no
|
||||||
|
# reporting).
|
||||||
|
recordIgnoredInvites: false
|
||||||
|
|
||||||
|
# The room ID where people can use the bot. The bot has no access controls, so
|
||||||
|
# anyone in this room can use the bot - secure your room!
|
||||||
|
# This should be a room alias or room ID - not a matrix.to URL.
|
||||||
|
# Note: Mjolnir is fairly verbose - expect a lot of messages from it.
|
||||||
|
managementRoom: "#moderators:localhost:9999"
|
||||||
|
|
||||||
|
# Set to false to make the management room a bit quieter.
|
||||||
|
verboseLogging: true
|
||||||
|
|
||||||
|
# The log level for the logs themselves. One of DEBUG, INFO, WARN, and ERROR.
|
||||||
|
# This should be at INFO or DEBUG in order to get support for Mjolnir problems.
|
||||||
|
logLevel: "DEBUG"
|
||||||
|
|
||||||
|
# Set to false to disable synchronizing the ban lists on startup. If true, this
|
||||||
|
# is the same as running !mjolnir sync immediately after startup.
|
||||||
|
syncOnStartup: true
|
||||||
|
|
||||||
|
# Set to false to prevent Mjolnir from checking its permissions on startup. This
|
||||||
|
# is recommended to be left as "true" to catch room permission problems (state
|
||||||
|
# resets, etc) before Mjolnir is needed.
|
||||||
|
verifyPermissionsOnStartup: true
|
||||||
|
|
||||||
|
# If true, Mjolnir won't actually ban users or apply server ACLs, but will
|
||||||
|
# think it has. This is useful to see what it does in a scenario where the
|
||||||
|
# bot might not be trusted fully, yet. Default false (do bans/ACLs).
|
||||||
|
noop: false
|
||||||
|
|
||||||
|
# Set to true to use /joined_members instead of /state to figure out who is
|
||||||
|
# in the room. Using /state is preferred because it means that users are
|
||||||
|
# banned when they are invited instead of just when they join, though if your
|
||||||
|
# server struggles with /state requests then set this to true.
|
||||||
|
fasterMembershipChecks: false
|
||||||
|
|
||||||
|
# A case-insensitive list of ban reasons to automatically redact a user's
|
||||||
|
# messages for. Typically this is useful to avoid having to type two commands
|
||||||
|
# to the bot. Use asterisks to represent globs (ie: "spam*testing" would match
|
||||||
|
# "spam for testing" as well as "spamtesting").
|
||||||
|
automaticallyRedactForReasons:
|
||||||
|
- "spam"
|
||||||
|
- "advertising"
|
||||||
|
|
||||||
|
# A list of rooms to protect (matrix.to URLs)
|
||||||
|
protectedRooms: []
|
||||||
|
|
||||||
|
# Set this option to true to protect every room the bot is joined to. Note that
|
||||||
|
# this effectively makes the protectedRooms and associated commands useless because
|
||||||
|
# the bot by nature must be joined to the room to protect it.
|
||||||
|
#
|
||||||
|
# Note: the management room is *excluded* from this condition. Add it to the
|
||||||
|
# protected rooms to protect it.
|
||||||
|
#
|
||||||
|
# Note: ban list rooms the bot is watching but didn't create will not be protected.
|
||||||
|
# Manually add these rooms to the protected rooms list if you want them protected.
|
||||||
|
protectAllJoinedRooms: false
|
||||||
|
|
||||||
|
# Misc options for command handling and commands
|
||||||
|
commands:
|
||||||
|
# If true, Mjolnir will respond to commands like !help and !ban instead of
|
||||||
|
# requiring a prefix. This is useful if Mjolnir is the only bot running in
|
||||||
|
# your management room.
|
||||||
|
#
|
||||||
|
# Note that Mjolnir can be pinged by display name instead of having to use
|
||||||
|
# the !mjolnir prefix. For example, "my_moderator_bot: ban @spammer:example.org"
|
||||||
|
# will ban a user.
|
||||||
|
allowNoPrefix: false
|
||||||
|
|
||||||
|
# In addition to the bot's display name, !mjolnir, and optionally no prefix
|
||||||
|
# above, the bot will respond to these names. The items here can be used either
|
||||||
|
# as display names or prefixed with exclamation points.
|
||||||
|
additionalPrefixes:
|
||||||
|
- "mjolnir_bot"
|
||||||
|
|
||||||
|
# If true, ban commands that use wildcard characters require confirmation with
|
||||||
|
# an extra `--force` argument
|
||||||
|
confirmWildcardBan: true
|
||||||
|
|
||||||
|
# Configuration specific to certain toggleable protections
|
||||||
|
protections:
|
||||||
|
# Configuration for the wordlist plugin, which can ban users based if they say certain
|
||||||
|
# blocked words shortly after joining.
|
||||||
|
wordlist:
|
||||||
|
# A list of words which should be monitored by the bot. These will match if any part
|
||||||
|
# of the word is present in the message in any case. e.g. "hello" also matches
|
||||||
|
# "HEllO". Additionally, regular expressions can be used.
|
||||||
|
words:
|
||||||
|
- "CaSe"
|
||||||
|
- "InSeNsAtIve"
|
||||||
|
- "WoRd"
|
||||||
|
- "LiSt"
|
||||||
|
|
||||||
|
# How long after a user joins the server should the bot monitor their messages. After
|
||||||
|
# this time, users can say words from the wordlist without being banned automatically.
|
||||||
|
# Set to zero to disable (users will always be banned if they say a bad word)
|
||||||
|
minutesBeforeTrusting: 20
|
||||||
|
|
||||||
|
# Options for monitoring the health of the bot
|
||||||
|
health:
|
||||||
|
# healthz options. These options are best for use in container environments
|
||||||
|
# like Kubernetes to detect how healthy the service is. The bot will report
|
||||||
|
# that it is unhealthy until it is able to process user requests. Typically
|
||||||
|
# this means that it'll flag itself as unhealthy for a number of minutes
|
||||||
|
# before saying "Now monitoring rooms" and flagging itself healthy.
|
||||||
|
#
|
||||||
|
# Health is flagged through HTTP status codes, defined below.
|
||||||
|
healthz:
|
||||||
|
# Whether the healthz integration should be enabled (default false)
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# The port to expose the webserver on. Defaults to 8080.
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# The address to listen for requests on. Defaults to all addresses.
|
||||||
|
address: "0.0.0.0"
|
||||||
|
|
||||||
|
# The path to expose the monitoring endpoint at. Defaults to `/healthz`
|
||||||
|
endpoint: "/healthz"
|
||||||
|
|
||||||
|
# The HTTP status code which reports that the bot is healthy/ready to
|
||||||
|
# process requests. Typically this should not be changed. Defaults to
|
||||||
|
# 200.
|
||||||
|
healthyStatus: 200
|
||||||
|
|
||||||
|
# The HTTP status code which reports that the bot is not healthy/ready.
|
||||||
|
# Defaults to 418.
|
||||||
|
unhealthyStatus: 418
|
63
mx-tester.yml
Normal file
63
mx-tester.yml
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
name: Mjolnir Testing
|
||||||
|
modules:
|
||||||
|
- name: synapse_antispam
|
||||||
|
build:
|
||||||
|
- cp -r synapse_antispam $MX_TEST_SYNAPSE_DIR
|
||||||
|
run:
|
||||||
|
- yarn test-integration
|
||||||
|
homeserver_config:
|
||||||
|
server_name: localhost:9999
|
||||||
|
pid_file: /data/homeserver.pid
|
||||||
|
public_baseurl: http://localhost:9999
|
||||||
|
listeners:
|
||||||
|
- port: 9999
|
||||||
|
tls: false
|
||||||
|
type: http
|
||||||
|
x_forwarded: true
|
||||||
|
resources:
|
||||||
|
- names: [client, federation]
|
||||||
|
compress: false
|
||||||
|
database:
|
||||||
|
name: sqlite3
|
||||||
|
args:
|
||||||
|
database: /data/homeserver.db
|
||||||
|
media_store_path: "/data/media_store"
|
||||||
|
enable_registration: true
|
||||||
|
report_stats: false
|
||||||
|
registration_shared_secret: "REGISTRATION_SHARED_SECRET"
|
||||||
|
macaroon_secret_key: "MACROON_SECRET_KEY"
|
||||||
|
signing_key_path: "/data/localhost:9999.signing.key"
|
||||||
|
trusted_key_servers:
|
||||||
|
- server_name: "matrix.org"
|
||||||
|
suppress_key_server_warning: true
|
||||||
|
|
||||||
|
rc_message:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
||||||
|
|
||||||
|
rc_registration:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
||||||
|
|
||||||
|
rc_login:
|
||||||
|
address:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
||||||
|
account:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
||||||
|
failed_attempts:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
||||||
|
|
||||||
|
rc_admin_redaction:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
||||||
|
|
||||||
|
rc_joins:
|
||||||
|
local:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
||||||
|
remote:
|
||||||
|
per_second: 10000
|
||||||
|
burst_count: 10000
|
11
package.json
11
package.json
@ -11,14 +11,21 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "tslint --project ./tsconfig.json -t stylish",
|
"lint": "tslint --project ./tsconfig.json -t stylish",
|
||||||
"start:dev": "yarn build && node lib/index.js",
|
"start:dev": "yarn build && node lib/index.js",
|
||||||
"test": "ts-mocha --project ./tsconfig.json test/**/*.ts"
|
"test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts",
|
||||||
|
"test:integration": "NODE_ENV=harness ts-mocha --require test/integration/fixtures.ts --project ./tsconfig.json test/integration/**/*Test.ts",
|
||||||
|
"test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/axios": "^0.14.0",
|
||||||
|
"@types/crypto-js": "^4.0.2",
|
||||||
"@types/mocha": "^9.0.0",
|
"@types/mocha": "^9.0.0",
|
||||||
"@types/node": "11",
|
"@types/node": "^16.7.10",
|
||||||
|
"axios": "^0.21.4",
|
||||||
|
"crypto-js": "^4.1.1",
|
||||||
"expect": "^27.0.6",
|
"expect": "^27.0.6",
|
||||||
"mocha": "^9.0.1",
|
"mocha": "^9.0.1",
|
||||||
"ts-mocha": "^8.0.0",
|
"ts-mocha": "^8.0.0",
|
||||||
|
"ts-node": "^10.2.1",
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
"typescript": "^4.3.5"
|
"typescript": "^4.3.5"
|
||||||
},
|
},
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
LogService,
|
LogService,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixGlob,
|
MatrixGlob,
|
||||||
|
MembershipEvent,
|
||||||
Permalinks,
|
Permalinks,
|
||||||
UserID
|
UserID
|
||||||
} from "matrix-bot-sdk";
|
} from "matrix-bot-sdk";
|
||||||
@ -37,6 +38,7 @@ import { PROTECTIONS } from "./protections/protections";
|
|||||||
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
|
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
|
||||||
import { Healthz } from "./health/healthz";
|
import { Healthz } from "./health/healthz";
|
||||||
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
|
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
|
||||||
|
import * as htmlEscape from "escape-html";
|
||||||
|
|
||||||
export const STATE_NOT_STARTED = "not_started";
|
export const STATE_NOT_STARTED = "not_started";
|
||||||
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
||||||
@ -69,6 +71,84 @@ export class Mjolnir {
|
|||||||
private explicitlyProtectedRoomIds: string[] = [];
|
private explicitlyProtectedRoomIds: string[] = [];
|
||||||
private knownUnprotectedRooms: string[] = [];
|
private knownUnprotectedRooms: string[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a listener to the client that will automatically accept invitations.
|
||||||
|
* @param {MatrixClient} client
|
||||||
|
* @param options By default accepts invites from anyone.
|
||||||
|
* @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true.
|
||||||
|
* @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`.
|
||||||
|
* @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`.
|
||||||
|
* @param {string} options.acceptInvitesFromGroup A group of users to accept invites from, ignores invites form users not in this group.
|
||||||
|
*/
|
||||||
|
private static addJoinOnInviteListener(client: MatrixClient, options) {
|
||||||
|
client.on("room.invite", async (roomId: string, inviteEvent: any) => {
|
||||||
|
const membershipEvent = new MembershipEvent(inviteEvent);
|
||||||
|
|
||||||
|
const reportInvite = async () => {
|
||||||
|
if (!options.recordIgnoredInvites) return; // Nothing to do
|
||||||
|
|
||||||
|
await client.sendMessage(options.managementRoom, {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `${membershipEvent.sender} has invited me to ${roomId} but the config prevents me from accepting the invitation. `
|
||||||
|
+ `If you would like this room protected, use "!mjolnir rooms add ${roomId}" so I can accept the invite.`,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `${htmlEscape(membershipEvent.sender)} has invited me to ${htmlEscape(roomId)} but the config prevents me from `
|
||||||
|
+ `accepting the invitation. If you would like this room protected, use <code>!mjolnir rooms add ${htmlEscape(roomId)}</code> `
|
||||||
|
+ `so I can accept the invite.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.autojoinOnlyIfManager) {
|
||||||
|
const managers = await client.getJoinedRoomMembers(options.managementRoom);
|
||||||
|
if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
|
||||||
|
} else {
|
||||||
|
const groupMembers = await client.unstableApis.getGroupUsers(options.acceptInvitesFromGroup);
|
||||||
|
const userIds = groupMembers.map(m => m.user_id);
|
||||||
|
if (!userIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.joinRoom(roomId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Mjolnir instance from a client and the options in the configuration file, ready to be started.
|
||||||
|
* @param {MatrixClient} client The client for Mjolnir to use.
|
||||||
|
* @returns A new Mjolnir instance that can be started without further setup.
|
||||||
|
*/
|
||||||
|
static async setupMjolnirFromConfig(client: MatrixClient): Promise<Mjolnir> {
|
||||||
|
Mjolnir.addJoinOnInviteListener(client, config);
|
||||||
|
|
||||||
|
const banLists: BanList[] = [];
|
||||||
|
const protectedRooms: { [roomId: string]: string } = {};
|
||||||
|
const joinedRooms = await client.getJoinedRooms();
|
||||||
|
// Ensure we're also joined to the rooms we're protecting
|
||||||
|
LogService.info("index", "Resolving protected rooms...");
|
||||||
|
for (const roomRef of config.protectedRooms) {
|
||||||
|
const permalink = Permalinks.parseUrl(roomRef);
|
||||||
|
if (!permalink.roomIdOrAlias) continue;
|
||||||
|
|
||||||
|
let roomId = await client.resolveRoom(permalink.roomIdOrAlias);
|
||||||
|
if (!joinedRooms.includes(roomId)) {
|
||||||
|
roomId = await client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
|
||||||
|
}
|
||||||
|
|
||||||
|
protectedRooms[roomId] = roomRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we're also in the management room
|
||||||
|
LogService.info("index", "Resolving management room...");
|
||||||
|
const managementRoomId = await client.resolveRoom(config.managementRoom);
|
||||||
|
if (!joinedRooms.includes(managementRoomId)) {
|
||||||
|
config.managementRoom = await client.joinRoom(config.managementRoom);
|
||||||
|
} else {
|
||||||
|
config.managementRoom = managementRoomId;
|
||||||
|
}
|
||||||
|
await logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
|
||||||
|
|
||||||
|
return new Mjolnir(client, protectedRooms, banLists);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly client: MatrixClient,
|
public readonly client: MatrixClient,
|
||||||
public readonly protectedRooms: { [roomId: string]: string },
|
public readonly protectedRooms: { [roomId: string]: string },
|
||||||
@ -209,6 +289,14 @@ export class Mjolnir {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Mjolnir from syncing and processing commands.
|
||||||
|
*/
|
||||||
|
public stop() {
|
||||||
|
LogService.info("Mjolnir", "Stopping Mjolnir...");
|
||||||
|
this.client.stop();
|
||||||
|
}
|
||||||
|
|
||||||
public async addProtectedRoom(roomId: string) {
|
public async addProtectedRoom(roomId: string) {
|
||||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||||
|
|
||||||
|
69
src/index.ts
69
src/index.ts
@ -20,17 +20,13 @@ import {
|
|||||||
LogService,
|
LogService,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
PantalaimonClient,
|
PantalaimonClient,
|
||||||
Permalinks,
|
|
||||||
RichConsoleLogger,
|
RichConsoleLogger,
|
||||||
SimpleFsStorageProvider
|
SimpleFsStorageProvider
|
||||||
} from "matrix-bot-sdk";
|
} from "matrix-bot-sdk";
|
||||||
import config from "./config";
|
import config from "./config";
|
||||||
import BanList from "./models/BanList";
|
|
||||||
import { Mjolnir } from "./Mjolnir";
|
|
||||||
import { logMessage } from "./LogProxy";
|
import { logMessage } from "./LogProxy";
|
||||||
import { MembershipEvent } from "matrix-bot-sdk/lib/models/events/MembershipEvent";
|
|
||||||
import * as htmlEscape from "escape-html";
|
|
||||||
import { Healthz } from "./health/healthz";
|
import { Healthz } from "./health/healthz";
|
||||||
|
import { Mjolnir } from "./Mjolnir";
|
||||||
|
|
||||||
config.RUNTIME = {};
|
config.RUNTIME = {};
|
||||||
|
|
||||||
@ -45,7 +41,8 @@ if (config.health.healthz.enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
const storage = new SimpleFsStorageProvider(path.join(config.dataPath, "bot.json"));
|
const storagePath = path.isAbsolute(config.dataPath) ? config.dataPath : path.join(__dirname, '../', config.dataPath);
|
||||||
|
const storage = new SimpleFsStorageProvider(path.join(storagePath, "bot.json"));
|
||||||
|
|
||||||
let client: MatrixClient;
|
let client: MatrixClient;
|
||||||
if (config.pantalaimon.use) {
|
if (config.pantalaimon.use) {
|
||||||
@ -57,65 +54,7 @@ if (config.health.healthz.enabled) {
|
|||||||
|
|
||||||
config.RUNTIME.client = client;
|
config.RUNTIME.client = client;
|
||||||
|
|
||||||
client.on("room.invite", async (roomId: string, inviteEvent: any) => {
|
let bot = await Mjolnir.setupMjolnirFromConfig(client);
|
||||||
const membershipEvent = new MembershipEvent(inviteEvent);
|
|
||||||
|
|
||||||
const reportInvite = async () => {
|
|
||||||
if (!config.recordIgnoredInvites) return; // Nothing to do
|
|
||||||
|
|
||||||
await client.sendMessage(config.managementRoom, {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: `${membershipEvent.sender} has invited me to ${roomId} but the config prevents me from accepting the invitation. `
|
|
||||||
+ `If you would like this room protected, use "!mjolnir rooms add ${roomId}" so I can accept the invite.`,
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `${htmlEscape(membershipEvent.sender)} has invited me to ${htmlEscape(roomId)} but the config prevents me from `
|
|
||||||
+ `accepting the invitation. If you would like this room protected, use <code>!mjolnir rooms add ${htmlEscape(roomId)}</code> `
|
|
||||||
+ `so I can accept the invite.`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.autojoinOnlyIfManager) {
|
|
||||||
const managers = await client.getJoinedRoomMembers(config.managementRoom);
|
|
||||||
if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
|
|
||||||
} else {
|
|
||||||
const groupMembers = await client.unstableApis.getGroupUsers(config.acceptInvitesFromGroup);
|
|
||||||
const userIds = groupMembers.map(m => m.user_id);
|
|
||||||
if (!userIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.joinRoom(roomId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const banLists: BanList[] = [];
|
|
||||||
const protectedRooms: { [roomId: string]: string } = {};
|
|
||||||
|
|
||||||
const joinedRooms = await client.getJoinedRooms();
|
|
||||||
|
|
||||||
// Ensure we're also joined to the rooms we're protecting
|
|
||||||
LogService.info("index", "Resolving protected rooms...");
|
|
||||||
for (const roomRef of config.protectedRooms) {
|
|
||||||
const permalink = Permalinks.parseUrl(roomRef);
|
|
||||||
if (!permalink.roomIdOrAlias) continue;
|
|
||||||
|
|
||||||
let roomId = await client.resolveRoom(permalink.roomIdOrAlias);
|
|
||||||
if (!joinedRooms.includes(roomId)) {
|
|
||||||
roomId = await client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
|
|
||||||
}
|
|
||||||
|
|
||||||
protectedRooms[roomId] = roomRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we're also in the management room
|
|
||||||
LogService.info("index", "Resolving management room...");
|
|
||||||
const managementRoomId = await client.resolveRoom(config.managementRoom);
|
|
||||||
if (!joinedRooms.includes(managementRoomId)) {
|
|
||||||
config.managementRoom = await client.joinRoom(config.managementRoom);
|
|
||||||
} else {
|
|
||||||
config.managementRoom = managementRoomId;
|
|
||||||
}
|
|
||||||
await logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
|
|
||||||
|
|
||||||
const bot = new Mjolnir(client, protectedRooms, banLists);
|
|
||||||
await bot.start();
|
await bot.start();
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
logMessage(LogLevel.ERROR, "index", err);
|
logMessage(LogLevel.ERROR, "index", err);
|
||||||
|
77
test/integration/clientHelper.ts
Normal file
77
test/integration/clientHelper.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { HmacSHA1 } from "crypto-js";
|
||||||
|
import { LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk";
|
||||||
|
import config from "../../src/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a user using the synapse admin api that requires the use of a registration secret rather than an admin user.
|
||||||
|
* This should only be used by test code and should not be included from any file in the source directory
|
||||||
|
* either by explicit imports or copy pasting.
|
||||||
|
* @param username The username to give the user.
|
||||||
|
* @param displayname The displayname to give the user.
|
||||||
|
* @param password The password to use.
|
||||||
|
* @param admin True to make the user an admin, false otherwise.
|
||||||
|
* @returns The response from synapse.
|
||||||
|
*/
|
||||||
|
export async function registerUser(username: string, displayname: string, password: string, admin: boolean) {
|
||||||
|
let registerUrl = `${config.homeserverUrl}/_synapse/admin/v1/register`
|
||||||
|
let { data } = await axios.get(registerUrl);
|
||||||
|
let nonce = data.nonce!;
|
||||||
|
let mac = HmacSHA1(`${nonce}\0${username}\0${password}\0${admin ? 'admin' : 'notadmin'}`, 'REGISTRATION_SHARED_SECRET');
|
||||||
|
return await axios.post(registerUrl, {
|
||||||
|
nonce,
|
||||||
|
username,
|
||||||
|
displayname,
|
||||||
|
password,
|
||||||
|
admin,
|
||||||
|
mac: mac.toString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new test user with a unique username.
|
||||||
|
* @param isAdmin Whether to make the new user an admin.
|
||||||
|
* @returns A string that is the username and password of a new user.
|
||||||
|
*/
|
||||||
|
export async function registerNewTestUser(isAdmin: boolean) {
|
||||||
|
let isUserValid = false;
|
||||||
|
let username;
|
||||||
|
do {
|
||||||
|
username = `mjolnir-test-user-${Math.floor(Math.random() * 100000)}`
|
||||||
|
await registerUser(username, username, username, isAdmin).then(_ => isUserValid = true).catch(e => {
|
||||||
|
if (e.isAxiosError && e?.response?.data?.errcode === 'M_USER_IN_USE') {
|
||||||
|
LogService.debug("test/clientHelper", `${username} already registered, trying another`);
|
||||||
|
false // continue and try again
|
||||||
|
} else {
|
||||||
|
console.error(`failed to register user ${e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} while (!isUserValid);
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a unique test user and returns a `MatrixClient` logged in and ready to use.
|
||||||
|
* @param isAdmin Whether to make the user an admin.
|
||||||
|
* @returns A new `MatrixClient` session for a unique test user.
|
||||||
|
*/
|
||||||
|
export async function newTestUser(isAdmin : boolean = false): Promise<MatrixClient> {
|
||||||
|
const username = await registerNewTestUser(isAdmin);
|
||||||
|
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
|
||||||
|
return await pantalaimon.createClientWithCredentials(username, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to create an event listener for m.notice msgtype m.room.messages.
|
||||||
|
* @param targetRoomdId The roomId to listen into.
|
||||||
|
* @param cb The callback when a m.notice event is found in targetRoomId.
|
||||||
|
* @returns The callback to pass to `MatrixClient.on('room.message', cb)`
|
||||||
|
*/
|
||||||
|
export function noticeListener(targetRoomdId: string, cb) {
|
||||||
|
return (roomId, event) => {
|
||||||
|
if (roomId !== targetRoomdId) return;
|
||||||
|
if (event?.content?.msgtype !== "m.notice") return;
|
||||||
|
cb(event);
|
||||||
|
}
|
||||||
|
}
|
28
test/integration/fixtures.ts
Normal file
28
test/integration/fixtures.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import config from "../../src/config";
|
||||||
|
import { makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils";
|
||||||
|
|
||||||
|
// When Mjolnir starts (src/index.ts) it clobbers the config by resolving the management room
|
||||||
|
// alias specified in the config (config.managementRoom) and overwriting that with the room ID.
|
||||||
|
// Unfortunately every piece of code importing that config imports the same instance, including
|
||||||
|
// testing code, which is problematic when we want to create a fresh management room for each test.
|
||||||
|
// So there is some code in here to "undo" the mutation after we stop Mjolnir syncing.
|
||||||
|
export const mochaHooks = {
|
||||||
|
beforeEach: [
|
||||||
|
async function() {
|
||||||
|
this.managementRoomAlias = config.managementRoom
|
||||||
|
this.mjolnir = await makeMjolnir()
|
||||||
|
this.mjolnir.start()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
afterEach: [
|
||||||
|
async function() {
|
||||||
|
await this.mjolnir.stop();
|
||||||
|
// Mjolnir resolves config.managementRoom and overwrites it, so we undo this here
|
||||||
|
// after stopping Mjolnir for the next time we setup a Mjolnir and a management room.
|
||||||
|
let managementRoomId = config.managementRoom;
|
||||||
|
config.managementRoom = this.managementRoomAlias;
|
||||||
|
// remove alias from management room and leave it.
|
||||||
|
await teardownManagementRoom(this.mjolnir.client, managementRoomId, this.managementRoomAlias);
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
30
test/integration/helloTest.ts
Normal file
30
test/integration/helloTest.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import config from "../../src/config";
|
||||||
|
import { newTestUser, noticeListener } from "./clientHelper"
|
||||||
|
|
||||||
|
describe("help command", () => {
|
||||||
|
let client;
|
||||||
|
before(async function () {
|
||||||
|
client = await newTestUser(true);
|
||||||
|
await client.start();
|
||||||
|
})
|
||||||
|
it('Mjolnir responded to !mjolnir help', async function() {
|
||||||
|
this.timeout(30000);
|
||||||
|
console.log(`management room ${config.managementRoom}`);
|
||||||
|
// send a messgage
|
||||||
|
await client.joinRoom(config.managementRoom);
|
||||||
|
// listener for getting the event reply
|
||||||
|
let reply = new Promise((resolve, reject) => {
|
||||||
|
client.on('room.message', noticeListener(config.managementRoom, (event) => {
|
||||||
|
if (event.content.body.includes("Print status information")) {
|
||||||
|
resolve(event);
|
||||||
|
}
|
||||||
|
}))});
|
||||||
|
// check we get one back
|
||||||
|
console.log(config);
|
||||||
|
await client.sendMessage(config.managementRoom, {msgtype: "m.text", body: "!mjolnir help"})
|
||||||
|
await reply
|
||||||
|
})
|
||||||
|
after(async function () {
|
||||||
|
await client.stop();
|
||||||
|
})
|
||||||
|
})
|
10
test/integration/manualLaunchScript.ts
Normal file
10
test/integration/manualLaunchScript.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* This file is used to launch mjolnir for manual testing, creating a user and management room automatically if it doesn't already exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { makeMjolnir } from "./mjolnirSetupUtils";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let mjolnir = await makeMjolnir();
|
||||||
|
await mjolnir.start()
|
||||||
|
})();
|
78
test/integration/mjolnirSetupUtils.ts
Normal file
78
test/integration/mjolnirSetupUtils.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
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 {
|
||||||
|
MatrixClient,
|
||||||
|
PantalaimonClient,
|
||||||
|
MemoryStorageProvider,
|
||||||
|
LogService,
|
||||||
|
LogLevel,
|
||||||
|
RichConsoleLogger
|
||||||
|
} from "matrix-bot-sdk";
|
||||||
|
import { Mjolnir} from '../../src/Mjolnir';
|
||||||
|
import config from "../../src/config";
|
||||||
|
import { registerUser } from "./clientHelper";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that a room exists with the alias, if it does not exist we create it.
|
||||||
|
* @param client The MatrixClient to use to resolve or create the aliased room.
|
||||||
|
* @param alias The alias of the room.
|
||||||
|
* @returns The room ID of the aliased room.
|
||||||
|
*/
|
||||||
|
export async function ensureAliasedRoomExists(client: MatrixClient, alias: string): Promise<string> {
|
||||||
|
return await client.resolveRoom(alias)
|
||||||
|
.catch(async e => {
|
||||||
|
if (e?.body?.errcode === 'M_NOT_FOUND') {
|
||||||
|
console.info(`${alias} hasn't been created yet, so we're making it now.`)
|
||||||
|
let roomId = await client.createRoom();
|
||||||
|
await client.createRoomAlias(config.managementRoom, roomId);
|
||||||
|
return roomId
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureMjolnir() {
|
||||||
|
await registerUser('mjolnir', 'mjolnir', 'mjolnir', true).catch(e => {
|
||||||
|
if (e.isAxiosError && e.response.data.errcode === 'M_USER_IN_USE') {
|
||||||
|
console.log('mjolnir already registered, skipping');
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function makeMjolnir() {
|
||||||
|
await configureMjolnir();
|
||||||
|
LogService.setLogger(new RichConsoleLogger());
|
||||||
|
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
|
||||||
|
LogService.info("test/mjolnirSetupUtils", "Starting bot...");
|
||||||
|
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
|
||||||
|
const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password);
|
||||||
|
await ensureAliasedRoomExists(client, config.managementRoom);
|
||||||
|
return await Mjolnir.setupMjolnirFromConfig(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the alias and leave the room, can't be implicitly provided from the config because Mjolnir currently mutates it.
|
||||||
|
* @param client The client to use to leave the room.
|
||||||
|
* @param roomId The roomId of the room to leave.
|
||||||
|
* @param alias The alias to remove from the room.
|
||||||
|
*/
|
||||||
|
export async function teardownManagementRoom(client: MatrixClient, roomId: string, alias: string) {
|
||||||
|
await client.deleteRoomAlias(alias);
|
||||||
|
await client.leaveRoom(roomId);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user