diff --git a/changelog.d/500.feature b/changelog.d/500.feature new file mode 100644 index 000000000..da3379077 --- /dev/null +++ b/changelog.d/500.feature @@ -0,0 +1 @@ +Support custom Slack emoji \ No newline at end of file diff --git a/changelog.d/551.misc b/changelog.d/551.misc new file mode 100644 index 000000000..ac84a17d9 --- /dev/null +++ b/changelog.d/551.misc @@ -0,0 +1 @@ +Docs: Consistently call the registration file slack-registration.yaml diff --git a/docs/getting_started.md b/docs/getting_started.md index 60617cb34..b0e654fb6 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -78,7 +78,7 @@ ever stuck, you can post a question in the ```sh $ docker run -v /path/to/config/:/config/ matrixdotorg/matrix-appservice-slack \ - -r -c /config/config.yaml -u "http://$HOST:$MATRIX_PORT" -f /config/slack.yaml + -r -c /config/config.yaml -u "http://$HOST:$MATRIX_PORT" -f /config/slack-registration.yaml ``` 1. Start the actual application service: diff --git a/src/SlackEventHandler.ts b/src/SlackEventHandler.ts index 19a505460..2744300f6 100644 --- a/src/SlackEventHandler.ts +++ b/src/SlackEventHandler.ts @@ -29,6 +29,17 @@ interface ISlackEventChannelRename extends ISlackEvent { created: number; } +/** + * https://api.slack.com/events/emoji_changed + */ +interface ISlackEventEmojiChanged extends ISlackEvent { + event_ts: string; + subtype?: "add"|"remove"|unknown; + name?: string; + names?: string[]; + value?: string; +} + /** * https://api.slack.com/events/team_domain_change */ @@ -102,7 +113,7 @@ export class SlackEventHandler extends BaseSlackHandler { */ protected static SUPPORTED_EVENTS: string[] = ["message", "reaction_added", "reaction_removed", "team_domain_change", "channel_rename", "user_change", "user_typing", "member_joined_channel", - "channel_created", "channel_deleted", "team_join"]; + "channel_created", "channel_deleted", "team_join", "emoji_changed"]; constructor(main: Main) { super(main); } @@ -185,6 +196,8 @@ export class SlackEventHandler extends BaseSlackHandler { case "reaction_removed": await this.handleReaction(event as ISlackEventReaction, teamId); break; + case "emoji_changed": + await this.handleEmojiChangedEvent(event as ISlackEventEmojiChanged, teamId); case "channel_rename": await this.handleChannelRenameEvent(event as ISlackEventChannelRename); break; @@ -335,6 +348,38 @@ export class SlackEventHandler extends BaseSlackHandler { } } + private async handleEmojiChangedEvent(event: ISlackEventEmojiChanged, teamId: string) { + if (!this.main.teamSyncer) { + throw Error("ignored"); + } + switch(event.subtype) { + case "add": { + if (typeof event.name !== 'string') { + throw Error('Slack event emoji_changed is expected to have name: string'); + } + if (typeof event.value !== 'string' || !/^https:\/\/|alias:/.test(event.value)) { + throw Error('Slack event emoji_changed is expected to have value: string and start with "https://" or "alias:"'); + } + const client = await this.main.clientFactory.getTeamClient(teamId); + await this.main.teamSyncer.addCustomEmoji(teamId, event.name, event.value, client.token!); + return; + } + case "remove": + if (!Array.isArray(event.names) || event.names.some(v => typeof v !== 'string')) { + throw Error('Slack event emoji_changed is expected to have names: string[]'); + } + for (const name of event.names) { + await this.main.teamSyncer.removeCustomEmoji(teamId, name); + } + break; + default: { + const client = await this.main.clientFactory.getTeamClient(teamId); + await this.main.teamSyncer.syncCustomEmoji(teamId, client); + break; + } + } + } + private async handleDomainChangeEvent(event: ISlackEventTeamDomainChange, teamId: string) { const team = await this.main.datastore.getTeam(teamId); if (team) { diff --git a/src/TeamSyncer.ts b/src/TeamSyncer.ts index 9cf3cb4aa..f812d74c4 100644 --- a/src/TeamSyncer.ts +++ b/src/TeamSyncer.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import axios from "axios"; import { Logging } from "matrix-appservice-bridge"; import { BridgedRoom } from "./BridgedRoom"; import { Main } from "./Main"; @@ -95,6 +96,7 @@ export class TeamSyncer { } functionsForQueue.push(async () => this.syncUsers(team, client)); functionsForQueue.push(async () => this.syncChannels(teamId, client)); + functionsForQueue.push(async () => this.syncCustomEmoji(teamId, client)); } try { log.info("Waiting for all teams to sync"); @@ -352,6 +354,49 @@ export class TeamSyncer { } } + public async syncCustomEmoji(teamId: string, client: WebClient): Promise { + // if (!this.getTeamSyncConfig(teamId, 'customEmoji')) { + // log.warn(`Not syncing custom emoji for ${teamId}`); + // return; + // } + log.info(`Syncing custom emoji ${teamId}`); + + const response = await client.emoji.list(); + if (response.ok !== true) { + throw Error("Slack replied to emoji.list but said the response wasn't ok."); + } + if (typeof response.emoji !== "object" || !response.emoji) { + throw Error("Slack replied to emoji.list but the list was not not an object."); + } + for (const [name, url] of Object.values(response.emoji)) { + await this.addCustomEmoji(teamId, name, url, client.token!); + } + } + + public async addCustomEmoji(teamId: string, name: string, url: string, accessToken: string): Promise { + const imageResponse = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + responseType: "arraybuffer", + }); + if (imageResponse.status !== 200) { + throw Error('Failed to get file'); + } + const mxc = await this.main.botIntent.getClient().uploadContent(imageResponse.data, { + name, + type: imageResponse.headers['content-type'], + rawResponse: false, + onlyContentUri: true, + }); + await this.main.datastore.upsertCustomEmoji(teamId, name, mxc); + return mxc; + } + + public async removeCustomEmoji(teamId: string, name: string): Promise { + return this.main.datastore.deleteCustomEmoji(teamId, name); + } + public async onChannelDeleted(teamId: string, channelId: string): Promise { log.info(`${teamId} removed channel ${channelId}`); if (!this.getTeamSyncConfig(teamId, "channel", channelId)) { diff --git a/src/datastore/Models.ts b/src/datastore/Models.ts index 3d07954fb..ace9fb08a 100644 --- a/src/datastore/Models.ts +++ b/src/datastore/Models.ts @@ -109,6 +109,11 @@ export interface Datastore { deleteRoom(id: string): Promise; getAllRooms(): Promise; + // Custom emoji + upsertCustomEmoji(teamId: string, name: string, mxc: string): Promise; + getCustomEmojiMxc(teamId: string, name: string): Promise; + deleteCustomEmoji(teamId: string, name: string): Promise; + // Events upsertEvent(roomId: string, eventId: string, channelId: string, ts: string, extras?: EventEntryExtra): Promise; upsertEvent(roomIdOrEntry: EventEntry): Promise; diff --git a/src/datastore/NedbDatastore.ts b/src/datastore/NedbDatastore.ts index bd6ba8ca2..031f8b9e1 100644 --- a/src/datastore/NedbDatastore.ts +++ b/src/datastore/NedbDatastore.ts @@ -193,6 +193,21 @@ export class NedbDatastore implements Datastore { }); } + public async upsertCustomEmoji(teamId: string, name: string, mxc: string): Promise { + // no-op; custom emoji are not implemented for NeDB + return null; + } + + public async getCustomEmojiMxc(teamId: string, name: string): Promise { + // no-op; custom emoji are not implemented for NeDB + return null; + } + + public async deleteCustomEmoji(teamId: string, name: string): Promise { + // no-op; custom emoji are not implemented for NeDB + return null; + } + public async upsertEvent(roomIdOrEntry: string|EventEntry, eventId?: string, channelId?: string, ts?: string, extras?: EventEntryExtra): Promise { let storeEv: StoredEvent; diff --git a/src/datastore/postgres/PgDatastore.ts b/src/datastore/postgres/PgDatastore.ts index 825a83bad..b9a1288f6 100644 --- a/src/datastore/postgres/PgDatastore.ts +++ b/src/datastore/postgres/PgDatastore.ts @@ -49,7 +49,7 @@ interface ClientSessionSchema { } export class PgDatastore implements Datastore, ClientEncryptionStore { - public static readonly LATEST_SCHEMA = 13; + public static readonly LATEST_SCHEMA = 14; public readonly postgresDb: IDatabase; constructor(connectionString: string) { @@ -126,6 +126,35 @@ export class PgDatastore implements Datastore, ClientEncryptionStore { return this.postgresDb.none("DELETE FROM linked_accounts WHERE slack_id = ${slackId} AND user_id = ${userId}", { userId, slackId }); } + public async upsertCustomEmoji(teamId: string, name: string, mxc: string): Promise { + log.debug(`upsertCustomEmoji: ${teamId} ${name} ${mxc}`); + return this.postgresDb.none( + "INSERT INTO custom_emoji(slack_team_id, name, mxc) " + + "VALUES(${teamId}, ${name}, ${mxc})" + + "ON CONFLICT ON CONSTRAINT custom_emoji_slack_idx DO UPDATE SET mxc = ${mxc}", + { + teamId, + name, + mxc, + }, + ); + } + + public async getCustomEmojiMxc(teamId: string, name: string): Promise { + // TODO Resolve aliases + return this.postgresDb.oneOrNone( + "SELECT mxc FROM custom_emoji WHERE team_id = ${teamId} AND name = ${name}", + { teamId, name }, + response => response && response.mxc, + ); + } + + public async deleteCustomEmoji(teamId: string, name: string): Promise { + log.debug(`deleteCustomEmoji: ${teamId} ${name}`); + // TODO Delete aliases + return this.postgresDb.none("DELETE FROM custom_emoji WHERE slack_team_id = ${teamId} AND name = ${name}", { teamId, name }); + } + public async upsertEvent( roomIdOrEntry: string | EventEntry, eventId?: string, @@ -312,7 +341,7 @@ export class PgDatastore implements Datastore, ClientEncryptionStore { return this.postgresDb.none(statement, props); } - private static teamEntryForRow(doc: any) { + private static teamEntryForRow(doc: Record): TeamEntry { return { id: doc.id, name: doc.name, diff --git a/src/datastore/postgres/schema/v14.ts b/src/datastore/postgres/schema/v14.ts new file mode 100644 index 000000000..a8620f1bb --- /dev/null +++ b/src/datastore/postgres/schema/v14.ts @@ -0,0 +1,13 @@ +import { IDatabase } from "pg-promise"; + +// tslint:disable-next-line: no-any +export const runSchema = async (db: IDatabase) => { + await db.none(` + CREATE TABLE custom_emoji ( + slack_team_id TEXT NOT NULL, + name TEXT NOT NULL, + mxc TEXT NOT NULL + ); + CREATE UNIQUE INDEX custom_emoji_slack_idx ON custom_emoji (slack_team_id, name); + `); +}; diff --git a/tests/unit/convert/index.ts b/tests/unit/convert/index.ts new file mode 100644 index 000000000..9cb5fd17e --- /dev/null +++ b/tests/unit/convert/index.ts @@ -0,0 +1,25 @@ +/* +Copyright 2019 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 convert from "../../src/images/convert"; +import { expect } from "chai"; + +describe("AdminCommand", () => { + it("constructs", async() => { + const result = await convert("/test"); + expect(result).to.be.a("string"); + }); +}); diff --git a/tests/utils/fakeDatastore.ts b/tests/utils/fakeDatastore.ts index a2adfca6a..53baf4c53 100644 --- a/tests/utils/fakeDatastore.ts +++ b/tests/utils/fakeDatastore.ts @@ -71,6 +71,18 @@ export class FakeDatastore implements Datastore { throw Error("Method not implemented."); } + public async upsertCustomEmoji(teamId: string, name: string, mxc: string): Promise { + throw Error("Method not implemented."); + } + + public async getCustomEmojiMxc(teamId: string, name: string): Promise { + throw Error("Method not implemented."); + } + + public async deleteCustomEmoji(teamId: string, name: string): Promise { + throw Error("Method not implemented."); + } + public async upsertEvent(roomId: string, eventId: string, channelId: string, ts: string, extras?: EventEntryExtra): Promise; public async upsertEvent(roomIdOrEntry: EventEntry): Promise;