diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 67d397fc10..627e525924 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -180,11 +180,16 @@ def _build_discord(adapter) -> List[Dict[str, str]]: import discord as _discord # noqa: F401 — SDK presence check except ImportError: return channels + from gateway.platforms.helpers import is_discord_channel_obfuscated + for guild in client.guilds: # Forum channels (type 15): creating a message auto-spawns a thread post. forums = getattr(guild, "forum_channels", None) or [] for chs, ch_type in ((guild.text_channels, "channel"), (forums, "forum")): for ch in chs: + # Obfuscated placeholders (no VIEW_CHANNEL) can never be posted to. #90154 + if is_discord_channel_obfuscated(ch): + continue channels.append({"id": str(ch.id), "name": ch.name, "guild": guild.name, "type": ch_type}) # DM-capable users aren't reachable via guild enumeration; they come from sessions. channels.extend(_build_from_sessions("discord")) diff --git a/gateway/platforms/helpers.py b/gateway/platforms/helpers.py index d2c111fe13..3663d0b83d 100644 --- a/gateway/platforms/helpers.py +++ b/gateway/platforms/helpers.py @@ -537,6 +537,32 @@ def _chunk_newline_preferred(text, limit, len_fn): return chunks +# ─── Discord channel obfuscation (Bot API change, mandatory Nov 16 2026) ─────── +# Channels a bot lacks VIEW_CHANNEL on are still dispatched over the Gateway with the +# name replaced by "___hidden___", sensitive fields nulled and flag 1 << 17 set; over +# HTTP they are omitted. https://discord.com/developers/docs/change-log (Aug 12, 2026) +DISCORD_CHANNEL_OBFUSCATED_FLAG = 1 << 17 +DISCORD_OBFUSCATED_CHANNEL_NAME = "___hidden___" + + +def is_discord_channel_obfuscated(channel) -> bool: + """True when a discord.py channel object is an obfuscated placeholder. + + Skip these wherever guild channels are enumerated (channel directory, message + backfill): history reads and sends always fail with a permission error. Flag + first; the sentinel name is the fallback for discord.py builds that don't + expose the flag (a visible channel literally named ``___hidden___`` is then + also skipped — deliberate bias toward hiding). #90154 + """ + try: + flag_value = channel.flags.value + except AttributeError: + flag_value = None + if isinstance(flag_value, int) and flag_value & DISCORD_CHANNEL_OBFUSCATED_FLAG: + return True + return getattr(channel, "name", None) == DISCORD_OBFUSCATED_CHANNEL_NAME + + # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- # Names external plugins imported from this module before the Sep 2026 decomposition. # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index f97bd894f6..d652a4b504 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -2280,8 +2280,18 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): seen: set[str] = set() candidate_channels = [] if "*" in channel_ids: + from gateway.platforms.helpers import is_discord_channel_obfuscated + for guild in getattr(self._client, "guilds", []) or []: - candidate_channels.extend(getattr(guild, "text_channels", []) or []) + # Skip obfuscated placeholders (bot lacks VIEW_CHANNEL; + # Discord dispatches them with name "___hidden___" + + # CHANNEL_OBFUSCATED flag as of the Aug 2026 privacy + # change). History reads on them always fail. + candidate_channels.extend( + ch + for ch in (getattr(guild, "text_channels", []) or []) + if not is_discord_channel_obfuscated(ch) + ) else: for channel_id in sorted(channel_ids): channel = None diff --git a/tests/gateway/test_discord_channel_obfuscation.py b/tests/gateway/test_discord_channel_obfuscation.py new file mode 100644 index 0000000000..4142337de4 --- /dev/null +++ b/tests/gateway/test_discord_channel_obfuscation.py @@ -0,0 +1,89 @@ +"""Tests for Discord channel-obfuscation handling. + +Discord's August 2026 privacy change ("Channel Obfuscation") dispatches +channels a bot lacks VIEW_CHANNEL on with the name "___hidden___", flag +1 << 17 (CHANNEL_OBFUSCATED) set, and sensitive fields nulled. Enumeration +sites (channel directory, missed-message backfill) must skip these +placeholders — the HTTP API omits them entirely starting Nov 16, 2026. +""" + +import unittest +from types import SimpleNamespace + +from gateway.platforms.helpers import ( + DISCORD_CHANNEL_OBFUSCATED_FLAG, + DISCORD_OBFUSCATED_CHANNEL_NAME, + is_discord_channel_obfuscated, +) + + +def _channel(name="general", flag_value=0, cid=123): + return SimpleNamespace( + id=cid, + name=name, + flags=SimpleNamespace(value=flag_value), + ) + + +class TestIsDiscordChannelObfuscated(unittest.TestCase): + def test_normal_channel_not_obfuscated(self): + self.assertFalse(is_discord_channel_obfuscated(_channel())) + + def test_flag_marks_obfuscated(self): + ch = _channel(name="whatever", flag_value=DISCORD_CHANNEL_OBFUSCATED_FLAG) + self.assertTrue(is_discord_channel_obfuscated(ch)) + + def test_flag_combined_with_other_flags(self): + ch = _channel(flag_value=DISCORD_CHANNEL_OBFUSCATED_FLAG | (1 << 4)) + self.assertTrue(is_discord_channel_obfuscated(ch)) + + def test_sentinel_name_marks_obfuscated_without_flag(self): + # Older discord.py builds may not surface the new flag bit; the + # sentinel name is the fallback signal. + ch = _channel(name=DISCORD_OBFUSCATED_CHANNEL_NAME, flag_value=0) + self.assertTrue(is_discord_channel_obfuscated(ch)) + + def test_missing_flags_attribute(self): + ch = SimpleNamespace(id=1, name="ok") + self.assertFalse(is_discord_channel_obfuscated(ch)) + + def test_non_int_flag_value_falls_back_to_name(self): + ch = SimpleNamespace(id=1, name="ok", flags=SimpleNamespace(value=None)) + self.assertFalse(is_discord_channel_obfuscated(ch)) + + +class TestChannelDirectorySkipsObfuscated(unittest.TestCase): + def test_build_discord_filters_hidden_channels(self): + from gateway import channel_directory as cd + + visible = _channel(name="general", cid=1) + hidden_flag = _channel(name="secret", flag_value=DISCORD_CHANNEL_OBFUSCATED_FLAG, cid=2) + hidden_name = _channel(name=DISCORD_OBFUSCATED_CHANNEL_NAME, cid=3) + visible_forum = _channel(name="forum-open", cid=4) + hidden_forum = _channel( + name="forum-secret", flag_value=DISCORD_CHANNEL_OBFUSCATED_FLAG, cid=5 + ) + + guild = SimpleNamespace( + name="TestGuild", + text_channels=[visible, hidden_flag, hidden_name], + forum_channels=[visible_forum, hidden_forum], + ) + adapter = SimpleNamespace(_client=SimpleNamespace(guilds=[guild])) + + original = cd._build_from_sessions + cd._build_from_sessions = lambda platform: [] + try: + channels = cd._build_discord(adapter) + finally: + cd._build_from_sessions = original + + ids = {c["id"] for c in channels} + self.assertEqual(ids, {"1", "4"}) + types = {c["id"]: c["type"] for c in channels} + self.assertEqual(types["1"], "channel") + self.assertEqual(types["4"], "forum") + + +if __name__ == "__main__": + unittest.main()