fix(irc): recognize punctuation in nickname mentions (#116758)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-07-31 03:21:31 -07:00
committed by GitHub
parent 58580fff2f
commit 873bcc2985
2 changed files with 89 additions and 1 deletions
@@ -334,6 +334,70 @@ describe("irc inbound behavior", () => {
expect(ctx?.OriginatingTo).toBe("channel:#ops");
});
it.each([
{ label: "ordinary nick", nick: "OpenClaw", text: "OpenClaw: hello", mentioned: true },
{ label: "ASCII case folding", nick: "OpenClaw", text: "openclaw: hello", mentioned: true },
{ label: "leading bracket", nick: "[Claw]", text: "[Claw]: hello", mentioned: true },
{ label: "trailing bracket", nick: "Claw]", text: "hello Claw],", mentioned: true },
{ label: "leading caret", nick: "^Claw", text: "^Claw, hello", mentioned: true },
{ label: "trailing hyphen", nick: "Claw-", text: "Claw-: hello", mentioned: true },
{ label: "escaped backslash", nick: "\\Claw", text: "\\Claw: hello", mentioned: true },
{ label: "embedded brackets", nick: "Claw[Ops]", text: "Claw[Ops]: hi", mentioned: true },
{ label: "RFC1459 opening bracket", nick: "[Claw", text: "{claw: hello", mentioned: true },
{ label: "RFC1459 opening brace", nick: "{Claw", text: "[claw: hello", mentioned: true },
{ label: "RFC1459 closing bracket", nick: "Claw]", text: "claw}: hello", mentioned: true },
{ label: "RFC1459 closing brace", nick: "Claw}", text: "claw]: hello", mentioned: true },
{ label: "RFC1459 backslash", nick: "\\Claw", text: "|claw: hello", mentioned: true },
{ label: "RFC1459 vertical bar", nick: "|Claw", text: "\\claw: hello", mentioned: true },
{ label: "RFC1459 caret", nick: "^Claw", text: "~claw: hello", mentioned: true },
{ label: "RFC1459 tilde", nick: "~Claw", text: "^claw: hello", mentioned: true },
{ label: "ordinary nick suffix", nick: "Claw", text: "Clawbot: hello", mentioned: false },
{ label: "ordinary nick prefix", nick: "Claw", text: "overClaw: hello", mentioned: false },
{ label: "IRC nick punctuation suffix", nick: "Claw", text: "Claw-bot: hi", mentioned: false },
{ label: "RFC1459 tilde nick suffix", nick: "Claw", text: "Claw~bot: hi", mentioned: false },
{
label: "punctuated nick inside a longer nick",
nick: "[Claw]",
text: "prefix[Claw]: hello",
mentioned: false,
},
])(
"recognizes only complete IRC nickname mentions: $label",
async ({ nick, text, mentioned }) => {
const coreRuntime = createPluginRuntimeMock();
const runtime = createRuntimeEnv();
setIrcRuntime(coreRuntime as never);
await handleIrcInbound({
message: createMessage({
target: "#ops",
isGroup: true,
text,
}),
account: createAccount({
nick,
config: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
groupAllowFrom: [],
groups: {
"#ops": { enabled: true, requireMention: true },
},
},
}),
config: { channels: { irc: {} } } as CoreConfig,
runtime,
sendReply: vi.fn(async () => {}),
});
expect(coreRuntime.channel.inbound.dispatch).toHaveBeenCalledTimes(mentioned ? 1 : 0);
if (!mentioned) {
expect(runtime.log).toHaveBeenCalledWith("irc: drop channel #ops (missing-mention)");
}
},
);
it("drops a spoofed sender for a host-less nick!user DM allowlist entry", async () => {
const coreRuntime = createPluginRuntimeMock();
const runtime = createRuntimeEnv();
+25 -1
View File
@@ -81,6 +81,27 @@ const ircIngressIdentity = defineStableChannelIngressIdentity({
});
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// IRC nicknames permit punctuation, so ASCII word boundaries lose valid leading/trailing chars.
const IRC_NICK_CHARACTER = String.raw`[A-Za-z0-9_\-\[\]\\\x60^{}|~]`;
const IRC_RFC1459_CASE_EQUIVALENTS = new Map([
["[", "{"],
["{", "["],
["]", "}"],
["}", "]"],
["\\", "|"],
["|", "\\"],
["^", "~"],
["~", "^"],
]);
function buildIrcNickMentionPattern(value: string): string {
return Array.from(value, (character) => {
const equivalent = IRC_RFC1459_CASE_EQUIVALENTS.get(character);
return equivalent
? `[${escapeIrcRegexLiteral(character)}${escapeIrcRegexLiteral(equivalent)}]`
: escapeIrcRegexLiteral(character);
}).join("");
}
function isBareNick(value: string): boolean {
return !value.includes("!") && !value.includes("@");
@@ -266,7 +287,10 @@ export async function handleIrcInbound(params: {
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
const mentionNick = connectedNick?.trim() || account.nick;
const explicitMentionRegex = mentionNick
? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
? new RegExp(
`(?<!${IRC_NICK_CHARACTER})${buildIrcNickMentionPattern(mentionNick)}(?!${IRC_NICK_CHARACTER})[:,]?`,
"i",
)
: null;
const wasMentioned =
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||