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

WIP: Support custom Slack emoji #500

Open
wants to merge 15 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
1 change: 1 addition & 0 deletions changelog.d/500.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support custom Slack emoji
1 change: 1 addition & 0 deletions changelog.d/551.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Docs: Consistently call the registration file slack-registration.yaml
2 changes: 1 addition & 1 deletion docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 46 additions & 1 deletion src/SlackEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions src/TeamSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -352,6 +354,49 @@ export class TeamSyncer {
}
}

public async syncCustomEmoji(teamId: string, client: WebClient): Promise<void> {
// 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually create a response type interface because the client sucks and doesn't return any types.

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<string> {
const imageResponse = await axios.get<ArrayBuffer>(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<null> {
return this.main.datastore.deleteCustomEmoji(teamId, name);
}

public async onChannelDeleted(teamId: string, channelId: string): Promise<void> {
log.info(`${teamId} removed channel ${channelId}`);
if (!this.getTeamSyncConfig(teamId, "channel", channelId)) {
Expand Down
5 changes: 5 additions & 0 deletions src/datastore/Models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ export interface Datastore {
deleteRoom(id: string): Promise<null>;
getAllRooms(): Promise<RoomEntry[]>;

// Custom emoji
upsertCustomEmoji(teamId: string, name: string, mxc: string): Promise<null>;
getCustomEmojiMxc(teamId: string, name: string): Promise<string|null>;
deleteCustomEmoji(teamId: string, name: string): Promise<null>;

// Events
upsertEvent(roomId: string, eventId: string, channelId: string, ts: string, extras?: EventEntryExtra): Promise<null>;
upsertEvent(roomIdOrEntry: EventEntry): Promise<null>;
Expand Down
15 changes: 15 additions & 0 deletions src/datastore/NedbDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,21 @@ export class NedbDatastore implements Datastore {
});
}

public async upsertCustomEmoji(teamId: string, name: string, mxc: string): Promise<null> {
// no-op; custom emoji are not implemented for NeDB
return null;
}

public async getCustomEmojiMxc(teamId: string, name: string): Promise<string|null> {
// no-op; custom emoji are not implemented for NeDB
return null;
}

public async deleteCustomEmoji(teamId: string, name: string): Promise<null> {
// 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<null> {
let storeEv: StoredEvent;
Expand Down
33 changes: 31 additions & 2 deletions src/datastore/postgres/PgDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;

constructor(connectionString: string) {
Expand Down Expand Up @@ -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<null> {
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<string|null> {
// TODO Resolve aliases
return this.postgresDb.oneOrNone<any>(
"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<null> {
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,
Expand Down Expand Up @@ -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<string, unknown>): TeamEntry {
return {
id: doc.id,
name: doc.name,
Expand Down
13 changes: 13 additions & 0 deletions src/datastore/postgres/schema/v14.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { IDatabase } from "pg-promise";

// tslint:disable-next-line: no-any
export const runSchema = async (db: IDatabase<unknown>) => {
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);
`);
};
25 changes: 25 additions & 0 deletions tests/unit/convert/index.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
12 changes: 12 additions & 0 deletions tests/utils/fakeDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ export class FakeDatastore implements Datastore {
throw Error("Method not implemented.");
}

public async upsertCustomEmoji(teamId: string, name: string, mxc: string): Promise<null> {
throw Error("Method not implemented.");
}

public async getCustomEmojiMxc(teamId: string, name: string): Promise<string | null> {
throw Error("Method not implemented.");
}

public async deleteCustomEmoji(teamId: string, name: string): Promise<null> {
throw Error("Method not implemented.");
}

public async upsertEvent(roomId: string, eventId: string, channelId: string, ts: string, extras?: EventEntryExtra): Promise<null>;

public async upsertEvent(roomIdOrEntry: EventEntry): Promise<null>;
Expand Down