Skip to content

fix(notify): immediate Discord notifications silently lost on int snowflake target#97

Merged
mruwnik merged 1 commit into
masterfrom
claude/u2-e19-5505ca3cb0b9dd102fabc64d93f30869
Jun 12, 2026
Merged

fix(notify): immediate Discord notifications silently lost on int snowflake target#97
mruwnik merged 1 commit into
masterfrom
claude/u2-e19-5505ca3cb0b9dd102fabc64d93f30869

Conversation

@mruwnik

@mruwnik mruwnik commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Problem

meta_notify_user returned {"success": true, "channel_type": "discord"} but no Discord DM ever arrived — every immediate Discord notification since ~2026-05-30 was silently dropped. The worker crashed on all 3 celery attempts with:

AttributeError: 'int' object has no attribute 'isdigit'
  scheduled_tasks.py send_via_discord → discord_data.py discord_channel_for_target → target.isdigit()

Root cause — int/str mismatch across the celery boundary

  1. User.serialize() keys discord_accounts by the int snowflake (account.id).
  2. notify_user picks that int and passes it as the celery task target; JSON serialization preserves it as int.
  3. The worker classifies channel-vs-DM via target.isdigit(), which assumes strAttributeError, task fails, message lost (no retry, no dead-letter, no DB record).

Why only the immediate path broke: scheduled notifications round-trip through ScheduledTask.notification_target (a String column), so the int is coerced to str by the DB. The immediate path (added 2026-05-29) skips the DB; the .isdigit() classification landed 2026-05-30. Each commit was fine alone — together they broke every immediate Discord send. Slack/email use string identifiers throughout and were unaffected.

Fix (layered)

  • Sourceget_notification_channel coerces the Discord id to str (slack/email were already str).
  • Classificationdiscord_channel_for_target / discord_target_is_channel accept str | int and normalize; the one canonical place that classifies a snowflake.
  • Dispatchsend_via_discord str()s the target before send_dm/send_to_channel (the collector API wants str).

Related fix — get_user public contract

User.serialize() and the _get_current_user Person fallback emitted discord_accounts keyed by int. JSON object keys are always strings, so in-process callers saw {123456789: …} while MCP clients over the wire saw {"123456789": …}. Both now coerce to str, so the shapes match.

Honesty fix — successqueued

The immediate-send return now reports {"queued": true, "scheduled": false, …} instead of success: a fire-and-forget enqueue is not a delivery confirmation and shouldn't imply one. There is no row/metric to read back. Scheduled sends keep success (a notification row is durably committed). Clients should branch on the shared scheduled key.

Tests

  • int-target classification (discord_target_is_channel) and dispatch (send_via_discord) no longer crash and route correctly
  • int → str channel coercion in get_notification_channel
  • str-keyed User.serialize() and the get_user Person fallback
  • the queued immediate-send return shape ("success" not in result)

All passing; verified across test_meta, test_meta_notify, test_users, test_notification_targets, test_scheduled_tasks, test_teams (no regressions).

🤖 Generated with Claude Code

…wflake target

meta_notify_user returned success but no Discord DM arrived: an int/str
mismatch crashed the worker. User.serialize() keys discord_accounts by the
int snowflake; notify_user passed that int as the celery task target, and the
worker's .isdigit() channel-vs-DM classification assumed str, raising
AttributeError on every attempt. Scheduled sends round-trip through a String
DB column so the int was coerced; the immediate path skipped the DB.

Fixes, layered:
- Source: get_notification_channel coerces the Discord id to str (slack/email
  were already str).
- Classification: discord_channel_for_target accepts str | int and normalizes.
- Dispatch: send_via_discord str()s the target before send_dm/send_to_channel.

Also coerce discord_accounts keys to str in User.serialize() and the
_get_current_user Person fallback, so get_user's in-process output matches the
str-keyed JSON-RPC wire shape clients already receive.

Rename the immediate-send return key success -> queued: a fire-and-forget
enqueue is not a delivery confirmation and shouldn't imply one. Scheduled
sends keep success (a row is durably committed).

Tests: int-target classification + dispatch, int->str channel coercion,
str-keyed serialize() and get_user fallback, and the queued return shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mruwnik mruwnik merged commit b5abc99 into master Jun 12, 2026
2 checks passed
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