Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
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
48 changes: 46 additions & 2 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 @@ -59,7 +60,7 @@ export class TeamSyncer {
this.teamConfigs = config.team_sync;
}

public async syncAllTeams(teamClients: { [id: string]: WebClient; }) {
public async syncAllTeams(teamClients: { [id: string]: WebClient; }): Promise<void> {
const queue = new PQueue({concurrency: TEAM_SYNC_CONCURRENCY});
const functionsForQueue: (() => Promise<void>)[] = [];
for (const [teamId, client] of Object.entries(teamClients)) {
Expand All @@ -71,6 +72,7 @@ export class TeamSyncer {
log.info("Syncing team", teamId);
await this.syncItems(teamId, client, "user");
await this.syncItems(teamId, client, "channel");
await this.syncCustomEmoji(teamId, client);
});
}
try {
Expand All @@ -88,7 +90,6 @@ export class TeamSyncer {
log.warn(`Not syncing ${type}s for ${teamId}`);
return;
}
// tslint:disable-next-line: no-any
let itemList: any[] = [];
let cursor: string|undefined;
for (let i = 0; i < TEAM_SYNC_FAILSAFE && (cursor === undefined || cursor !== ""); i++) {
Expand Down Expand Up @@ -299,6 +300,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) {
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 @@ -183,6 +183,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
59 changes: 46 additions & 13 deletions src/datastore/postgres/PgDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ const pgp: IMain = pgInit({
const log = Logging.get("PgDatastore");

export class PgDatastore implements Datastore {
public static readonly LATEST_SCHEMA = 9;
// tslint:disable-next-line: no-any
public readonly postgresDb: IDatabase<any>;
public static readonly LATEST_SCHEMA = 10;
public readonly postgresDb: IDatabase<unknown>;

constructor(connectionString: string) {
this.postgresDb = pgp(connectionString);
Expand Down Expand Up @@ -120,7 +119,42 @@ export class PgDatastore implements Datastore {
return this.postgresDb.none("DELETE FROM linked_accounts WHERE slack_id = ${slackId} AND user_id = ${userId}", { userId, slackId });
}

public async upsertEvent(roomIdOrEntry: string|EventEntry, eventId?: string, channelId?: string, ts?: string, extras?: EventEntryExtra) {
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,
channelId?: string,
ts?: string,
extras?: EventEntryExtra
): Promise<null> {
let entry: EventEntry = roomIdOrEntry as EventEntry;
if (typeof(roomIdOrEntry) === "string") {
entry = {
Expand Down Expand Up @@ -233,7 +267,7 @@ export class PgDatastore implements Datastore {
);
}

public async ensureSchema() {
public async ensureSchema(): Promise<void> {
let currentVersion = await this.getSchemaVersion();
while (currentVersion < PgDatastore.LATEST_SCHEMA) {
log.info(`Updating schema to v${currentVersion + 1}`);
Expand All @@ -251,7 +285,7 @@ export class PgDatastore implements Datastore {
log.info(`Database schema is at version v${currentVersion}`);
}

public async upsertRoom(room: BridgedRoom) {
public async upsertRoom(room: BridgedRoom): Promise<null> {
const entry = room.toEntry();
log.debug(`upsertRoom: ${entry.id}`);
return this.postgresDb.none(
Expand All @@ -265,12 +299,12 @@ export class PgDatastore implements Datastore {
);
}

public async deleteRoom(id: string) {
public async deleteRoom(id: string): Promise<null> {
log.debug(`deleteRoom: ${id}`);
return this.postgresDb.none("DELETE FROM rooms WHERE id = ${id}", { id });
}

public async getAllRooms() {
public async getAllRooms(): Promise<RoomEntry[]> {
const entries = await this.postgresDb.manyOrNone("SELECT * FROM rooms");
return entries.map((r) => {
const remote = JSON.parse(r.json);
Expand All @@ -283,7 +317,7 @@ export class PgDatastore implements Datastore {
});
}

public async upsertTeam(entry: TeamEntry) {
public async upsertTeam(entry: TeamEntry): Promise<null> {
log.debug(`upsertTeam: ${entry.id} ${entry.name}`);
const props = {
id: entry.id,
Expand All @@ -296,11 +330,10 @@ export class PgDatastore implements Datastore {
user_id: entry.user_id,
};
const statement = PgDatastore.BuildUpsertStatement("teams", ["id"], [props]);
await this.postgresDb.none(statement, props);
return this.postgresDb.none(statement, props);
}

// tslint:disable-next-line: no-any
private static teamEntryForRow(doc: any) {
private static teamEntryForRow(doc: Record<string, unknown>): TeamEntry {
return {
id: doc.id,
name: doc.name,
Expand Down Expand Up @@ -339,7 +372,7 @@ export class PgDatastore implements Datastore {
);
}

public async removePuppetTokenByMatrixId(teamId: string, matrixId: string) {
public async removePuppetTokenByMatrixId(teamId: string, matrixId: string): Promise<null> {
return this.postgresDb.none("DELETE FROM puppets WHERE slackteam = ${teamId} " +
"AND matrixuser = ${matrixId}", { teamId, matrixId });
}
Expand Down
13 changes: 13 additions & 0 deletions src/datastore/postgres/schema/v10.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<any>) => {
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);
`);
};
12 changes: 12 additions & 0 deletions src/tests/utils/fakeDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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