Compare commits

...

9 commits

Author SHA1 Message Date
Bea
a97a41a75d
chore: add global prettier ignore file
As prettier is not configured for this project, and the style doesn't
meet prettier's opinionated defaults, it's worth dropping this blanket
ignore until such a time (if ever) a prettier config is produced that
matches the styleguide of this project.
2026-03-24 19:11:09 +00:00
Bea
6a9e894966
chore: editorconfig matching existing code format styles
An editor config has been written that matches existing indentation and
formatting used by this project, which should extend the courtesy of
intended tab use to other editors like NeoVim which are often conigured
to adhere to `.editorconfig` by default.

As part of this change, the official EditorConfig extension has been
added to VSCode recommendations, which should additionally enable
Code-like editors to additionally adhere to the rules of other files
like json and markdown which are the only types that deviate from
Cadence's global default.
2026-03-24 19:08:29 +00:00
Bea
2a6fec1cc9
Merge branch 'msc4144' into testing 2026-03-24 16:47:57 +00:00
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
Bea
8ff61a5499
Look up thread IDs if channels cannot be found 2026-03-20 13:40:05 +00:00
7 changed files with 209 additions and 5 deletions

16
.editorconfig Normal file
View file

@ -0,0 +1,16 @@
root = true
[*]
indent_style = tab
tab_width = 3
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
*

5
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"recommendations": [
"editorconfig.editorconfig"
]
}

View file

@ -51,10 +51,24 @@ const preparedInsert = backfill.prepare("INSERT INTO backfill (channel_id, messa
async function event(event) {
if (event.t !== "GUILD_CREATE") return
const channel = event.d.channels.find(c => c.id === channelID)
if (!channel) return
let channel = event.d.channels.find(c => c.id === channelID) || (event.d.threads || []).find(c => c.id === channelID)
const guild_id = event.d.id
if (!channel) {
// May be an archived thread not present in GUILD_CREATE data - try fetching via API
try {
const fetched = await discord.snow.channel.getChannel(channelID)
if (!fetched.guild_id || fetched.guild_id !== guild_id) return
fetched.guild_id = guild_id
discord.channels.set(fetched.id, fetched)
channel = fetched
} catch (e) {
return
}
}
if (!channel) return
try {
await createRoom.syncRoom(channelID)
let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"

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))
.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 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 {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
@ -70,9 +83,9 @@ async function _interact({guild_id, data}, {api}) {
author: {
name,
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,
fields: [{
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)
if (member.displayname) displayName = member.displayname
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),
// put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
@ -799,6 +811,10 @@ async function eventToMessage(event, guild, channel, di) {
if (shouldProcessTextEvent) {
if (event.content.format === "org.matrix.custom.html" && 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") {
input = `* ${displayName} ${input}`
}
@ -944,6 +960,10 @@ async function eventToMessage(event, guild, channel, di) {
} else {
// Looks like we're using the plaintext 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") {
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 => {
t.deepEqual(
await eventToMessage({