Skip to content

feat(discord): add resolveTarget to Discord outbound adapter [claude, human developer oversight]#30355

Closed
zeroaltitude wants to merge 5 commits intoopenclaw:mainfrom
zeroaltitude:feat/discord-resolve-target
Closed

feat(discord): add resolveTarget to Discord outbound adapter [claude, human developer oversight]#30355
zeroaltitude wants to merge 5 commits intoopenclaw:mainfrom
zeroaltitude:feat/discord-resolve-target

Conversation

@zeroaltitude
Copy link
Copy Markdown
Contributor

Summary

  • Problem: Discord's resolveTarget adapter used a simple normalizer that silently failed on ambiguous bare numeric IDs (is 123456 a user or a channel?) and had no fallback for implicit/heartbeat delivery modes
  • Fix: Replace normalizeDiscordOutboundTarget with a proper resolveTarget implementation that validates targets via parseDiscordTarget, re-formats with explicit user:/channel: prefixes, and falls back to the main session's last delivery context for implicit/heartbeat flows
  • Scope: ~75 lines in src/channels/plugins/outbound/discord.ts, 2-line whitespace cleanup in message-handler.process.ts

What Changed

  • resolveTarget now uses parseDiscordTarget to validate and normalize targets at resolution time, catching ambiguous bare numeric IDs early with clear error messages
  • For implicit/heartbeat delivery modes (cron jobs, announce flows), falls back to the main session's deliveryContext.to when no explicit target is provided
  • Ambiguous bare numeric IDs that fail parsing also attempt session fallback before erroring

Security Impact

Low — delivery target resolution is validation-only (no new network calls, no credential handling). The session fallback reads the local session store file.

Risks

  • Session fallback reads the main agent's session store; if the store is corrupted or missing, fallback silently returns undefined (existing error path)
  • parseDiscordTarget throwing on bare numeric IDs is the expected path — the catch block handles it gracefully

AI-Assisted Development Disclosure

This PR was developed with AI assistance (OpenClaw agent). The code was:

  • Reviewed and understood by the human contributor
  • Type-checked (tsgo --noEmit passes clean)
  • Tested against the existing test suite

Validate and normalize Discord delivery targets at resolution time
instead of failing silently at send. Adds:
- parseDiscordTarget validation for ambiguous bare numeric IDs
- Session-based fallback for implicit/heartbeat delivery modes
- Clear error messages for ambiguous recipients

