fix(slack): insert resolved display names literally when humanizing mentions

_humanize_user_mentions rewrites <@UID> to @DisplayName by passing the
resolved name as re.sub's replacement, where re parses it as a template.
A display name is arbitrary user-set text, so the escapes in it are the
user's characters, not regex syntax:

  dev\ops  -> re.error: bad escape \o
  a\1b     -> re.error: invalid group reference 1
  \g<0>    -> expands to the whole match, silently putting the opaque
              <@UID> back — the token this method exists to remove

The trigger-text call site sits in _handle_slack_message outside any try,
and both Bolt event handlers await it bare, so the raise takes the whole
inbound message down: every message mentioning that person is dropped.

Pass the replacement as a function instead — re does no template parsing
on the return value, so the name lands verbatim. Same shape the Matrix
adapter already uses for its outbound mention rewrite.
This commit is contained in:
Drexuxux
2026-08-04 21:47:53 +03:00
committed by Teknium
parent d3fca90fa6
commit 7c02bfce89
2 changed files with 51 additions and 1 deletions

View File

@@ -3922,7 +3922,17 @@ class SlackAdapter(BasePlatformAdapter):
# (keeps the message intact rather than emptying a mention).
display = (name or uid).strip() or uid
# Replace both the bare and labelled forms of this exact ID.
text = re.sub(rf"<@{uid}(?:\|[^>]*)?>", f"@{display}", text)
# The replacement goes in as a *function* so the resolved name is
# inserted verbatim: as a template string, ``re`` parses backslash
# escapes in it, and a display name is arbitrary user-set text.
# ``dev\ops`` raises ``re.error: bad escape \o``, ``a\1b`` raises
# on the group reference, and ``\g<0>`` silently re-injects the
# raw ``<@UID>`` this method exists to remove.
text = re.sub(
rf"<@{uid}(?:\|[^>]*)?>",
lambda _m, _name=f"@{display}": _name,
text,
)
return text
def _build_identity_prompt(self, team_id: str = "") -> str:

View File

@@ -95,6 +95,46 @@ async def test_handles_labelled_mention_form():
assert out == "@Alice Example hi"
@pytest.mark.asyncio
async def test_backslash_in_display_name_does_not_raise():
"""A display name is arbitrary user-set text. Fed to ``re.sub`` as a
replacement template it is parsed for escapes, so ``dev\\ops`` blew up
with ``re.error: bad escape \\o`` and the inbound message was lost."""
adapter = _adapter_with_names({"U07DEV": r"dev\ops"})
out = await adapter._humanize_user_mentions("ping <@U07DEV> please", chat_id="C1")
assert out == r"ping @dev\ops please"
@pytest.mark.asyncio
async def test_group_reference_in_display_name_is_literal():
"""``\\1`` in a name is a group reference in a replacement template — with
no groups in the pattern it raised ``invalid group reference``."""
adapter = _adapter_with_names({"U07ODD": r"a\1b"})
out = await adapter._humanize_user_mentions("hi <@U07ODD>", chat_id="C1")
assert out == r"hi @a\1b"
@pytest.mark.asyncio
async def test_named_group_reference_does_not_reinject_the_raw_id():
"""``\\g<0>`` expands to the whole match, silently putting the opaque
``<@UID>`` back — the exact token this method exists to remove."""
adapter = _adapter_with_names({"U07ODD": r"\g<0>"})
out = await adapter._humanize_user_mentions("hi <@U07ODD>", chat_id="C1")
assert "<@" not in out
assert out == r"hi @\g<0>"
@pytest.mark.asyncio
async def test_one_odd_name_does_not_break_the_other_mentions():
adapter = _adapter_with_names(
{"U07DEV": r"dev\ops", "U07ALICE": "Alice Example"}
)
out = await adapter._humanize_user_mentions(
"<@U07DEV> and <@U07ALICE> ship it", chat_id="C1"
)
assert out == r"@dev\ops and @Alice Example ship it"
# ----- _build_identity_prompt --------------------------------------------------
def test_identity_prompt_names_the_bot():