Compare commits

..

5 commits

Author SHA1 Message Date
Bea
87fcdb18ab
feat(discord): show per-message profile info in matrix info command 2026-03-24 16:45:40 +00:00
Bea
015bedab69
fix(m2d): implement MSC4144 avatar clearing algorithm
- Empty string "" -> undefined (Discord uses default avatar)
- Valid MXC URI -> convert to public URL
- Omitted/null -> keep member avatar
2026-03-24 16:45:40 +00:00
Bea
e25f788738
fix(m2d): only use unstable com.beeper.per_message_profile prefix 2026-03-24 16:45:39 +00:00
Bea
cfa319eaa3
feat(m2d): strip per-message profile fallbacks from message content
Remove data-mx-profile-fallback elements from formatted_body and
displayname prefix from plain body when per-message profile is used.
2026-03-24 16:45:30 +00:00
Bea
714e990bef
feat(m2d): support MSC4144 per-message profiles
Override webhook username and avatar_url from m.per_message_profile
(and unstable com.beeper.per_message_profile) when present.
The stable key takes priority over the unstable prefix.
2026-03-20 14:09:59 +00:00
3 changed files with 171 additions and 3 deletions

View file

@ -62,7 +62,20 @@ async function _interact({guild_id, data}, {api}) {
.sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels))
.filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get())
const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get()
const name = matrixMember?.displayname || event.sender // Check for per-message profile
const perMessageProfile = event.content?.["com.beeper.per_message_profile"]
let name = matrixMember?.displayname || event.sender
let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url)
let profileNote = ""
if (perMessageProfile) {
if (perMessageProfile.displayname) {
name = perMessageProfile.displayname
}
if ("avatar_url" in perMessageProfile) {
avatar = perMessageProfile.avatar_url ? utils.getPublicUrlForMxc(perMessageProfile.avatar_url) : undefined
}
profileNote = " (sent with a per-message profile)"
}
return { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
@ -70,9 +83,9 @@ async function _interact({guild_id, data}, {api}) {
author: { author: {
name, name,
url: `https://matrix.to/#/${event.sender}`, url: `https://matrix.to/#/${event.sender}`,
icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url) icon_url: avatar
}, },
description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`, description: `This Matrix message was delivered to Discord by **Out Of Your Element**${profileNote}.\n[View on Matrix →](<https://matrix.to/#/${message.room_id}/${message.event_id}?${via}>)\n\n**User ID**: [${event.sender}](<https://matrix.to/#/${event.sender}>)`,
color: 0x0dbd8b, color: 0x0dbd8b,
fields: [{ fields: [{
name: "In Channels", name: "In Channels",

View file

@ -557,6 +557,18 @@ async function eventToMessage(event, guild, channel, di) {
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
if (member.displayname) displayName = member.displayname if (member.displayname) displayName = member.displayname
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
// MSC4144: Override display name and avatar from per-message profile if present
const perMessageProfile = event.content["com.beeper.per_message_profile"]
if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname
if (perMessageProfile && "avatar_url" in perMessageProfile) {
if (perMessageProfile.avatar_url === "") {
// empty string avatar_url clears the avatar (use default)
avatarURL = undefined
} else if (perMessageProfile.avatar_url) {
// omitted/null falls back to member avatar
avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url)
}
}
// If the display name is too long to be put into the webhook (80 characters is the maximum), // If the display name is too long to be put into the webhook (80 characters is the maximum),
// put the excess characters into displayNameRunoff, later to be put at the top of the message // put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
@ -799,6 +811,10 @@ async function eventToMessage(event, guild, channel, di) {
if (shouldProcessTextEvent) { if (shouldProcessTextEvent) {
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
let input = event.content.formatted_body let input = event.content.formatted_body
if (perMessageProfile?.has_fallback) {
// Strip fallback elements added for clients that don't support per-message profiles
input = input.replace(/<(\w+)[^>]*\bdata-mx-profile-fallback\b[^>]*>[^<]*<\/\1>/g, "")
}
if (event.content.msgtype === "m.emote") { if (event.content.msgtype === "m.emote") {
input = `* ${displayName} ${input}` input = `* ${displayName} ${input}`
} }
@ -944,6 +960,10 @@ async function eventToMessage(event, guild, channel, di) {
} else { } else {
// Looks like we're using the plaintext body! // Looks like we're using the plaintext body!
content = event.content.body content = event.content.body
if (perMessageProfile?.has_fallback && perMessageProfile.displayname && content.startsWith(perMessageProfile.displayname + ": ")) {
// Strip the display name prefix fallback added for clients that don't support per-message profiles
content = content.slice(perMessageProfile.displayname.length + 2)
}
if (event.content.msgtype === "m.emote") { if (event.content.msgtype === "m.emote") {
content = `* ${displayName} ${content}` content = `* ${displayName} ${content}`

View file

@ -5526,6 +5526,141 @@ test("event2message: known and unknown emojis in the end are used for sprite she
) )
}) })
test("event2message: com.beeper.per_message_profile overrides displayname and avatar_url", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "hello from unstable profile",
"com.beeper.per_message_profile": {
id: "custom-id",
displayname: "Unstable Name",
avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo"
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Unstable Name",
content: "hello from unstable profile",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: com.beeper.per_message_profile empty avatar_url clears avatar", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "hello with cleared avatar",
"com.beeper.per_message_profile": {
id: "no-avatar",
displayname: "No Avatar User",
avatar_url: ""
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "No Avatar User",
content: "hello with cleared avatar",
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: data-mx-profile-fallback element is stripped from formatted_body when per-message profile is present", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "Tidus Herboren: one more test",
format: "org.matrix.custom.html",
formatted_body: "<strong data-mx-profile-fallback>Tidus Herboren: </strong>one more test",
"com.beeper.per_message_profile": {
id: "tidus",
displayname: "Tidus Herboren",
avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
has_fallback: true
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
content: "one more test",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: displayname prefix is stripped from plain body when per-message profile has_fallback", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "Tidus Herboren: one more test",
"com.beeper.per_message_profile": {
id: "tidus",
displayname: "Tidus Herboren",
has_fallback: true
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
content: "one more test",
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: all unknown chess emojis are used for sprite sheet", async t => { test("event2message: all unknown chess emojis are used for sprite sheet", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({