Co-authored-by: Eddie Abrams <eddie@bighatbio.com>
@openclaw-barnacle openclaw-barnacle Bot added channel: discord Channel integration: discord size: S labels Mar 1, 2026
@zeroaltitude zeroaltitude changed the title feat(discord): add resolveTarget to Discord outbound adapter feat(discord): add resolveTarget to Discord outbound adapter [claude, human developer oversight] Mar 1, 2026
Copy link
Copy Markdown

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

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: c781ea5adb

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const parsed = parseDiscordTarget(trimmed);
if (parsed) {
// Re-format with the proper prefix so downstream send never sees ambiguous IDs
return { ok: true, to: `${parsed.kind}:${parsed.id}` };
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 Keep unprefixed usernames for Discord target resolution

This normalization rewrites any parsed non-empty target to kind:id, so inputs like jane become channel:jane before send. In src/discord/send.shared.ts (parseAndResolveRecipient), username-to-userID directory lookup is only attempted for raw likely-username inputs, so adding channel: here bypasses that path and regresses DM-by-username behavior into a channel-id send/failure path.

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.

The resolveTarget hook runs at delivery-time resolution, before sendText/sendMedia. The downstream parseAndResolveRecipient in send.shared.ts uses resolveDiscordTarget (the async version) which does directory username lookup via isLikelyUsername. So a username like jane that hits our resolveTarget as channel:jane will still get properly resolved to a user ID in the send path.

That said, the re-prefixing here is intentional: resolveTarget validates early so callers get a clear error at resolution time rather than a cryptic failure at send time. The send path does its own resolution regardless.

Comment on lines +139 to +143
} catch {
// Bare numeric ID — ambiguous. Try the session's lastTo as a fallback.
const fallback = resolveDiscordTargetFromSession(cfg);
if (fallback) {
return { ok: true, to: fallback };
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 Avoid session fallback on non-ambiguous target parse errors

The catch block treats every parseDiscordTarget failure as an ambiguous numeric ID and falls back to the last session target; however parseDiscordTarget also throws for invalid @... user syntax (for example @bob). In that case, if a prior Discord session target exists, an explicitly malformed recipient can be silently redirected to stale context instead of failing, which can misdeliver messages to the wrong recipient.

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.

Good catch — fixed in d236f7e. The catch block now checks isAmbiguousNumeric = /^\d+$/.test(trimmed) and only falls back to session context for bare numeric IDs. Other parse errors (like @bob failing the ensureTargetId digit check) now propagate the original error directly instead of silently falling back.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 1, 2026

Greptile Summary

Replaces normalizeDiscordOutboundTarget with a comprehensive resolveTarget implementation that validates Discord delivery targets at resolution time using parseDiscordTarget, preventing ambiguous bare numeric IDs from silently failing at send time.

  • Target validation: Uses parseDiscordTarget to validate targets early, re-formatting with explicit user:/channel: prefixes for downstream send operations
  • Session fallback: For implicit/heartbeat delivery modes (cron jobs, announce flows) with no explicit target, falls back to main session's last delivery context
  • Ambiguity handling: Bare numeric IDs (e.g., 123456) trigger proper error messages directing users to use user:123456 or channel:123456
  • Previous issues resolved: The narrowed catch scope (d236f7e) ensures session fallback only applies to genuinely ambiguous bare numeric IDs, not other parse errors like @invalidUser

The implementation correctly handles edge cases and provides clear error messages for invalid inputs. The whitespace cleanup in message-handler.process.ts is cosmetic only.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The implementation is well-designed with proper error handling, validation logic, and defensive programming. Previous review issues were thoroughly addressed in commit d236f7e. The changes are localized to target resolution logic with no network calls or credential handling. Type-checking passes and the implementation follows established patterns in the codebase.
  • No files require special attention

Last reviewed commit: 3244983

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +139 to +150
} catch {
// Bare numeric ID — ambiguous. Try the session's lastTo as a fallback.
const fallback = resolveDiscordTargetFromSession(cfg);
if (fallback) {
return { ok: true, to: fallback };
}
return {
ok: false,
error: new Error(
`Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
),
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Explicit target silently replaced by session fallback

When a caller explicitly provides a bare numeric ID (e.g. to: "123456"), parseDiscordTarget throws the ambiguity error as expected. The catch block then silently discards that explicit target and substitutes the main session's last delivery context instead — potentially delivering the message to a completely different user or channel than the one the caller intended.

The caller receives { ok: true, to: "<session-fallback-value>" } with no indication that their input was ignored. This is particularly dangerous because the ambiguity error message is already correct ("Use user:123456 for DMs or channel:123456 for channel messages") — the session fallback overrides what should be an actionable error.

The PR description states the session fallback is intended for implicit/heartbeat flows (no explicit to). For cases where an explicit to was provided but is ambiguous, the error should be surfaced rather than silently swapped out:

    } catch {
      // Bare numeric ID — ambiguous. Only try the session fallback when no
      // explicit target was provided (implicit/heartbeat flows).
      if (mode === "implicit" || mode === "heartbeat") {
        const fallback = resolveDiscordTargetFromSession(cfg);
        if (fallback) {
          return { ok: true, to: fallback };
        }
      }
      return {
        ok: false,
        error: new Error(
          `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
        ),
      };
    }
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/channels/plugins/outbound/discord.ts
Line: 139-150

Comment:
**Explicit target silently replaced by session fallback**

When a caller explicitly provides a bare numeric ID (e.g. `to: "123456"`), `parseDiscordTarget` throws the ambiguity error as expected. The `catch` block then silently discards that explicit target and substitutes the main session's last delivery context instead — potentially delivering the message to a completely different user or channel than the one the caller intended.

The caller receives `{ ok: true, to: "<session-fallback-value>" }` with no indication that their input was ignored. This is particularly dangerous because the ambiguity error message is _already correct_ ("Use `user:123456` for DMs or `channel:123456` for channel messages") — the session fallback overrides what should be an actionable error.

The PR description states the session fallback is intended for *implicit/heartbeat* flows (no explicit `to`). For cases where an explicit `to` _was_ provided but is ambiguous, the error should be surfaced rather than silently swapped out:

```typescript
    } catch {
      // Bare numeric ID — ambiguous. Only try the session fallback when no
      // explicit target was provided (implicit/heartbeat flows).
      if (mode === "implicit" || mode === "heartbeat") {
        const fallback = resolveDiscordTargetFromSession(cfg);
        if (fallback) {
          return { ok: true, to: fallback };
        }
      }
      return {
        ok: false,
        error: new Error(
          `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
        ),
      };
    }
