From 7c1d9af31d513fe6eb14b2a45b6a731b2c65a1d9 Mon Sep 17 00:00:00 2001 From: l1s5 Date: Tue, 10 Nov 2026 00:47:32 +0900 Subject: [PATCH 1/3] Handle d2m events with empty guild/user avatars --- src/d2m/actions/create-room.js | 2 +- src/d2m/actions/create-space.js | 2 +- src/d2m/actions/register-pk-user.js | 5 +++- src/d2m/actions/register-plu-ral-user.js | 2 +- src/d2m/actions/register-webhook-user.js | 2 +- src/matrix/file.js | 12 ++++++++ src/matrix/file.test.js | 36 ++++++++++++++++++++++++ 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 9fb44e6..b4c91b1 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -181,7 +181,7 @@ async function channelToKState(channel, guild, di) { network: { id: guild.id, displayname: guild.name, - avatar_url: {$url: file.guildIcon(guild)} + ...(guild.icon ? {avatar_url: {$url: file.guildIcon(guild)}} : {}) }, channel: { id: channel.id, diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 3ee135d..0b12683 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -77,7 +77,7 @@ async function guildToKState(guild, privacyLevel) { "m.room.name/": {name: guild.name}, "m.room.avatar/": { $if: guild.icon, - url: {$url: file.guildIcon(guild)} + url: guild.icon ? {$url: file.guildIcon(guild)} : null }, "m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]}, diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 281bfcb..07dd90d 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -110,7 +110,10 @@ async function ensureSimJoined(pkMessage, roomID) { async function memberToStateContent(pkMessage, author) { // We prefer to use the member's avatar URL data since the image upload can be cached across channels, // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. - const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author) + const avatar = pkMessage.member.avatar_url + || pkMessage.member.webhook_avatar_url + || pkMessage.system.avatar_url + || (author.avatar ? file.userAvatar(author) : null) const color = pkMessage.member.color ?? pkMessage.system.color const content = { diff --git a/src/d2m/actions/register-plu-ral-user.js b/src/d2m/actions/register-plu-ral-user.js index 4e1d347..2b46002 100644 --- a/src/d2m/actions/register-plu-ral-user.js +++ b/src/d2m/actions/register-plu-ral-user.js @@ -115,7 +115,7 @@ async function ensureSimJoined(pluRalMessage, roomID) { async function memberToStateContent(pluRalMessage, author) { // We prefer to use the member's avatar URL data since the image upload can be cached across channels, // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. - const avatar = pluRalMessage.member.avatar_url || file.userAvatar(author) + const avatar = pluRalMessage.member.avatar_url || (author.avatar ? file.userAvatar(author) : null) const content = { displayname: author.username, diff --git a/src/d2m/actions/register-webhook-user.js b/src/d2m/actions/register-webhook-user.js index 5114a8e..977c12a 100644 --- a/src/d2m/actions/register-webhook-user.js +++ b/src/d2m/actions/register-webhook-user.js @@ -89,7 +89,7 @@ async function ensureSimJoined(fakeUserID, author, roomID) { * @param {Ty.WebhookAuthor} author */ async function authorToStateContent(author) { - const avatar = file.userAvatar(author) + const avatar = author.avatar ? file.userAvatar(author) : null const content = { displayname: author.username, diff --git a/src/matrix/file.js b/src/matrix/file.js index 240999c..e02a001 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -1,5 +1,7 @@ // @ts-check +const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../passthrough") const {reg, writeRegistration} = require("./read-registration.js") const Ty = require("../types") @@ -138,11 +140,21 @@ async function _actuallyUploadDiscordFileToMxc(url) { } } +/** + * @param {DiscordTypes.APIGuild} guild + * @returns {string} + */ function guildIcon(guild) { + assert(guild.icon, `guild ${guild.id} has no icon`) return `/icons/${guild.id}/${guild.icon}.png?size=${IMAGE_SIZE}` } +/** + * @param {DiscordTypes.APIUser | Ty.WebhookAuthor} user + * @returns {string} + */ function userAvatar(user) { + assert(user.avatar, `user ${user.id} has no avatar`) return `/avatars/${user.id}/${user.avatar}.png?size=${IMAGE_SIZE}` } diff --git a/src/matrix/file.test.js b/src/matrix/file.test.js index aa7e6cb..9c470a4 100644 --- a/src/matrix/file.test.js +++ b/src/matrix/file.test.js @@ -26,3 +26,39 @@ test("removeExpiryParams: works on media proxy and keeps quality", t => { const result = file._removeExpiryParams(url) t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=webp") }) + +test("guildIcon: returns icon path", t => { + t.equal( + // @ts-ignore + file.guildIcon({id: "112760669178241024", icon: "a_f83622e09ead74f0c5c527fe241f8f8c"}), + "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024" + ) +}) + +test("guildIcon: throws if guild has no icon", t => { + let err + try { + // @ts-ignore + file.guildIcon({id: "112760669178241024", icon: null}) + } catch (e) { + err = e + } + t.match(err?.message, /has no icon/) +}) + +test("userAvatar: returns avatar path", t => { + t.equal( + file.userAvatar({id: "113340068197859328", username: "kumaccino", avatar: "b48302623a12bc7c59a71328f72ccb39"}), + "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024" + ) +}) + +test("userAvatar: throws if user has no avatar", t => { + let err + try { + file.userAvatar({id: "113340068197859328", username: "kumaccino", avatar: null}) + } catch (e) { + err = e + } + t.match(err?.message, /has no avatar/) +}) -- 2.34.1 From 6118c7fb741811666a70a9df4b2bef62b2c8c544 Mon Sep 17 00:00:00 2001 From: l1s5 Date: Tue, 10 Nov 2026 09:08:28 +0900 Subject: [PATCH 2/3] fixup! Handle d2m events with empty guild/user avatars --- src/d2m/actions/create-room.js | 2 +- src/d2m/actions/create-space.js | 2 +- src/d2m/actions/register-pk-user.js | 5 +---- src/d2m/actions/register-plu-ral-user.js | 2 +- src/d2m/actions/register-user.js | 10 ++++----- src/d2m/actions/register-webhook-user.js | 2 +- src/matrix/file.js | 9 ++++---- src/matrix/file.test.js | 26 +++++++++--------------- src/matrix/kstate.js | 6 +++++- src/matrix/kstate.test.js | 12 +++++++++++ 10 files changed, 41 insertions(+), 35 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index b4c91b1..9fb44e6 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -181,7 +181,7 @@ async function channelToKState(channel, guild, di) { network: { id: guild.id, displayname: guild.name, - ...(guild.icon ? {avatar_url: {$url: file.guildIcon(guild)}} : {}) + avatar_url: {$url: file.guildIcon(guild)} }, channel: { id: channel.id, diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 0b12683..3ee135d 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -77,7 +77,7 @@ async function guildToKState(guild, privacyLevel) { "m.room.name/": {name: guild.name}, "m.room.avatar/": { $if: guild.icon, - url: guild.icon ? {$url: file.guildIcon(guild)} : null + url: {$url: file.guildIcon(guild)} }, "m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]}, diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 07dd90d..281bfcb 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -110,10 +110,7 @@ async function ensureSimJoined(pkMessage, roomID) { async function memberToStateContent(pkMessage, author) { // We prefer to use the member's avatar URL data since the image upload can be cached across channels, // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. - const avatar = pkMessage.member.avatar_url - || pkMessage.member.webhook_avatar_url - || pkMessage.system.avatar_url - || (author.avatar ? file.userAvatar(author) : null) + const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author) const color = pkMessage.member.color ?? pkMessage.system.color const content = { diff --git a/src/d2m/actions/register-plu-ral-user.js b/src/d2m/actions/register-plu-ral-user.js index 2b46002..4e1d347 100644 --- a/src/d2m/actions/register-plu-ral-user.js +++ b/src/d2m/actions/register-plu-ral-user.js @@ -115,7 +115,7 @@ async function ensureSimJoined(pluRalMessage, roomID) { async function memberToStateContent(pluRalMessage, author) { // We prefer to use the member's avatar URL data since the image upload can be cached across channels, // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. - const avatar = pluRalMessage.member.avatar_url || (author.avatar ? file.userAvatar(author) : null) + const avatar = pluRalMessage.member.avatar_url || file.userAvatar(author) const content = { displayname: author.username, diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index 3427bbd..1a80c00 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -102,8 +102,8 @@ async function userToGlobalProfile(user) { globalProfile.displayname = user.username if (user.global_name) globalProfile.displayname = user.global_name - if (user.avatar) { - const avatarPath = file.userAvatar(user) // the user avatar only + const avatarPath = file.userAvatar(user) // the user avatar only + if (avatarPath) { globalProfile.avatar_url = await file.uploadDiscordFileToMxc(avatarPath) } @@ -132,9 +132,9 @@ async function memberToStateContent(user, member, guildID) { } } - if (member?.avatar || user.avatar) { - // const avatarPath = file.userAvatar(user) // the user avatar only - const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar + // const avatarPath = file.userAvatar(user) // the user avatar only + const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar + if (avatarPath) { content["moe.cadence.ooye.member"].avatar = avatarPath content.avatar_url = await file.uploadDiscordFileToMxc(avatarPath) } diff --git a/src/d2m/actions/register-webhook-user.js b/src/d2m/actions/register-webhook-user.js index 977c12a..5114a8e 100644 --- a/src/d2m/actions/register-webhook-user.js +++ b/src/d2m/actions/register-webhook-user.js @@ -89,7 +89,7 @@ async function ensureSimJoined(fakeUserID, author, roomID) { * @param {Ty.WebhookAuthor} author */ async function authorToStateContent(author) { - const avatar = author.avatar ? file.userAvatar(author) : null + const avatar = file.userAvatar(author) const content = { displayname: author.username, diff --git a/src/matrix/file.js b/src/matrix/file.js index e02a001..030f789 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -1,6 +1,5 @@ // @ts-check -const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../passthrough") const {reg, writeRegistration} = require("./read-registration.js") @@ -142,19 +141,19 @@ async function _actuallyUploadDiscordFileToMxc(url) { /** * @param {DiscordTypes.APIGuild} guild - * @returns {string} + * @returns {string | null} */ function guildIcon(guild) { - assert(guild.icon, `guild ${guild.id} has no icon`) + if (!guild.icon) return null return `/icons/${guild.id}/${guild.icon}.png?size=${IMAGE_SIZE}` } /** * @param {DiscordTypes.APIUser | Ty.WebhookAuthor} user - * @returns {string} + * @returns {string | null} */ function userAvatar(user) { - assert(user.avatar, `user ${user.id} has no avatar`) + if (!user.avatar) return null return `/avatars/${user.id}/${user.avatar}.png?size=${IMAGE_SIZE}` } diff --git a/src/matrix/file.test.js b/src/matrix/file.test.js index 9c470a4..6d66ed7 100644 --- a/src/matrix/file.test.js +++ b/src/matrix/file.test.js @@ -35,15 +35,12 @@ test("guildIcon: returns icon path", t => { ) }) -test("guildIcon: throws if guild has no icon", t => { - let err - try { +test("guildIcon: returns null if guild has no icon", t => { + t.equal( // @ts-ignore - file.guildIcon({id: "112760669178241024", icon: null}) - } catch (e) { - err = e - } - t.match(err?.message, /has no icon/) + file.guildIcon({id: "112760669178241024", icon: null}), + null + ) }) test("userAvatar: returns avatar path", t => { @@ -53,12 +50,9 @@ test("userAvatar: returns avatar path", t => { ) }) -test("userAvatar: throws if user has no avatar", t => { - let err - try { - file.userAvatar({id: "113340068197859328", username: "kumaccino", avatar: null}) - } catch (e) { - err = e - } - t.match(err?.message, /has no avatar/) +test("userAvatar: returns null if user has no avatar", t => { + t.equal( + file.userAvatar({id: "113340068197859328", username: "kumaccino", avatar: null}), + null + ) }) diff --git a/src/matrix/kstate.js b/src/matrix/kstate.js index 3648f2d..c110fdf 100644 --- a/src/matrix/kstate.js +++ b/src/matrix/kstate.js @@ -32,7 +32,11 @@ async function kstateUploadMxc(obj) { for (const [k, v] of Object.entries(obj)) { if (v == null || typeof v !== "object") continue - if (v.$url) { + if ("$url" in v) { + if (!v.$url) { + delete obj[k] + continue + } promises.push( file.uploadDiscordFileToMxc(v.$url) .then(mxc => obj[k] = mxc) diff --git a/src/matrix/kstate.test.js b/src/matrix/kstate.test.js index b67a725..1cc600b 100644 --- a/src/matrix/kstate.test.js +++ b/src/matrix/kstate.test.js @@ -21,6 +21,18 @@ test("kstate strip: keeps true conditions while removing $if", t => { }) }) +test("kstateUploadMxc: removes keys where $url is null", async t => { + const input = { + "m.room.avatar/": { + url: {$url: null} + } + } + await kstateUploadMxc(input) + t.deepEqual(input, { + "m.room.avatar/": {} + }) +}) + test("kstateUploadMxc: sets the mxc", async t => { const input = { "m.room.avatar/": { -- 2.34.1 From 9141bba25ead576100da5c82b452087719e92cc2 Mon Sep 17 00:00:00 2001 From: l1s5 Date: Tue, 10 Nov 2026 10:04:57 +0900 Subject: [PATCH 3/3] Add a few more jsdoc types --- src/d2m/actions/register-pk-user.js | 1 + src/d2m/actions/register-plu-ral-user.js | 1 + src/d2m/actions/register-user.js | 2 ++ src/d2m/actions/register-webhook-user.js | 1 + src/matrix/file.js | 6 ++++++ 5 files changed, 11 insertions(+) diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 281bfcb..d127f21 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -106,6 +106,7 @@ async function ensureSimJoined(pkMessage, roomID) { * Generate profile data based on webhook displayname and configured avatar. * @param {Ty.PkMessage} pkMessage * @param {Ty.WebhookAuthor} author + * @returns {Promise} */ async function memberToStateContent(pkMessage, author) { // We prefer to use the member's avatar URL data since the image upload can be cached across channels, diff --git a/src/d2m/actions/register-plu-ral-user.js b/src/d2m/actions/register-plu-ral-user.js index 4e1d347..5830ce8 100644 --- a/src/d2m/actions/register-plu-ral-user.js +++ b/src/d2m/actions/register-plu-ral-user.js @@ -111,6 +111,7 @@ async function ensureSimJoined(pluRalMessage, roomID) { * Generate profile data based on webhook displayname and configured avatar. * @param {Ty.PluRalWebhookMessage} pluRalMessage * @param {Ty.WebhookAuthor} author + * @returns {Promise} */ async function memberToStateContent(pluRalMessage, author) { // We prefer to use the member's avatar URL data since the image upload can be cached across channels, diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index 1a80c00..db04c8e 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -113,6 +113,8 @@ async function userToGlobalProfile(user) { /** * @param {DiscordTypes.APIUser} user * @param {Omit | undefined} member + * @param {string} guildID + * @returns {Promise} */ async function memberToStateContent(user, member, guildID) { let displayname = user.username diff --git a/src/d2m/actions/register-webhook-user.js b/src/d2m/actions/register-webhook-user.js index 5114a8e..4dc3c3f 100644 --- a/src/d2m/actions/register-webhook-user.js +++ b/src/d2m/actions/register-webhook-user.js @@ -87,6 +87,7 @@ async function ensureSimJoined(fakeUserID, author, roomID) { /** * Generate profile data based on webhook displayname and configured avatar. * @param {Ty.WebhookAuthor} author + * @returns {Promise} */ async function authorToStateContent(author) { const avatar = file.userAvatar(author) diff --git a/src/matrix/file.js b/src/matrix/file.js index 030f789..e1e6542 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -157,6 +157,12 @@ function userAvatar(user) { return `/avatars/${user.id}/${user.avatar}.png?size=${IMAGE_SIZE}` } +/** + * @param {string} guildID + * @param {DiscordTypes.APIUser} user + * @param {Omit | undefined} member + * @returns {string} + */ function memberAvatar(guildID, user, member) { if (!member?.avatar) return userAvatar(user) return `/guilds/${guildID}/users/${user.id}/avatars/${member?.avatar}.png?size=${IMAGE_SIZE}` -- 2.34.1