Skip to content

feat(discord): migrate Discord integration from private repo - #439

Open
Shubhank-Jonnada wants to merge 5 commits into
masterfrom
feat/migrate-discord-from-private
Open

feat(discord): migrate Discord integration from private repo#439
Shubhank-Jonnada wants to merge 5 commits into
masterfrom
feat/migrate-discord-from-private

Conversation

@Shubhank-Jonnada

@Shubhank-Jonnada Shubhank-Jonnada commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Moves the Discord integration from the private integrations repo into this repository, following the pattern used for Teams (#397) and Agno Data Agent (#401).

Five actions against the Discord REST API v10:

Action Purpose
list_channels List the channels of the connected server
get_message_history Read channel history, paged via a before message id
send_message Post a message, optionally as a threaded reply
add_reaction Add a Unicode or custom emoji reaction
remove_reaction Remove Autohive's own reaction

Auth is platform OAuth with the bot scope. Calls authenticate with Autohive's bot token, read from DISCORD_BOT_TOKEN at call time and never stored in source. Channel-scoped actions verify the target channel belongs to the authorized guild before acting, so a workflow cannot reach into an unrelated guild.

Which version this is

The copy on master in the private repo is a legacy layout: SDK ~=1.0.2 with vendored aiohttp/jsonschema, an integration.py, and a single tests/test_discord.py. This PR migrates the modernised version instead, which was on the private repo's fix/discord-icon branch: SDK 2.x, current action-handler layout, and a real unit suite.

Changes made during the migration

  • SDK pinned to ~=2.0.1. The integration was on ~=2.0.0, which HiveUp flags as deprecated.
  • Dropped three unused OAuth scopes: messages.read, guilds, and guilds.members.read. All call sites use the bot token, so no user-token scope gated any of them. The one thing that looked scope dependent, the guild id in context.metadata, is written by the platform as guild from the OAuth callback's guild_id parameter (DiscordOAuthConnectionProvisioner.cs), which the bot scope provides, so the guild authorization check is unaffected.
  • Removed a remote CDN icon URL from config.json. It pointed at Discord's old Webflow host and was the only icon field of its kind in either repo. The bundled icon.png is now used, matching every other integration here.
  • README rewritten in the public style, including a table of the bot permissions each action requires.
  • Cleared a stale __init__.py comment that referenced a vendored SDK and a private issue number.
  • Added the Discord variables to .env.example and listed the integration in the repository README.

Review feedback addressed

Both raised by Codex review, each fixed in its own commit.

Guild authorization failed open (707220d). _verify_channel_guild only compared the channel's guild when one was present in metadata, so an absent or empty value skipped the check and the action proceeded. Since every action uses Autohive's shared bot token rather than a user token, that allowed reaching any channel that bot can see in any server. The guild is now required, and checked before the channel lookup so an unauthorized call costs no request. This also makes the four channel-scoped actions agree with list_channels, which already failed closed.

Custom emoji reactions were malformed (2a8f169). Discord needs the emoji path segment URL encoded and a custom emoji identified as name:id; a bare id fails with 10014: Unknown Emoji. The old check skipped encoding for anything alphanumeric, so a bare snowflake, precisely what config.json documented as a "custom emoji ID", went into the URL untouched. Reaction handling now accepts three shapes:

Input Handling
Unicode emoji URL encoded
Custom emoji as name:id URL encoded, used directly, no extra request
Bare custom emoji ID Name resolved from the authorized guild's emoji, sent as name:id

An id absent from the server returns an error naming it rather than Discord's opaque 10014. A blank reaction is rejected up front.

One incidental find while fixing that: config.json is kept ASCII deliberately. The SDK's Integration.load opens it without an explicit encoding, so a literal emoji in a description makes config parsing fail on any Windows machine with a cp1252 locale. The emoji examples live in the README.

Tests

21 unit tests (test_discord_unit.py): every action, guild authorization including the fail-closed cases parametrized across all four channel-scoped actions, has_more paging, threaded-reply payload, and all the emoji shapes above.

12 integration tests (test_discord_integration.py), written for this PR in 2cc085b. The private repo's live harness asserted nothing, so rather than migrate a file that passes unconditionally, these were written properly.

9 read-only:

  • list_channels returns channels, payload structure, every channel belongs to the authorized guild, missing guild rejected without any HTTP call
  • get_message_history returns messages/has_more, payload structure, limit respected, before cursor pages backwards and excludes itself, channel outside the authorized guild rejected

3 destructive, marked @pytest.mark.destructive because they write real data: send_message, threaded reply, and a reaction lifecycle (post a message, react, remove the reaction).

The live context performs real HTTP via aiohttp and supplies the guild metadata the platform injects. It deliberately does not add an Authorization header, since the integration builds its own from DISCORD_BOT_TOKEN and that is the path worth exercising. Empty 204 bodies from the reaction endpoints and non-string query values are handled in the fetch wrapper.

Running them

Read-only, safe to repeat:

pytest discord/tests/test_discord_integration.py -m "integration and not destructive"

⚠️ Destructive tests post real messages and reactions into DISCORD_CHANNEL_ID. Run deliberately, not as part of a review pass:

pytest discord/tests/test_discord_integration.py -m "integration and destructive"

Requires DISCORD_BOT_TOKEN, DISCORD_GUILD_ID, and DISCORD_CHANNEL_ID; all skip cleanly when unset. Neither set runs in CI, by both the -m unit default filter and python_files not matching test_*_integration.py. I verified all three: 9 skipped / 3 deselected read-only, 3 skipped / 9 deselected destructive, and a default pytest discord/ collecting only the unit tests.

Security review

No hardcoded secrets or credentials. The only long literals in the tests are placeholder snowflake ids (111111111111111111 and similar). The bot token is environment supplied, so nothing needs rotating as a result of this move.

Validation

hiveup validate --base-ref origin/master passes all 13 checks.

Related

Moves the Discord integration out of the private integrations repo and into
this repository. Five actions: list_channels, get_message_history,
send_message, add_reaction, and remove_reaction, all against the Discord
REST API v10.

Auth is platform OAuth with the bot scope. The bot token is read from the
DISCORD_BOT_TOKEN environment variable at call time and is not stored in
source. Channel-scoped actions verify the channel belongs to the authorized
guild before acting.

Changes made during the migration:

* Pinned the SDK to autohive-integrations-sdk~=2.0.1, since 2.0.0 is
  deprecated.
* Dropped the messages.read, guilds, and guilds.members.read OAuth scopes.
  Every call authenticates with the bot token, so no user-token scope gated
  any of them, and the guild id in connection metadata comes from the
  guild_id parameter on the OAuth callback rather than the guilds scope.
* Removed a remote CDN icon URL from config.json so the bundled icon.png is
  used, matching every other integration here.
* Rewrote the README in the public style, documenting the bot permissions
  each action needs.
* Cleared a stale __init__.py comment that referenced a vendored SDK and a
  private issue number.
* Added the Discord environment variables to .env.example and listed the
  integration in the repository README.

Security review: no hardcoded secrets or credentials. The only long literals
in the tests are placeholder snowflake ids. The legacy manual live-test
harness (test_discord_integration.py) asserts nothing and was left in the
private repo rather than migrated, matching the Teams migration in #397.

Validated with hiveup: all 13 checks pass, including 12 unit tests.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🔍 Integration Validation Results

Commit: 923f1fef5fb9dde7f23bc2c8f6f921dc808b29fd · fix(discord): validate IDs that reach a URL path
Changed directories: discord

Check Result
Structure ✅ Passed
Code ✅ Passed
Tests ✅ Passed
README ✅ Passed
Version ✅ Passed
✅ Structure Check output
Validating 1 integration(s)...

============================================================
Integration: discord
============================================================

✅ Structure valid

============================================================
SUMMARY
============================================================
Integrations validated: 1
Total errors: 0
Total warnings: 0

✅ All validations passed!
✅ Code Check output
----------------------------------------
Checking: discord
----------------------------------------

🐍 Checking Python syntax...
   ✅ Syntax OK

📥 Checking imports...
   ✅ Imports OK

📄 Checking JSON files...
   ✅ JSON files OK

🔍 Linting with ruff...
   ✅ Lint OK

🎨 Checking formatting with ruff...
   ✅ Formatting OK

🔒 Scanning for security issues with bandit...
   ✅ Security OK
   Warnings:
     ⚠️ nosec encountered (B105) in discord/tests/test_discord_unit.py:37

🛡️ Checking dependencies for vulnerabilities with pip-audit...
   ✅ Dependencies OK

🔗 Checking config-code sync...
   ✅ Config-code sync OK

🔄 Checking fetch patterns...
   ✅ Fetch patterns OK

========================================
✅ CODE CHECK PASSED
========================================
✅ Tests Check output
Integration    Tests  Coverage        Status
--------------------------------------------
discord        39/39       89%      ✅ Passed
--------------------------------------------
Total          39/39            ✅ All passed

✅ Tests passed: discord
✅ README Check output
========================================
✅ README CHECK PASSED
========================================
✅ Version Check output
✅ discord: New integration with version 2.0.0

========================================
✅ VERSION CHECK PASSED
========================================

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d28123d9cf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread discord/discord.py Outdated
Comment on lines +46 to +47
allowed_guild = context.metadata.get("guild")
if allowed_guild and response.data.get("guild_id") != allowed_guild:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed when guild metadata is absent

When the connection metadata does not contain guild (for example if the platform stores the OAuth callback value under guild_id or metadata is missing), this condition skips the guild comparison entirely. Because the actions authenticate with Autohive's shared bot token, get_message_history, send_message, and reaction actions can then operate on any channel ID where that bot is installed instead of failing closed for the connected server.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now fails closed. The guild is required and checked before the channel lookup, so a call without it costs no request, and the four channel-scoped actions agree with list_channels which already failed closed. Added unit coverage parametrized across all four actions plus the None and empty-string cases - 707220d

One correction on the cause: the platform does store the value under guild, not guild_id. DiscordOAuthConnectionProvisioner.cs serializes the connection metadata as guild = tokenGuild.Id, so the key matches what the integration reads. The fail-open you identified is real regardless, since absent or empty metadata skipped the comparison entirely.

Comment thread discord/discord.py Outdated
Comment on lines +114 to +116
emoji = inputs["reaction"]
if not emoji.isalnum():
emoji = quote(emoji)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept the custom emoji format Discord requires

When a workflow follows the schema/README and supplies a custom emoji ID, isalnum() leaves the raw snowflake in the reaction URL; Discord requires custom emoji reactions to be URL-encoded as name:id, so these add/remove calls fail with 10014: Unknown Emoji for custom emoji IDs. Please either require/build the name:id value or expose the emoji name alongside the ID. See Discord's reaction docs: https://docs.discord.com/developers/resources/message#create-reaction.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by building the name:id value, which is the more complete of the two options you offered. Reaction handling moved into _resolve_reaction_emoji, which accepts a Unicode emoji (URL encoded), a custom emoji already given as name:id (encoded, no extra request), or a bare custom emoji id, which is resolved against the authorized guild emoji list and sent as name:id. An id absent from the server returns an error naming the id instead of Discord 10014. A blank reaction is rejected up front - 2a8f169

Worth noting the old test_alphanumeric_emoji_not_encoded asserted the broken behaviour, so it is replaced by coverage of all the shapes above.

One thing surfaced while fixing this: adding a literal emoji to a config.json description breaks config parsing on Windows, because the SDK Integration.load opens the file without an explicit encoding and cp1252 cannot decode it. config.json is kept ASCII and the emoji examples live in the README.

Replaces the assertion-free live harness that was left behind in the private
repo with real pytest integration tests against the Discord REST API.

Twelve tests, nine read-only and three destructive:

* list_channels: returns channels, payload structure, every channel belongs
  to the authorized guild, and a missing guild is rejected without a call.
* get_message_history: returns messages and has_more, payload structure,
  limit is respected, the before cursor pages backwards and excludes itself,
  and a channel outside the authorized guild is rejected.
* send_message and the reaction pair are marked destructive, since they post
  real messages and reactions. The reaction test is a lifecycle: post a
  message, react to it, then remove the reaction.

The live context performs real HTTP through aiohttp and supplies the guild
metadata the platform would normally inject. It deliberately does not add an
Authorization header, because the integration builds its own from
DISCORD_BOT_TOKEN, which is what these tests need to exercise. Empty 204
bodies from the reaction endpoints and non-string query values are both
handled in the fetch wrapper.

Tests skip rather than fail when credentials are absent. Documented the
required variables and the safe and destructive run commands in the
integration README, and narrowed the .env.example block to the three
variables the tests actually read.
_verify_channel_guild only compared the channel's guild when a guild was
present in the connection metadata, so an absent or empty value skipped the
comparison and let the action proceed. Because every action authenticates
with Autohive's shared bot token rather than a user token, that meant
get_message_history, send_message, add_reaction, and remove_reaction could
operate on any channel that bot can see in any server, not just the one the
connection was authorized for.

The guild is now required, and checked before the channel lookup rather than
after, so an unauthorized call costs no request. This also makes the four
channel-scoped actions agree with list_channels, which already failed closed
on missing metadata.

Adds unit coverage parametrized across all four channel-scoped actions for
missing metadata, plus the None and empty-string guild cases.

Reported by Codex review on #439.
Discord's reaction routes need the emoji path segment URL encoded, and a
custom emoji identified as name:id. A bare id fails with 10014: Unknown
Emoji. The previous check skipped encoding for anything alphanumeric, so a
bare snowflake went into the URL untouched and add_reaction and
remove_reaction failed for exactly the input config.json documented, a
"custom emoji ID".

Reaction handling now lives in _resolve_reaction_emoji and accepts three
shapes:

* A Unicode emoji, which is URL encoded.
* A custom emoji already given as name:id, which is URL encoded and used as
  is, with no extra request.
* A bare custom emoji id, which is resolved against the authorized guild's
  emoji list and sent as name:id. An id that does not exist there returns an
  error naming the id rather than Discord's opaque 10014.

A blank reaction is now rejected up front instead of producing a malformed
URL.

Kept config.json ASCII: the SDK's Integration.load opens it without an
explicit encoding, so a literal emoji in a description makes config parsing
fail on any Windows machine with a cp1252 locale. The emoji examples live in
the README instead, which documents all three accepted forms.

test_alphanumeric_emoji_not_encoded asserted the old behaviour and is
replaced by coverage of the encoded Unicode path, the name:id path, bare-id
resolution for both add and remove, an unknown id, and a blank reaction.

Reported by Codex review on #439. Format confirmed against Discord's
reaction docs.
Every action authenticates with Autohive's shared bot token, so
_verify_channel_guild is the only thing keeping a connection inside its
own server. It validated `channel`, but two other caller-supplied values
land in the URL after that check has already passed, and the HTTP client
resolves dot segments before sending.

A message_id of

    ../../<other-channel>/messages/<id>

turned

    PUT /channels/<authorized>/messages/<crafted>/reactions/<e>/@me

into

    PUT /channels/<other-channel>/messages/<id>/reactions/<e>/@me

reaching a channel in a guild the connection was never authorized for,
using a bot token shared across customers. The reaction segment had the
same hole from the opposite direction: quote() leaves "/" unescaped by
default, so an emoji string could also rewrite the path.

_snowflake() now validates channel and message_id in every action that
builds a path from them, the emoji is quoted with safe="", and
config.json carries a matching pattern. A test pins the rewrite itself,
and disabling the validation makes it fail with the PUT landing on the
other channel, so the coverage is not vacuous.
@Shubhank-Jonnada

Copy link
Copy Markdown
Contributor Author

Audited this against the migrating-private-integration skill. The migration mechanics were already clean; the security review turned up one thing that needs attention before merge.

Step 1 — Security / safety / secrets review

Check Result
1a. Static secret scan Pass. Only env-var names, docstrings and a # nosec test_bot_token placeholder. No real values.
1b. Internal hostnames / employees / customers Pass. Zero matches.
1c. Trade secrets / proprietary logic Pass. Thin wrapper over Discord's public REST API v10. No Autohive internals.
1d. .env / credential files Pass. None present.
1e. Secret scanner Partial. Ran grep-based scanning only; mcp__github__run_secret_scanning was not available to me. Worth someone running it independently.
1f. Icon Pass. 512x512 RGBA PNG, no embedded metadata.
Step 2. Legacy artifacts Pass. No integration.py, dependencies/, or caches.
Step 3. Structure / READMEs / .env.example Pass, with one gap fixed in the private PR (below).
Step 4. Validation Pass. validate_integration.py 0 errors 0 warnings; check_code.py PASSED; 39 unit tests.

Finding: path-bound IDs could cross the guild boundary

Fixed in 923f1fe.

_verify_channel_guild is the whole security model here, because every request uses Autohive's shared bot token. It validated channel, but two other caller-supplied values reach the URL after that check passes, and the HTTP client resolves dot segments before sending.

A message_id of ../../<other-channel>/messages/<id> turned

PUT /channels/<authorized>/messages/<crafted>/reactions/<e>/@me

into

PUT /channels/<other-channel>/messages/<id>/reactions/<e>/@me

reaching a channel in a guild the connection was never authorized for. The reaction segment had the same hole from the other direction: quote() leaves / unescaped by default, so an emoji string could rewrite the path too.

Fix: _snowflake() validates channel and message_id everywhere they build a path, the emoji is quoted with safe="", and config.json carries a matching pattern. Patterns were deliberately not added to before (query param) or reference_message_id (JSON body), since neither reaches a path and constraining them would change behaviour beyond the security fix.

I checked the tests are not vacuous: disabling the validation makes them fail with the PUT landing on 999999999999999999, a channel in a different guild.

Also

  • Private README gap. Autohive-AI/integrations#169 removed the Discord section but never added it to the "Migrated to the Public Repo" list. Fixed on that branch in f3fda89.
  • Merge order is intact. Whatsapp icon #169 is still open, so Discord is currently on private master only. Public first, then private, as the PR body says.
  • Step 6 is still outstanding. The skill requires a second engineer's explicit sign-off in the AH engineering Slack channel before merging. I have not done that and cannot; given the finding above involves a shared credential crossing a tenant boundary, it is worth flagging in that thread specifically.

@Shubhank-Jonnada

Copy link
Copy Markdown
Contributor Author

@Autohive-AI review

@autohive-ai-beta autohive-ai-beta Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict Errors Warnings Suggestions
Reviewed 0 1 0

Reviewed

Summary

Adds the Discord REST integration with guild-scoped channel actions and tests. Targeted unit tests could not run because pytest is unavailable in the review container.

PR Change Flow
sequenceDiagram
    participant W as Workflow
    participant D as Discord Integration
    participant API as Discord API
    W->>D: Execute channel action
    D->>API: Verify channel guild
    API-->>D: Channel guild metadata
    D->>API: Read, post, or react
    API-->>D: Action response
    D-->>W: Action result
Loading

Review powered by Autohive

Comment thread discord/discord.py
except ValueError as e:
return ActionError(message=str(e))

limit = inputs.get("limit", 100)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A caller can supply limit=101, 0, or 1.5: the schema accepts these values and this forwards them unchanged to Discord, which only accepts an integer from 1 through 100 and responds with a 400. Validate the range and integer type here (and constrain the schema) so invalid workflow input returns a local action error instead of a failed API request.

Suggested change
limit = inputs.get("limit", 100)
limit = inputs.get("limit", 100)
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100:
return ActionError(message="'limit' must be an integer between 1 and 100.")

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