```

How can I resolve this? If you propose a fix, please make it concise.

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 in d236f7e — same root cause as the Codex comment above. The catch block now narrows to bare numeric IDs only (/^\d+$/), so @bob or other explicit-but-invalid targets surface the original parse error instead of silently substituting the session fallback.

),
};
}
return { ok: true, to: trimmed };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unreachable fallthrough returns unvalidated target

This return statement is dead code. For any non-empty trimmed string, parseDiscordTarget either returns a truthy MessagingTarget (handled by the if (parsed) branch, which always returns) or throws an Error (handled by the catch block). There is no code path in which parseDiscordTarget returns a falsy value for a non-empty input.

The danger is that if parseDiscordTarget's contract ever changes to return undefined for some inputs, this line would silently return the original, un-validated trimmed string — bypassing the validation this PR is designed to enforce. The intent can be made explicit:

Suggested change
return { ok: true, to: trimmed };
return { ok: false, error: new Error(`Unexpected: parseDiscordTarget returned undefined for "${trimmed}"`) };
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/channels/plugins/outbound/discord.ts
Line: 152

Comment:
**Unreachable fallthrough returns unvalidated target**

This `return` statement is dead code. For any non-empty `trimmed` string, `parseDiscordTarget` either returns a truthy `MessagingTarget` (handled by the `if (parsed)` branch, which always returns) or throws an `Error` (handled by the `catch` block). There is no code path in which `parseDiscordTarget` returns a falsy value for a non-empty input.

The danger is that if `parseDiscordTarget`'s contract ever changes to return `undefined` for some inputs, this line would silently return the original, un-validated `trimmed` string — bypassing the validation this PR is designed to enforce. The intent can be made explicit:

```suggestion
    return { ok: false, error: new Error(`Unexpected: parseDiscordTarget returned undefined for "${trimmed}"`) };
