Skip to content

Commit

Permalink
Merge pull request #1301 from matrix-org/hs/add-support-for-per-room-…
Browse files Browse the repository at this point in the history
…config

Add support for setting the pastebin line limit in room state
  • Loading branch information
Half-Shot authored May 7, 2021
2 parents 67cc2bc + 35b5a10 commit 98aec94
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 10 deletions.
1 change: 1 addition & 0 deletions changelog.d/1301.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for specifying the paste bin limit in room state with the `org.matrix.appservice-irc.config` event type.
10 changes: 10 additions & 0 deletions config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,16 @@ ircService:
# 'matrix.org': admin
# '@fibble:matrix.org': admin

# Allow room moderators to adjust the configuration of the bridge via room state.
# See docs/room_commands.md
# Optional: Off by default
perRoomConfig:
# Should the bridge use per-room configuration state. If false, the state
# events will be ignored.
enabled: false
# The maximum number that can be set for the `lineLimit` configuration option
# lineLimitMax: 5

# Options here are generally only applicable to large-scale bridges and may have
# consequences greater than other options in this configuration file.
advanced:
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- [Usage](./usage.md)
- [Admin Room](./admin_room.md)
- [Room Commands](./room_commands.md)
- [Room Configuration](./room_configuration.md)
- [IRC Modes](./irc_modes.md)
- [Setup](./bridge_setup.md)
- [Administrators Guide](./administrators_guide.md)
Expand Down
39 changes: 39 additions & 0 deletions docs/room_configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
Room Configuration
==================

You can now configure certain options on a per-room basis from within your Matrix client. Currently
this requires a bit of technical know-how as no clients expose an interface to changing the
bridge configuration.

## The `org.matrix.appservice-irc.config` event

The bridge allows room moderators to create a state event in the room to change the way the bridge
behaves in that room.

In Element you can modify the room state by:

- Opening the room you wish to configure.
- Typing `/devtools`.
- Click Explore Room State.
- Look for the `org.matrix.appservice-irc.config` event.
- You should be able to click Edit to edit the content, and then hit Send to adjust the config.

If an event does not exist yet, you can instead do:

- Typing `/devtools`.
- Click Send Custom Event.
- Click the Event button to change the type to a **State Event**.
- The event type must be `org.matrix.appservice-irc.config`
- The state key can be left blank.
- Enter the `Event Content` as a JSON object. The schema is described in the following section.
- You may now hit Send to apply the config.

## Configuration Options

### `lineLimit`

Type: `number`

This allows you to modify the minimum number of lines permitted in a room before the
message is pastebinned. The setting is analogous to the `lineLimit` config option in
the bridge config file.
7 changes: 7 additions & 0 deletions src/bridge/IrcBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { spawnMetricsWorker } from "../workers/MetricsWorker";
import { getBridgeVersion } from "../util/PackageInfo";
import { globalAgent as gAHTTP } from "http";
import { globalAgent as gAHTTPS } from "https";
import { RoomConfig } from "./RoomConfig";

const log = getLogger("IrcBridge");
const DEFAULT_PORT = 8090;
Expand All @@ -64,6 +65,7 @@ export class IrcBridge {
public readonly ircHandler: IrcHandler;
public readonly publicitySyncer: PublicitySyncer;
public readonly activityTracker: MatrixActivityTracker|null = null;
public readonly roomConfigs: RoomConfig;
private clientPool!: ClientPool; // This gets defined in the `run` function
private ircServers: IrcServer[] = [];
private memberListSyncers: {[domain: string]: MemberListSyncer} = {};
Expand Down Expand Up @@ -206,6 +208,7 @@ export class IrcBridge {
homeserverToken,
httpMaxSizeBytes: (this.config.advanced || { }).maxTxnSize || TXN_SIZE_DEFAULT,
});
this.roomConfigs = new RoomConfig(this.bridge, this.config.ircService.perRoomConfig);
}

public async onConfigChanged(newConfig: BridgeConfig) {
Expand Down Expand Up @@ -235,6 +238,7 @@ export class IrcBridge {
this.ircHandler.onConfigChanged(newConfig.ircHandler || {});
this.config.ircHandler = newConfig.ircHandler;
this.config.ircService.permissions = newConfig.ircService.permissions;
this.roomConfigs.config = newConfig.ircService.perRoomConfig;

const hasLoggingChanged = JSON.stringify(oldConfig.ircService.logging)
!== JSON.stringify(newConfig.ircService.logging);
Expand Down Expand Up @@ -942,6 +946,9 @@ export class IrcBridge {
else if (event.type === "m.room.topic" && event.state_key === "") {
await this.matrixHandler.onMessage(request, event as unknown as MatrixMessageEvent);
}
else if (event.type === RoomConfig.STATE_EVENT_TYPE && typeof event.state_key === 'string') {
this.roomConfigs.invalidateConfig(event.room_id, event.state_key);
}
else if (event.type === "m.room.member" && event.state_key) {
if (!event.content || !event.content.membership) {
return BridgeRequestErr.ERR_NOT_MAPPED;
Expand Down
3 changes: 2 additions & 1 deletion src/bridge/MatrixHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,8 @@ export class MatrixHandler {

// Generate an array of individual messages that would be sent
const potentialMessages = ircClient.getSplitMessages(ircRoom.channel, ircAction.text);
const lineLimit = ircRoom.server.getLineLimit();
const roomLineLimit = await this.ircBridge.roomConfigs.getLineLimit(event.room_id, ircRoom);
const lineLimit = roomLineLimit === null ? ircRoom.server.getLineLimit() : roomLineLimit;

if (potentialMessages.length <= lineLimit) {
await this.ircBridge.sendIrcAction(ircRoom, ircClient, ircAction);
Expand Down
92 changes: 92 additions & 0 deletions src/bridge/RoomConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Bridge } from "matrix-appservice-bridge";
import { IrcRoom } from "../models/IrcRoom";
import QuickLRU from "quick-lru";
import getLogger from "../logging";

interface RoomConfigContent {
lineLimit?: number;
}

export interface RoomConfigConfig {
enabled: boolean;
lineLimitMax?: number;
}

const MAX_CACHE_SIZE = 512;
const STATE_TIMEOUT_MS = 2000;

const log = getLogger("RoomConfig");
export class RoomConfig {
public static readonly STATE_EVENT_TYPE = 'org.matrix.appservice-irc.config';
private cache = new QuickLRU<string, RoomConfigContent|undefined>({maxSize: MAX_CACHE_SIZE});
constructor(private bridge: Bridge, public config?: RoomConfigConfig) { }

/**
* Fetch the state for the room, preferring a keyed state event over a global one.
* This request will time out after `STATE_TIMEOUT_MS` if the state could not be fetched in time.
* @param roomId The Matrix room ID
* @param ircRoom The IRC room we want the configuration for.
* @returns A content object containing the configuration, or null if the event was not found or the
* request timed out.
*/
private async getRoomState(roomId: string, ircRoom?: IrcRoom): Promise<RoomConfigContent|null> {
if (!this.config?.enabled) {
// If not enabled, always return null
return null;
}
const cacheKey = `${roomId}:${ircRoom?.getId() || 'global'}`;
let keyedConfig = this.cache.get(cacheKey);
if (keyedConfig) {
return keyedConfig;
}
const internalFunc = async () => {
const intent = this.bridge.getIntent();
keyedConfig = ircRoom &&
await intent.getStateEvent(roomId, RoomConfig.STATE_EVENT_TYPE, ircRoom.getId(), true);
if (!keyedConfig) {
// Fall back to an empty key
keyedConfig = await intent.getStateEvent(roomId, RoomConfig.STATE_EVENT_TYPE, '', true);
}
log.debug(
`Stored new config for ${cacheKey}: ${keyedConfig ? 'No config set' : JSON.stringify(keyedConfig)}`
);
this.cache.set(cacheKey, keyedConfig || undefined);
return keyedConfig as RoomConfigContent|null;
}
// We don't want to spend too long trying to fetch the state, so return null.
return Promise.race([
internalFunc(),
new Promise<null>(res => setTimeout(res, STATE_TIMEOUT_MS)),
// We *never* want this function to throw, as it's critical for the bridging of messages.
// Instead we return null for any errors.
]).catch(ex => {
log.warn(`Failed to fetch state for ${cacheKey}`, ex);
return null;
})
}

/**
* Invalidate the cache for a room.
* @param roomId The Matrix roomId
* @param stateKey The state event's key
*/
public invalidateConfig(roomId: string, stateKey = 'global') {
log.info(`Invalidating config for ${roomId}:${stateKey}`);
this.cache.delete(`${roomId}:${stateKey}`)
}

/**
* Get the per-room configuration for the paste bin limit for a room.
* @param roomId The Matrix roomId
* @param ircRoom The IRC roomId. Optional.
* @returns The number of lines required for a pastebin. `null` means no limit set in the room.
*/
public async getLineLimit(roomId: string, ircRoom?: IrcRoom) {
const roomState = await this.getRoomState(roomId, ircRoom);
if (typeof roomState?.lineLimit !== 'number' || roomState.lineLimit <= 0) {
// A missing line limit or an invalid one is considered invalid.
return null;
}
return Math.min(roomState.lineLimit, this.config?.lineLimitMax ?? roomState.lineLimit);
}
}
2 changes: 2 additions & 0 deletions src/config/BridgeConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IrcServerConfig } from "../irc/IrcServer";
import { LoggerConfig } from "../logging";
import { IrcHandlerConfig } from "../bridge/IrcHandler";
import { RoomConfigConfig } from "../bridge/RoomConfig";

export interface BridgeConfig {
matrixHandler: {
Expand Down Expand Up @@ -57,6 +58,7 @@ export interface BridgeConfig {
permissions?: {
[userIdOrDomain: string]: "admin";
};
perRoomConfig?: RoomConfigConfig;
};
sentry?: {
enabled: boolean;
Expand Down
33 changes: 24 additions & 9 deletions src/datastore/postgres/PgDataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ limitations under the License.

import { Pool } from "pg";

import { MatrixUser, MatrixRoom, RemoteRoom, RoomBridgeStoreEntry as Entry } from "matrix-appservice-bridge";
import {
MatrixUser,
MatrixRoom,
RemoteRoom,
RoomBridgeStoreEntry as Entry,
MatrixRoomData
} from "matrix-appservice-bridge";
import { DataStore, RoomOrigin, ChannelMappings, UserFeatures } from "../DataStore";
import { IrcRoom } from "../../models/IrcRoom";
import { IrcClientConfig } from "../../models/IrcClientConfig";
Expand All @@ -33,6 +39,16 @@ const log = getLogger("PgDatastore");

const FEATURE_CACHE_SIZE = 512;

interface RoomRecord {
room_id: string;
irc_domain: string;
irc_channel: string;
matrix_json?: MatrixRoomData;
irc_json: Record<string, unknown>;
type: string;
origin: RoomOrigin;
}

export class PgDataStore implements DataStore {
private serverMappings: {[domain: string]: IrcServer} = {};

Expand Down Expand Up @@ -130,7 +146,7 @@ export class PgDataStore implements DataStore {
await this.pgPool.query(statement, Object.values(parameters));
}

private static pgToRoomEntry(pgEntry: any): Entry {
private static pgToRoomEntry(pgEntry: RoomRecord): Entry {
return {
id: NeDBDataStore.createMappingId(pgEntry.room_id, pgEntry.irc_domain, pgEntry.irc_channel),
matrix: new MatrixRoom(pgEntry.room_id, pgEntry.matrix_json),
Expand Down Expand Up @@ -159,7 +175,7 @@ export class PgDataStore implements DataStore {
statement += " AND origin = $4";
params = params.concat(origin);
}
const pgEntry = await this.pgPool.query(statement, params);
const pgEntry = await this.pgPool.query<RoomRecord>(statement, params);
if (!pgEntry.rowCount) {
return null;
}
Expand Down Expand Up @@ -266,13 +282,12 @@ export class PgDataStore implements DataStore {
server: IrcServer,
channel: string,
origin: RoomOrigin | RoomOrigin[],
allowUnset: boolean,
): Promise<Entry[]> {
if (!Array.isArray(origin)) {
origin = [origin];
}
const inStatement = origin.map((_, i) => `\$${i + 3}`).join(", ");
const entries = await this.pgPool.query(
const entries = await this.pgPool.query<RoomRecord>(
`SELECT * FROM rooms WHERE irc_domain = $1 AND irc_channel = $2 AND origin IN (${inStatement})`,
[
server.domain,
Expand Down Expand Up @@ -494,15 +509,15 @@ export class PgDataStore implements DataStore {
log.debug(`Storing client configuration for ${userId}`);
// We need to make sure we have a matrix user in the store.
await this.pgPool.query("INSERT INTO matrix_users VALUES ($1, NULL) ON CONFLICT DO NOTHING", [userId]);
let password = undefined;
if (config.getPassword() && this.cryptoStore) {
password = this.cryptoStore.encrypt(config.getPassword()!);
let password = config.getPassword();
if (password && this.cryptoStore) {
password = this.cryptoStore.encrypt(password);
}
const parameters = {
user_id: userId,
domain: config.getDomain(),
// either use the decrypted password, or whatever is stored already.
password: password || config.getPassword()!,
password,
config: JSON.stringify(config.serialize(true)),
};
const statement = PgDataStore.BuildUpsertStatement(
Expand Down

0 comments on commit 98aec94

Please sign in to comment.