Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement MSC2346: Bridge information state event #333

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 76 additions & 31 deletions src/BridgedRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ import * as emoji from "node-emoji";
import { ISlackMessageEvent, ISlackEvent } from "./BaseSlackHandler";
import { WebClient } from "@slack/web-api";
import { ChatUpdateResponse,
ChatPostMessageResponse, ConversationsInfoResponse } from "./SlackResponses";
ChatPostMessageResponse, ConversationsInfoResponse, TeamInfoResponse } from "./SlackResponses";
import { RoomEntry, EventEntry, TeamEntry } from "./datastore/Models";
import { getBridgeStateKey, BridgeStateType, buildBridgeStateEvent } from "./RoomUtils";
import { tenRetriesInAboutThirtyMinutes } from "@slack/web-api/dist/retry-policies";
import e = require("express");

const log = Logging.get("BridgedRoom");

interface IBridgedRoomOpts {
export interface IBridgedRoomOpts {
matrix_room_id: string;
inbound_id: string;
slack_channel_name?: string;
Expand Down Expand Up @@ -115,29 +118,15 @@ export class BridgedRoom {
return this.slackType;
}

public static fromEntry(main: Main, entry: RoomEntry, team?: TeamEntry, botClient?: WebClient) {
return new BridgedRoom(main, {
inbound_id: entry.remote_id,
matrix_room_id: entry.matrix_id,
slack_channel_id: entry.remote.id,
slack_channel_name: entry.remote.name,
slack_team_id: entry.remote.slack_team_id,
slack_webhook_uri: entry.remote.webhook_uri,
puppet_owner: entry.remote.puppet_owner,
is_private: entry.remote.slack_private,
slack_type: entry.remote.slack_type,
}, team, botClient);
}

private matrixRoomId: string;
private inboundId: string;
private slackChannelName?: string;
private slackChannelId?: string;
private slackWebhookUri?: string;
private slackTeamId?: string;
private slackType?: string;
private isPrivate?: boolean;
private puppetOwner?: string;
protected matrixRoomId: string;
protected inboundId: string;
protected slackChannelName?: string;
protected slackChannelId?: string;
protected slackWebhookUri?: string;
protected slackTeamId?: string;
protected slackType?: string;
protected isPrivate?: boolean;
protected puppetOwner?: string;

// last activity time in epoch seconds
private slackATime?: number;
Expand All @@ -154,7 +143,7 @@ export class BridgedRoom {
*/
private dirty: boolean;

constructor(private main: Main, opts: IBridgedRoomOpts, private team?: TeamEntry, private botClient?: WebClient) {
constructor(protected main: Main, opts: IBridgedRoomOpts, private team?: TeamEntry, private botClient?: WebClient) {

this.MatrixRoomActive = true;
if (!opts.inbound_id) {
Expand Down Expand Up @@ -527,6 +516,62 @@ export class BridgedRoom {
this.botClient = slackClient;
}

public async syncBridgeState(force = false) {
if (!this.slackTeamId || !this.slackChannelId || this.isPrivate) {
return; // TODO: How to handle this?
}
const intent = await this.main.botIntent;
const key = getBridgeStateKey(this.slackTeamId, this.slackChannelId);
if (!force) {
// This throws if it can't find the event.
try {
await intent.getStateEvent(
this.MatrixRoomId,
BridgeStateType,
key,
);
return;
} catch (ex) {
if (ex.message !== "Event not found.") {
throw ex;
}
}
}

const { team } = await this.botClient!.team.info() as TeamInfoResponse;
let icon: string|undefined;
if (team.icon && !team.icon.image_default) {
const iconUrl = team.icon[Object.keys(team.icon).filter((s) => s !== "icon_default").sort().reverse()[0]];

const response = await rp({
encoding: null,
resolveWithFullResponse: true,
uri: iconUrl,
});
const content = response.body as Buffer;

icon = await intent.getClient().uploadContent(content, {
name: "workspace-icon.png",
type: response.headers["content-type"],
rawResponse: false,
onlyContentUri: true,
});
}

// No state, build one.
const event = buildBridgeStateEvent({
workspaceId: this.slackTeamId,
workspaceName: team.name,
workspaceUrl: `https://${team.domain}.slack.com`,
workspaceLogo: icon,
channelId: this.slackChannelId,
channelName: this.slackChannelName || undefined,
channelUrl: `https://app.slack.com/client/${this.slackTeamId}/${this.slackChannelId}`,
isActive: true,
});
await intent.sendStateEvent(this.MatrixRoomId, event.type, key, event.content);
}

private setValue<T>(key: string, value: T) {
const sneakyThis = this as any;
if (sneakyThis[key] === value) {
Expand Down Expand Up @@ -753,7 +798,7 @@ export class BridgedRoom {
if (replyToEvent === null) {
return null;
}
const intent = await this.getIntentForRoom(roomID);
const intent = await this.getIntentForRoom();
return await intent.getClient().fetchRoomEvent(roomID, replyToEvent.eventId);
}

Expand Down Expand Up @@ -807,21 +852,21 @@ export class BridgedRoom {
return parentEventId; // We have hit our depth limit, use this one.
}

const intent = await this.getIntentForRoom(message.room_id);
const nextEvent = await intent.getClient().fetchRoomEvent(message.room_id, parentEventId);
const intent = await this.getIntentForRoom();
const nextEvent = await intent.getClient().fetchRoomEvent(this.MatrixRoomId, parentEventId);

return this.findParentReply(nextEvent, depth++);
}

private async getIntentForRoom(roomID: string) {
protected async getIntentForRoom() {
if (this.intent) {
return this.intent;
}
// Ensure we get the right user.
if (!this.IsPrivate) {
this.intent = this.main.botIntent; // Non-private channels should have the bot inside.
}
const firstGhost = (await this.main.listGhostUsers(roomID))[0];
const firstGhost = (await this.main.listGhostUsers(this.MatrixRoomId))[0];
this.intent = this.main.getIntent(firstGhost);
return this.intent;
}
Expand Down
14 changes: 12 additions & 2 deletions src/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import PQueue from "p-queue";
import { UserAdminRoom } from "./rooms/UserAdminRoom";
import { TeamSyncer } from "./TeamSyncer";
import { AppService, AppServiceRegistration } from "matrix-appservice";
import { fromEntry } from "./rooms/Rooms";

const log = Logging.get("Main");

Expand Down Expand Up @@ -413,6 +414,11 @@ export class Main {
// doesn't currently have a client running.
await this.slackRtm.startTeamClientIfNotStarted(room.SlackTeamId);
}
try {
await room.syncBridgeState();
} catch (ex) {
log.warn("Failed to sync bridge state:", ex);
}
}

public getInboundUrlForRoom(room: BridgedRoom) {
Expand Down Expand Up @@ -901,12 +907,16 @@ export class Main {
if (!slackClient && !entry.remote.webhook_uri) { // Do not warn if this is a webhook.
log.warn(`${entry.remote.name} ${entry.remote.id} does not have a WebClient and will not be able to issue slack requests`);
}
const room = BridgedRoom.fromEntry(this, entry, teamEntry, slackClient || undefined);
const room = fromEntry(this, entry, teamEntry, slackClient || undefined);
await this.addBridgedRoom(room);
room.MatrixRoomActive = activeRoom;
if (!room.IsPrivate && activeRoom) {
// Only public rooms can be tracked.
this.stateStorage.trackRoom(entry.matrix_id);
try {
await this.stateStorage.trackRoom(entry.matrix_id);
} catch (ex) {
this.stateStorage.untrackRoom(entry.matrix_id);
}
}
}

Expand Down
43 changes: 43 additions & 0 deletions src/RoomUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export interface BuildBridgeStateEventOpts {
workspaceId: string;
workspaceName: string;
workspaceUrl: string;
workspaceLogo?: string;
channelId: string;
channelName?: string;
channelUrl: string;
creator?: string;
isActive: boolean;
}

export function getBridgeStateKey(workspaceId: string, channelId: string) {
return `org.matrix.matrix-appservice-slack://slack/${workspaceId}/${channelId}`;
}

export const BridgeStateType = "uk.half-shot.bridge";

export function buildBridgeStateEvent(opts: BuildBridgeStateEventOpts) {
// See https://github.com/matrix-org/matrix-doc/blob/hs/msc-bridge-inf/proposals/2346-bridge-info-state-event.md
return {
type: BridgeStateType,
content: {
...(opts.creator ? {creator: opts.creator } : {}),
status: opts.isActive ? "active" : "inactive",
protocol: {
id: "slack",
displayname: "Slack",
},
network: {
id: opts.workspaceId,
displayname: opts.workspaceName,
external_url: opts.workspaceUrl,
...(opts.workspaceLogo ? {avatar: opts.workspaceLogo } : {}),
},
channel: {
id: opts.channelId,
displayname: opts.channelName,
external_url: opts.channelUrl,
},
},
};
}
3 changes: 2 additions & 1 deletion src/SlackEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export class SlackEventHandler extends BaseSlackHandler {
* to events in order to handle them.
*/
protected static SUPPORTED_EVENTS: string[] = ["message", "reaction_added", "reaction_removed",
"team_domain_change", "channel_rename", "user_typing"];
"team_domain_change", "channel_rename", "user_typing",
"channel_created", "channel_deleted", "user_change", "team_join"];
constructor(main: Main) {
super(main);
}
Expand Down
1 change: 1 addition & 0 deletions src/SlackRTMHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export class SlackRTMHandler extends SlackEventHandler {
await this.main.datastore.upsertRoom(room);
} else if (!room) {
log.warn(`No room found for ${event.channel} and not sure how to create one`);
return;
}
return this.handleMessageEvent(event, puppet.teamId);
}
Expand Down
9 changes: 9 additions & 0 deletions src/SlackResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export interface TeamInfoResponse extends WebAPICallResult {
id: string;
name: string;
domain: string;
icon: {
image_36?: string;
image_44?: string;
image_68?: string;
image_88?: string;
image_102?: string;
image_123?: string;
image_default: boolean;
}
};
}

Expand Down
50 changes: 50 additions & 0 deletions src/rooms/DMRoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { BridgedRoom, IBridgedRoomOpts } from "../BridgedRoom";
import { Main } from "../Main";
import { TeamEntry } from "../datastore/Models";
import { WebClient } from "@slack/web-api";
import { ISlackMessageEvent } from "../BaseSlackHandler";
import { ConversationsMembersResponse } from "../SlackResponses";
import { Logging } from "matrix-appservice-bridge";

const log = Logging.get("DMRoom");

/**
* The DM room class is used to implement custom logic for
* "im" and "mpim" rooms.
*/
export class DMRoom extends BridgedRoom {
constructor(main: Main, opts: IBridgedRoomOpts, team: TeamEntry, botClient: WebClient) {
super(main, opts, team, botClient);
}

public async onSlackMessage(message: ISlackMessageEvent, content?: Buffer) {
await super.onSlackMessage(message, content);

// Check if the recipient is joined to the room.
const cli = await this.main.clientFactory.getClientForUser(this.SlackTeamId!, this.puppetOwner!);
if (!cli) {
return;
}

const expectedSlackMembers = (await cli.conversations.members({ channel: this.SlackChannelId! }) as ConversationsMembersResponse).members;
const expectedMatrixMembers = (await Promise.all(expectedSlackMembers.map(
(slackId) => this.main.datastore.getPuppetMatrixUserBySlackId(this.SlackTeamId!, slackId),
)));

const members = await this.main.listAllUsers(this.MatrixRoomId);
const intent = await this.getIntentForRoom();

try {
await Promise.all(
expectedMatrixMembers.filter((s) => s !== null && !members.includes(s)).map(
(member) => {
log.info(`Reinviting ${member} to the room`);
return intent.invite(this.MatrixRoomId, member);
},
),
);
} catch (ex) {
log.warn("Failed to reinvite user to the room:", ex);
}
}
}
30 changes: 30 additions & 0 deletions src/rooms/Rooms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Main } from "../Main";
import { RoomEntry, TeamEntry } from "../datastore/Models";
import { WebClient } from "@slack/web-api";
import { DMRoom } from "./DMRoom";
import { BridgedRoom } from "../BridgedRoom";

export function fromEntry(main: Main, entry: RoomEntry, team?: TeamEntry, botClient?: WebClient) {
const slackType = entry.remote.slack_type;
const opts = {
inbound_id: entry.remote_id,
matrix_room_id: entry.matrix_id,
slack_channel_id: entry.remote.id,
slack_channel_name: entry.remote.name,
slack_team_id: entry.remote.slack_team_id,
slack_webhook_uri: entry.remote.webhook_uri,
puppet_owner: entry.remote.puppet_owner,
is_private: entry.remote.slack_private,
slack_type: entry.remote.slack_type,
};
if (slackType === "im" || slackType === "mpim") {
if (!team) {
throw Error("'team' is undefined, but required for DM rooms");
}
if (!botClient) {
throw Error("'botClient' is undefined, but required for DM rooms");
}
return new DMRoom(main, opts, team, botClient);
}
return new BridgedRoom(main, opts, team, botClient);
}
3 changes: 2 additions & 1 deletion src/scripts/migrateToPostgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Datastore, TeamEntry } from "../datastore/Models";
import { WebClient } from "@slack/web-api";
import { TeamInfoResponse } from "../SlackResponses";
import { SlackClientFactory } from "../SlackClientFactory";
import { fromEntry } from "../rooms/Rooms";

Logging.configure({ console: "info" });
const log = Logging.get("script");
Expand Down Expand Up @@ -144,7 +145,7 @@ export async function migrateFromNedb(nedb: NedbDatastore, targetDs: Datastore)
if (!room.remote.slack_team_id && token) {
room.remote.slack_team_id = teamTokenMap.get(token);
}
await targetDs.upsertRoom(BridgedRoom.fromEntry(null as any, room));
await targetDs.upsertRoom(fromEntry(null as any, room));
log.info(`Migrated room ${room.id} (${i + 1}/${allRooms.length})`);
}));

Expand Down