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 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