```

How can I resolve this? If you propose a fix, please make it concise.

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.

Correct — this was dead code. Fixed in d236f7e: moved the fallthrough return inside the try block with a clarifying comment that parseDiscordTarget only returns undefined for empty input (already handled above).

- Session fallback now only triggers for bare numeric IDs (the genuinely
  ambiguous case), not for all parse errors (e.g. @invaliduser).
- Propagate the original error from parseDiscordTarget instead of
  constructing a generic one.
- Move unreachable fallthrough return inside the try block with a
  clarifying comment.

Addresses Codex and Greptile review comments on openclaw#30355.
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

/rescore

Copy link
Copy Markdown

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

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: d236f7edfd

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +145 to +148
if (isAmbiguousNumeric) {
const fallback = resolveDiscordTargetFromSession(cfg);
if (fallback) {
return { ok: true, to: fallback };
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 Restrict ambiguous-ID fallback to implicit modes

This fallback runs even when mode is explicit, so a user-supplied bare numeric recipient can be silently replaced with the last Discord session target instead of failing fast. In the direct send path, resolveOutboundTarget defaults to explicit mode, so inputs like --to 123456 can be redirected to stale deliveryContext.to whenever session context exists, causing misdelivery rather than prompting for user:/channel: disambiguation.

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.

Good catch — fixed in 3244983. Session fallback for ambiguous bare numeric IDs is now gated on mode === "implicit" || mode === "heartbeat". Explicit sends (--to 123456) fail fast with the disambiguation error instead of silently falling back to stale deliveryContext.to.

Comment on lines +32 to +33
const mainKey = resolveAgentMainSessionKey({ cfg, agentId: "main" });
const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" });
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 Resolve fallback session key from configured main scope

The session fallback is hardcoded to agentId: "main" for both key and store-path resolution, which misses valid session state when the installation uses session.scope: "global" or a non-main default agent. In those supported configurations, the canonical main key is derived from config (not always agent:main:main), so this new fallback path quietly fails and implicit/heartbeat Discord target recovery does not work.

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.

Good catch — fixed in 752ca52. Now uses resolveMainSessionKey(cfg) which respects session.scope: "global" and picks the configured default agent instead of hardcoding "main". Also dropped the unnecessary { agentId: "main" } from resolveStorePath.

@zeroaltitude
Copy link
Copy Markdown
Contributor Author

Greptile Summary

This PR replaces the previous normalizeDiscordOutboundTarget call in the Discord outbound adapter with a proper resolveTarget implementation that validates targets via parseDiscordTarget and falls back to the main session's last delivery context for implicit/heartbeat flows (cron jobs, announce flows with no explicit recipient).

Key changes:

  • resolveTarget now parses and re-formats all targets with explicit user:/channel: prefixes, eliminating ambiguous bare numeric IDs at resolution time
  • A resolveDiscordTargetFromSession helper reads the main agent's session store to provide a fallback to when none is given (implicit/heartbeat modes)
  • Two cosmetic blank-line removals in message-handler.process.ts

Issues found:

  • The catch block for ambiguous bare numeric IDs unconditionally attempts a session fallback, even when the caller explicitly provided the numeric ID as the intended target. If a session fallback is found, the caller's explicit (ambiguous) target is silently discarded and replaced with whoever the main session last messaged — a potential misdelivery. The session fallback should be restricted to implicit/heartbeat modes, consistent with the empty-to branch above it.
  • After the try/catch, the return { ok: true, to: trimmed } line is unreachable dead code. parseDiscordTarget never returns a falsy value for a non-empty string — it either returns a valid target or throws. If its contract ever changes to return undefined, this line would silently pass an un-validated target to the downstream send layer.

Confidence Score: 2/5

  • The PR introduces a silent misdelivery risk when an explicit ambiguous target is provided — needs a fix before merging.
  • The core intent (early validation, prefixed re-formatting, implicit-mode fallback) is sound, but the catch block for ambiguous numeric IDs applies the session fallback even when the caller explicitly supplied the target. This can cause messages to be silently routed to the wrong user or channel, which is a meaningful correctness regression. The dead-code fallthrough is lower severity but a maintenance hazard.
  • Pay close attention to lines 139–152 in src/channels/plugins/outbound/discord.ts — specifically the catch block logic and the unreachable fallthrough return.

Last reviewed commit: c781ea5

Both issues fixed in d236f7e:

Catch block now gates on /^\d+$/.test(trimmed) — only genuinely ambiguous bare numeric IDs get the session fallback. @invaliduser and other parse errors propagate directly.
Unreachable fallthrough moved inside the try block with a clarifying comment.
The Codex P1 about username normalization is a false positive — resolveTarget runs before sendText, and the downstream parseAndResolveRecipient in send.shared.ts uses resolveDiscordTarget (the async version with directory username lookup). Bare usernames still resolve correctly through the full send path.

@greptile-apps please do re-review and rescore

Use resolveMainSessionKey(cfg) instead of hardcoding agentId: 'main'.
This respects session.scope: 'global' and non-default agent
configurations when resolving the fallback delivery context.

Addresses Codex review on openclaw#30355.
Explicit sends (e.g. --to 123456) now fail fast with the
disambiguation error instead of silently falling back to
stale session context. Only implicit/heartbeat modes use
the session fallback for bare numeric IDs.

Addresses Codex review on openclaw#30355.
Copy link
Copy Markdown

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

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: 324498363d

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const parsed = parseDiscordTarget(trimmed);
if (parsed) {
// Re-format with the proper prefix so downstream send never sees ambiguous IDs
return { ok: true, to: `${parsed.kind}:${parsed.id}` };
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 Keep unprefixed Discord usernames for directory resolution

Reformatting every parsed target to kind:id turns plain usernames like jane into channel:jane before delivery, which bypasses Discord username-to-user lookup and regresses DM-by-username sends. Fresh evidence: parseAndResolveRecipient relies on resolveDiscordTarget to do directory lookup for likely usernames, but channel:* is treated as an explicit channel target and skips that lookup path, so previously valid unprefixed usernames now fail or route incorrectly.

Useful? React with 👍 / 👎.

}
try {
const mainKey = resolveMainSessionKey(cfg);
const storePath = resolveStorePath(cfg.session?.store);
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 Derive fallback session store path from main-session agent

This fallback reads from resolveStorePath(cfg.session?.store) without an agent id, which resolves to the main agent store even when the configured main session key belongs to another agent (for example agent:ops:main) or when the store path is agent-templated. Fresh evidence: Discord session writes use agent-scoped store paths in processDiscordMessage (resolveStorePath(..., { agentId: route.agentId })), so this read can hit a different file and silently miss the delivery context, breaking implicit/heartbeat fallback in supported multi-agent setups.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

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

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: 0c757b67ff

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +26 to +28
function resolveDiscordTargetFromSession(
cfg: import("../../../config/config.js").OpenClawConfig | undefined,
): string | undefined {
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 Wire new Discord target resolver into adapter flow

resolveDiscordTargetFromSession is introduced here, but nothing in this adapter calls it and resolveTarget still delegates to normalizeDiscordOutboundTarget, so the new session-based fallback/validation path never executes. In practice, implicit/heartbeat sends and bare numeric IDs continue using the old normalization behavior, meaning this commit does not actually apply the new Discord target resolution logic it adds.

Useful? React with 👍 / 👎.

@zeroaltitude zeroaltitude deleted the feat/discord-resolve-target branch March 2, 2026 22:01
@invaliduser
Copy link
Copy Markdown

yeah, lgtm

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

Labels

channel: discord Channel integration: discord size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants