Skip to content

feat(channels): add Discord as a social channel - #100

Merged
paulocastellano merged 7 commits into
mainfrom
feat/discord-channel
Jun 16, 2026
Merged

feat(channels): add Discord as a social channel#100
paulocastellano merged 7 commits into
mainfrom
feat/discord-channel

Conversation

@paulocastellano

Copy link
Copy Markdown
Contributor

What

Adds Discord as a connectable social channel: connect a server via OAuth (bot authorization) and schedule/publish messages to its text channels, with mentions and rich embeds.

Modeled like the existing platforms — connect mirrors the OAuth flow (custom Socialite provider, à la Instagram), publishing mirrors the bot-token pattern (à la Telegram). Account = server (guild); the channel is chosen per post (stored in PostPlatform.meta).

How it works

  • ConnectDiscordProvider (Socialite, bot+identify+guilds scope) authorizes adding the shared bot to the user's server; the authorized guild becomes the SocialAccount. Throws (no broken account) if no server was authorized.
  • PublishDiscordPublisher posts with the global bot token:
    • validates the chosen channel_id belongs to the connected guild (prevents cross-guild posting via the shared bot);
    • downloads + optimizes media (MediaOptimizer) and uploads as attachments;
    • builds allowed_mentions only from explicit mention chips (a literal @everyone typed in shared content never pings);
    • renders rich embeds (title/description/url/image/color, clamped);
    • on a transient channel-lookup failure it raises PlatformUnavailableException so the job retries instead of failing permanently.
  • Compose — per-post channel picker (live lookup, throttled), mention autocomplete (roles/members), and an embed editor; a required-channel compliance rule blocks scheduling without a channel, and there's a Discord post preview.
  • PlumbingPlatform::Discord + ContentType::DiscordMessage (all enum arms), ConnectionVerifier health check (bot still in guild), throttled lookup endpoints, i18n in en/es/pt-BR.

Operator setup

Create a Discord application + bot and set: DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN, DISCORD_CLIENT_REDIRECT (optional: DISCORD_PERMISSIONS, DISCORD_ENABLED). Add the brand asset public/images/accounts/discord.png.

Testing

  • DiscordPublisherTest — text, mentions (allowed_mentions), embeds, channel-not-in-guild, missing channel, transient-retry, error mapping.
  • DiscordLookupTest — channel filtering, mention targets, graceful degradation on API failure, cross-workspace 403.
  • DiscordControllerTest — connect redirect, callback creates account, fails gracefully with no guild.
  • ConnectionVerifierTest — connected/disconnected guild.
  • UpdatePostRequestTest — channel required on publish, allowed on draft.
  • Full suite green (2078 passing).

Out of scope

Threads; richer per-post content overrides. The media-URL fetch follows the same pattern as every other byte-uploading publisher.

Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.

- Connect: custom Socialite Discord provider (bot scope) maps the authorized
  guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
  channel belongs to the connected guild (anti cross-guild), optimizes media,
  builds allowed_mentions only from explicit mention chips (no accidental pings),
  and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
  embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
  lookup endpoints, i18n (en/es/pt-BR), and tests.

Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
…a test

- Add a placeholder discord.png so the brand mark isn't a broken image
  (operators can swap in the official logo).
- Keep a saved Discord channel selectable before the live channel list loads
  (or if the lookup is unavailable), so editing a post never loses the channel.
- Cover the media multipart upload path (payload_json + files[N]) with a test.
…election

- Introduced caching for Discord channel lookups, reducing API calls and improving performance.
- Updated the DiscordSettings component to use a SearchableSelect for better user experience when selecting channels.
- Added new language strings for channel search and no channels found messages in English, Spanish, and Portuguese.
- Updated tests to ensure channel list caching is isolated between runs.
Persist channel_name (display-only) so the preview header shows the chosen
channel; render mentions as pills; restyle the preview to a light theme to
match the rest of the app.
…an use

Only text (0) and announcement (5) channels accept a direct message — forum
(15) and media (16) require thread creation, so they're dropped from the
picker (they returned 'non-text channel'). Channels are also filtered by the
bot's effective VIEW_CHANNEL + SEND_MESSAGES, computed from role base perms and
channel overwrites (Discord's documented algorithm), with a safe fallback to
type-only filtering when the bot's roles can't be resolved.
Add per-post Discord metrics (server member count, thread replies as comments,
and reaction counts) read live with the bot token via GET message + guild
with_counts, wired into PostMetricsFetcher. Redesign the shared post metrics
row: monochrome line icons for member/comment counts vs clean reaction pills,
so audience stats read distinctly from reactions. Applies to Telegram too.
@paulocastellano
paulocastellano merged commit 206d01f into main Jun 16, 2026
2 checks passed
@paulocastellano
paulocastellano deleted the feat/discord-channel branch June 16, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant