diff --git a/Dockerfile b/Dockerfile index 4adf3274..252b4dd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,7 @@ RUN yarn --network-timeout 600000 --production --pure-lockfile && yarn cache cle COPY --from=builder /src/lib ./ COPY --from=builder /src/public ./public +COPY --from=builder /src/assets ./assets VOLUME /data EXPOSE 9993 diff --git a/assets/feeds_avatar.png b/assets/feeds_avatar.png new file mode 100644 index 00000000..64350aed Binary files /dev/null and b/assets/feeds_avatar.png differ diff --git a/assets/figma_avatar.png b/assets/figma_avatar.png new file mode 100644 index 00000000..8dc64435 Binary files /dev/null and b/assets/figma_avatar.png differ diff --git a/assets/github_avatar.png b/assets/github_avatar.png new file mode 100644 index 00000000..3da737b3 Binary files /dev/null and b/assets/github_avatar.png differ diff --git a/assets/gitlab_avatar.png b/assets/gitlab_avatar.png new file mode 100644 index 00000000..f2f3aa92 Binary files /dev/null and b/assets/gitlab_avatar.png differ diff --git a/assets/jira_avatar.png b/assets/jira_avatar.png new file mode 100644 index 00000000..3eb55218 Binary files /dev/null and b/assets/jira_avatar.png differ diff --git a/assets/src/feeds_avatar.svg b/assets/src/feeds_avatar.svg new file mode 100644 index 00000000..2df89ecb --- /dev/null +++ b/assets/src/feeds_avatar.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + diff --git a/assets/src/figma_avatar.svg b/assets/src/figma_avatar.svg new file mode 100644 index 00000000..104a181b --- /dev/null +++ b/assets/src/figma_avatar.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/src/github_avatar.svg b/assets/src/github_avatar.svg new file mode 100644 index 00000000..64f54b36 --- /dev/null +++ b/assets/src/github_avatar.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + diff --git a/assets/src/gitlab_avatar.svg b/assets/src/gitlab_avatar.svg new file mode 100644 index 00000000..c9682f63 --- /dev/null +++ b/assets/src/gitlab_avatar.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + diff --git a/assets/src/jira_avatar.svg b/assets/src/jira_avatar.svg new file mode 100644 index 00000000..da9bfc17 --- /dev/null +++ b/assets/src/jira_avatar.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/src/webhooks_avatar.svg b/assets/src/webhooks_avatar.svg new file mode 100644 index 00000000..c41d74d4 --- /dev/null +++ b/assets/src/webhooks_avatar.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/webhooks_avatar.png b/assets/webhooks_avatar.png new file mode 100644 index 00000000..71286e6a Binary files /dev/null and b/assets/webhooks_avatar.png differ diff --git a/changelog.d/767.feature b/changelog.d/767.feature new file mode 100644 index 00000000..7661b6e6 --- /dev/null +++ b/changelog.d/767.feature @@ -0,0 +1 @@ +Add support for uploading bot avatar images. diff --git a/config.sample.yml b/config.sample.yml index 281140e6..3f3f4a37 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -107,7 +107,7 @@ serviceBots: - localpart: feeds displayname: Feeds - avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + avatar: ./assets/feeds_avatar.png prefix: "!feeds" service: feeds metrics: diff --git a/docs/advanced/service_bots.md b/docs/advanced/service_bots.md index 6336c648..e39925b2 100644 --- a/docs/advanced/service_bots.md +++ b/docs/advanced/service_bots.md @@ -20,7 +20,7 @@ For example with this configuration: serviceBots: - localpart: feeds displayname: Feeds - avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + avatar: "./assets/feeds_avatar.png" prefix: "!feeds" service: feeds ``` diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index b7fc1f9e..9ae8674c 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -1,3 +1,6 @@ +import { promises as fs } from "fs"; +import axios from "axios"; +import mime from "mime"; import { Appservice, Intent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; @@ -84,22 +87,122 @@ export default class BotUsersManager { log.debug(`Ensuring bot user ${botUser.userId} is registered`); await botUser.intent.ensureRegistered(); - // Set up the bot profile - let profile; + await this.ensureProfile(botUser); + } + } + + /** + * Ensures the bot user profile display name and avatar image are updated. + * + * @returns Promise resolving when the user profile has been ensured. + */ + private async ensureProfile(botUser: BotUser): Promise { + log.debug(`Ensuring profile for ${botUser.userId} is updated`); + + let profile: { + avatar_url?: string, + displayname?: string, + }; + try { + profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId); + } catch (e) { + log.error(`Failed to get user profile for ${botUser.userId}:`, e); + profile = {}; + } + + // Update display name if necessary + if (botUser.displayname && profile.displayname !== botUser.displayname) { try { - profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId); - } catch { - profile = {} - } - if (botUser.avatar && profile.avatar_url !== botUser.avatar) { - log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`); - await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); - } - if (botUser.displayname && profile.displayname !== botUser.displayname) { - log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`); await botUser.intent.underlyingClient.setDisplayName(botUser.displayname); + log.info(`Updated displayname for "${botUser.userId}" to ${botUser.displayname}`); + } catch (e) { + log.error(`Failed to set displayname for ${botUser.userId}:`, e); } } + + if (!botUser.avatar) { + // Unset any avatar + if (profile.avatar_url) { + await botUser.intent.underlyingClient.setAvatarUrl(''); + log.info(`Removed avatar for "${botUser.userId}"`); + } + + return; + } + + if (botUser.avatar.startsWith("mxc://")) { + // Configured avatar is a Matrix content URL + if (profile.avatar_url === botUser.avatar) { + // Avatar is current, no need to update + log.debug(`Avatar for ${botUser.userId} is already updated`); + return; + } + + try { + await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); + log.info(`Updated avatar for ${botUser.userId} to ${botUser.avatar}`); + } catch (e) { + log.error(`Failed to set avatar for ${botUser.userId}:`, e); + } + + return; + } + + // Otherwise assume configured avatar is a file path + let avatarImage: { + image: Buffer, + contentType: string, + }; + try { + const contentType = mime.getType(botUser.avatar); + if (!contentType) { + throw new Error("Could not determine content type"); + } + // File path + avatarImage = { + image: await fs.readFile(botUser.avatar), + contentType, + }; + } catch (e) { + log.error(`Failed to load avatar at ${botUser.avatar}:`, e); + return; + } + + // Determine if an avatar update is needed + if (profile.avatar_url) { + try { + const res = await axios.get( + botUser.intent.underlyingClient.mxcToHttp(profile.avatar_url), + { responseType: "arraybuffer" }, + ); + const currentAvatarImage = { + image: Buffer.from(res.data), + contentType: res.headers["content-type"], + }; + if ( + currentAvatarImage.image.equals(avatarImage.image) + && currentAvatarImage.contentType === avatarImage.contentType + ) { + // Avatar is current, no need to update + log.debug(`Avatar for ${botUser.userId} is already updated`); + return; + } + } catch (e) { + log.error(`Failed to get current avatar image for ${botUser.userId}:`, e); + } + } + + // Update the avatar + try { + const uploadedAvatarMxcUrl = await botUser.intent.underlyingClient.uploadContent( + avatarImage.image, + avatarImage.contentType, + ); + await botUser.intent.underlyingClient.setAvatarUrl(uploadedAvatarMxcUrl); + log.info(`Updated avatar for ${botUser.userId} to ${uploadedAvatarMxcUrl}`); + } catch (e) { + log.error(`Failed to set avatar for ${botUser.userId}:`, e); + } } private async getJoinedRooms(): Promise { diff --git a/src/config/Defaults.ts b/src/config/Defaults.ts index 09a3d2ae..cb1f4692 100644 --- a/src/config/Defaults.ts +++ b/src/config/Defaults.ts @@ -53,7 +53,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = { { localpart: "feeds", displayname: "Feeds", - avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d", + avatar: "./assets/feeds_avatar.png", prefix: "!feeds", service: "feeds", },