Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### `@Ops Lead` in a group addresses Ops Lead, not Ops as well

In a group conversation, a reply naming `@Ops Lead` also addressed a Bot called Ops, because the
shorter name matched at the same `@`, so both answered. An email address addressed a Bot by its
domain: `jo@sam.com` reached a Bot called Sam. Where two names start at the same `@`, only the
longer one is now addressed, and an `@` straight after a letter or digit is not a mention.

### A webhook with many long top-level fields no longer stops the server

A trigger's event is cut to 32 KiB before it is recorded, keeping each top-level text field up to
Expand Down
34 changes: 26 additions & 8 deletions server/src/channels/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,20 +460,38 @@ export function groupTurnMessage(
* The peers a reply addresses, in the order the reply names them.
*
* `@Name` with the whole name, case-insensitive, and not followed by more of a word, so a Bot called
* "Ops" is not addressed by "@Opsgenie". Never the speaker itself.
* "Ops" is not addressed by "@Opsgenie". Not preceded by one either, so "jo@sam.com" does not
* address "Sam". Where two names start at the same `@`, the longer one is meant: "@Ops Lead"
* addresses "Ops Lead" and not "Ops". Never the speaker itself.
*/
export function mentionedPeers(
reply: string,
roster: readonly GroupBot[],
speakerId: string,
): GroupBot[] {
return roster
.flatMap((bot) => {
if (bot.id === speakerId || !bot.name.trim()) return [];
const name = bot.name.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const at = reply.search(new RegExp(`@${name}(?![\\p{L}\\p{N}_])`, "iu"));
return at < 0 ? [] : [{ bot, at }];
})
// The speaker is matched too, so that its own longer name still hides a shorter peer's.
const hits = roster.flatMap((bot) => {
if (!bot.name.trim()) return [];
const name = bot.name.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(?<![\\p{L}\\p{N}_])@${name}(?![\\p{L}\\p{N}_])`,
"giu",
);
return Array.from(reply.matchAll(pattern), (match) => ({
bot,
at: match.index,
length: match[0].length,
}));
});
const first = new Map<string, { bot: GroupBot; at: number }>();
for (const hit of hits) {
if (hit.bot.id === speakerId || first.has(hit.bot.id)) continue;
const shadowed = hits.some(
(other) => other.at === hit.at && other.length > hit.length,
);
if (!shadowed) first.set(hit.bot.id, hit);
}
return [...first.values()]
.sort((left, right) => left.at - right.at)
.map(({ bot }) => bot);
}
Expand Down
17 changes: 17 additions & 0 deletions server/tests/group-conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,20 @@ test("a reply addresses peers by their whole @name, never itself", () => {
[],
);
});

test("a longer name at the same @ is the one addressed, and an email address is not a mention", () => {
const roster = [
{ id: "ada", name: "Ada" },
{ id: "ops", name: "Ops" },
{ id: "lead", name: "Ops Lead" },
{ id: "sam", name: "Sam" },
];
const ids = (reply: string, speaker = "ada") =>
mentionedPeers(reply, roster, speaker).map((bot) => bot.id);
expect(ids("@Ops Lead please look")).toEqual(["lead"]);
expect(ids("@Ops Lead first, then @Ops")).toEqual(["lead", "ops"]);
// The speaker's own name still counts as the longer one.
expect(ids("I, @Ops Lead, will do it", "lead")).toEqual([]);
expect(ids("write to jo@sam.com")).toEqual([]);
expect(ids("(@Sam) can you check")).toEqual(["sam"]);
});