From 6e6dcfab4d4ab9a0c134db02768bcb903ff48b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kohout?= Date: Thu, 2 Jul 2026 06:45:29 +0000 Subject: [PATCH 1/3] feat(discord): tag forwarded messages with channel/thread/DM context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discord bridge forwarded message text to the conductor with no indication of where it came from. Slack messages already carry a `[from:... (id)] [channel:#... (id)]` prefix (see resolve_slack_channel); Discord did not, so the conductor could not tell which channel, thread, or DM a request originated in. Add build_discord_context_tag() and prepend it in on_message, mirroring the Slack convention. Discord additionally emits a `[thread:#name (id) in #parent]` variant for messages sent inside a thread — Slack has no inline equivalent because it routes threads via thread_ts instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GN4ktNBa9zcKUXvV6HPZu --- internal/session/conductor_bridge.py | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/session/conductor_bridge.py b/internal/session/conductor_bridge.py index ad49d3ab..a4aa3763 100755 --- a/internal/session/conductor_bridge.py +++ b/internal/session/conductor_bridge.py @@ -2242,6 +2242,31 @@ def strip_bot_mentions(text: str) -> str: return text.strip() return re.sub(rf"<@!?{bot.user.id}>", "", text).strip() + def build_discord_context_tag(message: discord.Message) -> str: + """Build a `[from:... (id)] [channel:#... (id)|thread:... in #...|dm]` prefix + so the conductor knows which Discord channel/thread/DM a message came from. + Mirrors the Slack tagging convention (see resolve_slack_channel).""" + author = message.author + author_name = getattr(author, "display_name", None) or getattr(author, "name", "?") + from_tag = f"[from:{author_name} ({author.id})]" + channel = message.channel + ct = getattr(channel, "type", None) + thread_types = ( + getattr(discord.ChannelType, "public_thread", None), + getattr(discord.ChannelType, "private_thread", None), + getattr(discord.ChannelType, "news_thread", None), + ) + if ct == getattr(discord.ChannelType, "private", None): + chan_tag = "[dm]" + elif ct in thread_types: + parent = getattr(channel, "parent", None) + parent_name = f"#{parent.name}" if parent and getattr(parent, "name", None) else "?" + chan_tag = f"[thread:#{channel.name} ({channel.id}) in {parent_name}]" + else: + chan_name = getattr(channel, "name", "?") + chan_tag = f"[channel:#{chan_name} ({channel.id})]" + return f"{from_tag} {chan_tag}" + async def should_ignore_reply_to_other(message: discord.Message) -> bool: if not ignore_replies_to_others: return False @@ -2499,6 +2524,10 @@ async def on_message(message): if not cleaned_msg: cleaned_msg = text + # Prepend Discord channel/thread/DM context so the conductor knows + # where the message came from (mirrors the Slack tagging convention). + cleaned_msg = f"{build_discord_context_tag(message)} {cleaned_msg}" + session_title = conductor_session_title(target["name"]) profile = target["profile"] From 9149355d8a465093f62810f9c36c487dfe1b7aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kohout?= Date: Thu, 2 Jul 2026 07:11:22 +0000 Subject: [PATCH 2/3] test(discord): cover build_discord_context_tag channel/thread/DM branches Addresses CodeRabbit review on #1555: the Slack tagging logic has test_slack_message_enrichment.py but build_discord_context_tag had none. Mirrors the Slack test style (a faithful mirror of the closure) and asserts the exact tag format for regular channels, public/private threads, threads with an unresolvable parent, DMs, and the author/ channel name fallbacks. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GN4ktNBa9zcKUXvV6HPZu --- .../test_discord_message_enrichment.py | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 internal/session/test_discord_message_enrichment.py diff --git a/internal/session/test_discord_message_enrichment.py b/internal/session/test_discord_message_enrichment.py new file mode 100644 index 00000000..6a8e8e60 --- /dev/null +++ b/internal/session/test_discord_message_enrichment.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Test suite for Discord message enrichment in conductor bridge. + +Tests build_discord_context_tag(), which tags messages with +[from:name (ID)] plus [channel:#name (ID)] / [thread:#name (ID) in #parent] / +[dm] before relaying to the conductor. Mirrors the Slack tagging convention +covered by test_slack_message_enrichment.py. + +The bridge defines build_discord_context_tag as a closure inside +create_discord_bot, so — as with the Slack tests — this exercises a faithful +mirror of that logic rather than importing it directly. +""" + +import unittest +from types import SimpleNamespace + + +class _ChannelType: + """Minimal stand-in for discord.ChannelType (distinct sentinels).""" + + text = "text" + private = "private" + public_thread = "public_thread" + private_thread = "private_thread" + news_thread = "news_thread" + + +discord = SimpleNamespace(ChannelType=_ChannelType) # referenced by the mirror below + + +def build_discord_context_tag(message) -> str: + """Mirror of the bridge's build_discord_context_tag (see conductor_bridge.py).""" + author = message.author + author_name = getattr(author, "display_name", None) or getattr(author, "name", "?") + from_tag = f"[from:{author_name} ({author.id})]" + channel = message.channel + ct = getattr(channel, "type", None) + thread_types = ( + getattr(discord.ChannelType, "public_thread", None), + getattr(discord.ChannelType, "private_thread", None), + getattr(discord.ChannelType, "news_thread", None), + ) + if ct == getattr(discord.ChannelType, "private", None): + chan_tag = "[dm]" + elif ct in thread_types: + parent = getattr(channel, "parent", None) + parent_name = f"#{parent.name}" if parent and getattr(parent, "name", None) else "?" + chan_tag = f"[thread:#{channel.name} ({channel.id}) in {parent_name}]" + else: + chan_name = getattr(channel, "name", "?") + chan_tag = f"[channel:#{chan_name} ({channel.id})]" + return f"{from_tag} {chan_tag}" + + +def _msg(author, channel): + return SimpleNamespace(author=author, channel=channel) + + +class TestBuildDiscordContextTag(unittest.TestCase): + """Exact-format assertions for each channel/author branch.""" + + def test_regular_channel(self): + """A message in a normal text channel is tagged [channel:#name (id)].""" + m = _msg( + SimpleNamespace(display_name="alice", name="alice_x", id=987654321), + SimpleNamespace(type=discord.ChannelType.text, name="general", id=456, parent=None), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:alice (987654321)] [channel:#general (456)]", + ) + + def test_public_thread_includes_parent(self): + """A public thread is tagged [thread:#name (id) in #parent].""" + m = _msg( + SimpleNamespace(display_name="alice", name="alice_x", id=987654321), + SimpleNamespace( + type=discord.ChannelType.public_thread, + name="deploy-thread", + id=789, + parent=SimpleNamespace(name="general"), + ), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:alice (987654321)] [thread:#deploy-thread (789) in #general]", + ) + + def test_private_thread_includes_parent(self): + """Private threads use the same [thread:...] shape as public threads.""" + m = _msg( + SimpleNamespace(display_name="alice", name="alice_x", id=987654321), + SimpleNamespace( + type=discord.ChannelType.private_thread, + name="secret", + id=791, + parent=SimpleNamespace(name="ops"), + ), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:alice (987654321)] [thread:#secret (791) in #ops]", + ) + + def test_thread_missing_parent_falls_back_to_question_mark(self): + """When a thread's parent can't be resolved, parent renders as '?'.""" + m = _msg( + SimpleNamespace(display_name="alice", name="alice_x", id=987654321), + SimpleNamespace( + type=discord.ChannelType.public_thread, name="orphan", id=790, parent=None + ), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:alice (987654321)] [thread:#orphan (790) in ?]", + ) + + def test_direct_message(self): + """A DM is tagged [dm] with no channel name.""" + m = _msg( + SimpleNamespace(display_name="alice", name="alice_x", id=987654321), + SimpleNamespace(type=discord.ChannelType.private, name=None, id=111, parent=None), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:alice (987654321)] [dm]", + ) + + def test_author_falls_back_to_username(self): + """When display_name is absent, the author's username is used.""" + m = _msg( + SimpleNamespace(name="bob", id=222), # no display_name attribute + SimpleNamespace(type=discord.ChannelType.text, name="random", id=333, parent=None), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:bob (222)] [channel:#random (333)]", + ) + + def test_author_falls_back_to_question_mark(self): + """When neither display_name nor name is present, author renders as '?'.""" + m = _msg( + SimpleNamespace(id=444), # no display_name, no name + SimpleNamespace(type=discord.ChannelType.text, name="random", id=333, parent=None), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:? (444)] [channel:#random (333)]", + ) + + def test_channel_name_falls_back_to_question_mark(self): + """When a channel has no resolvable name, it renders as #?.""" + m = _msg( + SimpleNamespace(display_name="alice", name="alice_x", id=987654321), + SimpleNamespace(type=discord.ChannelType.text, id=333, parent=None), # no name + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:alice (987654321)] [channel:#? (333)]", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 3d455a35a05327a7ecb377886aeb69904dab898a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Kohout?= Date: Fri, 3 Jul 2026 13:04:12 +0000 Subject: [PATCH 3/3] test(discord): cover news_thread branch in context-tag tests Addresses CodeRabbit nitpick on #1555: thread_types includes news_thread but no test exercised it. Adds an announcement-thread case matching the public/private thread coverage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GN4ktNBa9zcKUXvV6HPZu --- .../session/test_discord_message_enrichment.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/session/test_discord_message_enrichment.py b/internal/session/test_discord_message_enrichment.py index 6a8e8e60..db675b4f 100644 --- a/internal/session/test_discord_message_enrichment.py +++ b/internal/session/test_discord_message_enrichment.py @@ -103,6 +103,22 @@ def test_private_thread_includes_parent(self): "[from:alice (987654321)] [thread:#secret (791) in #ops]", ) + def test_news_thread_includes_parent(self): + """Announcement (news) threads use the same [thread:...] shape.""" + m = _msg( + SimpleNamespace(display_name="alice", name="alice_x", id=987654321), + SimpleNamespace( + type=discord.ChannelType.news_thread, + name="release-notes", + id=792, + parent=SimpleNamespace(name="announcements"), + ), + ) + self.assertEqual( + build_discord_context_tag(m), + "[from:alice (987654321)] [thread:#release-notes (792) in #announcements]", + ) + def test_thread_missing_parent_falls_back_to_question_mark(self): """When a thread's parent can't be resolved, parent renders as '?'.""" m = _msg(