Skip to content

[4.41/D2] Discord features: ephemeral slash responses, select values, channel allowlist, global mentions opt-in, rename threads #230

Description

@patrick-chinchill

Summary

Remaining webhook-side Discord features from chat@4.32–4.41:

  • Slash commands can be answered ephemerally through an interaction_flags callback, and the flag sticks to every follow-up.
  • Select-menu interactions report the selected value.
  • Operators can make the bot respond to every message in allowlisted channels (respond_to_channel_ids).
  • @everyone/@here count as mentions only when opted in.
  • The adapter can rename thread channels.

Upstream changes

Current Python behavior

  • src/chat_sdk/adapters/discord/adapter.py:297-303 (handle_webhook) returns {"type": DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE} with no data.flags. The forwarded-gateway path (_handle_forwarded_interaction, :625-633, and _defer_gateway_interaction, :649-668) posts {"type": response_type} only. grep -rn 'interaction_flags\|initial_response_flags' src/chat_sdk/adapters/discord finds nothing.
  • types.py:78-83 DiscordSlashCommandContext has no flags field. post_message (adapter.py:923-938) sends the slash follow-up without flags.
  • adapter.py:399-403 (_handle_component_interaction) sets value=decoded.value if decoded.value is not None else decoded.action_id and never reads data["values"].
  • adapter.py:704-711 (_handle_forwarded_message): is_mentioned is the forwarder's data.get("is_mention", False) OR a user ping OR a role ping. mention_everyone is not read. grep -rn 'respond_to_channel_ids\|RESPOND_TO\|respond_to_global' src/chat_sdk/adapters/discord finds nothing.
  • grep -rn 'set_thread_title' src/chat_sdk finds nothing.
  • types.py:20-47 DiscordAdapterConfig has no interaction_flags, respond_to_channel_ids or respond_to_global_mentions.

Scope

  • Add a DiscordInteractionFlagsContext dataclass (channel_id, command, interaction, text, user) and DiscordInteractionResponseFlag.EPHEMERAL = 64 to types.py.
  • Add these fields to DiscordAdapterConfig:
    • interaction_flags: Callable[[DiscordInteractionFlagsContext], int | None] | None
    • respond_to_channel_ids: list[str] | None
    • respond_to_global_mentions: bool | None
  • Add initial_response_flags: int | None = None to DiscordSlashCommandContext.
  • Split _handle_application_command_interaction in two:
    • _build_application_command_context(interaction) -> DiscordInteractionFlagsContext | None, which parses channel_id, command and text once.
    • A handler that takes (context, initial_response_flags, options).
  • HTTP path: compute the flags and return {"type": 5, "data": {"flags": f}} when f is not None. Forwarded path: pass the flags to _defer_gateway_interaction.
  • post_message slash follow-up: when initial_response_flags is not None, send flags = initial | payload.get("flags", 0) on the @original edit and later follow-ups.
  • Component actions: value = values[0] when data.get("values") is a non-empty list, else decoded.value if it is not None, else decoded.action_id.
  • _handle_forwarded_message: compute is_mentioned as user ping OR role ping OR (respond_to_global_mentions and data.get("mention_everyone") is True) OR (the author is not a bot and parent_channel_id in respond_to_channel_ids). Stop reading the forwarder's data["is_mention"] (decision below).
  • async set_thread_title(thread_id, title): return if there is no thread; otherwise call _resolve_thread_channel_id (from [4.41/D1] Discord correctness & security: thread-parent validation, starter-message routing, mentions/URLs, forwarded snapshots, downloads #229) and then PATCH /channels/{id} {"name": title}.
  • Resolve respond_to_channel_ids as config.respond_to_channel_ids when it is not None, else DISCORD_RESPOND_TO_CHANNEL_IDS split on , and stripped (upstream keeps empty entries; dropping them is harmless), else []. Do not copy the or in the existing mention_role_ids parse (adapter.py:137-139): an explicit [] must win over the env var (upstream ??). respond_to_global_mentions likewise uses is not None, default False.

Out of scope

Porting notes

  • interactionFlags may return undefined. Test with is not None: a return of 0 is a real flag value and must still be sent.
  • Bitfields: initial | (flags if flags is not None else 0).
  • An empty-string select value is legal. Pick values[0] whenever the list is non-empty, even if values[0] == "".
  • mention_everyone is True: strict identity, matching === true.
  • The allowlist checks the parent channel id, so messages inside threads of an allowlisted channel qualify. A top-level message in an allowlisted channel creates a thread, as a mention does today (adapter.py:714).
  • The callback is synchronous (as upstream). Python-only hardening: if it raises, log and fall back to no flags rather than failing the ACK (upstream lets it throw); record this in docs/UPSTREAM_SYNC.md.
  • Design decision: upstream no longer honours a forwarder-supplied is_mention field. Recommended default: drop it (parity, and it closes an @everyone leak for forwarders that computed it with discord.js mentions.has). Record it in docs/UPSTREAM_SYNC.md and the CHANGELOG as a behaviour change for custom forwarders.

Tests

packages/adapter-discord/src/index.test.ts is not fidelity-mapped (see #78). Port into tests/test_discord_adapter.py / tests/test_discord_extended.py:

  • "sets initial deferred slash command interaction flags from config"
  • "keeps slash command follow-up responses ephemeral"
  • "sets gateway deferred slash command interaction flags from config". Upstream drives discord.js deferReply; adapt the assertion to the Python forwarded GATEWAY_INTERACTION_CREATE path, where _defer_gateway_interaction must carry data.flags.
  • "uses selected values from select interactions"
  • "should resolve respondToChannelIds from DISCORD_RESPOND_TO_CHANNEL_IDS env var"
  • "keeps allowlisted forwarded messages in their Discord thread"
  • describe("respondToGlobalMentions handling"):
    • "ignores @everyone in forwarded gateway messages by default"
    • "treats @everyone in forwarded gateway messages as a mention when respondToGlobalMentions is true"
    • "still detects direct user mentions when @everyone is ignored"
  • describe("setThreadTitle"): "renames Discord thread channels"

N/A (drive the discord.js listener): "creates a thread for messages in an allowlisted channel", "reuses the existing thread for messages in a thread of an allowlisted channel", "does not treat a parentless thread message as allowlisted", "ignores @everyone in legacy gateway mode by default", "treats @everyone in legacy gateway mode as a mention when respondToGlobalMentions is true". Carry their assertions to the forwarded path only where not covered above.

Python-specific: a raising flags callback still ACKs; an empty-string select value is kept; an explicit respond_to_channel_ids=[] beats the env var. AsyncMock for _discord_fetch.

Acceptance criteria

  • With interaction_flags returning 64, the deferred response and every follow-up carry the ephemeral flag.
  • @everyone does not trigger mention handlers unless opted in, and allowlisted channels trigger them without a mention.
  • The full validation command from CLAUDE.md passes.
  • docs/UPSTREAM_SYNC.md records the Components V2 deferral, the forwarder is_mention decision and the flags-callback error handling.
  • A CHANGELOG entry under "Unreleased (4.41 wave)" calls out the new options and that forwarder-supplied is_mention is ignored.

Dependencies

Blocked by #229.

Metadata

  • Effort: M (~400–650 LOC including tests)
  • Consumer impact: low. It is Discord only; the one behaviour change is for custom forwarders. None for Slack/Teams.
  • Suggested branch: sync/4.41-d2

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions