openclaw - 💡(How to fix) Fix [Bug]: Plugin approval delivers duplicate messages on Telegram — forwarder fallback and native runtime both send when turnSourceChannel is null [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#75749Fetched 2026-05-02 05:30:48
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Author
Timeline (top)
commented ×1cross-referenced ×1

Root Cause

Two independent delivery paths exist for plugin approvals on Telegram:

PathMechanismCode
A (Forwarder fallback)exec-approval-forwarder.tsdeliverOutboundPayloadssrc/infra/exec-approval-forwarder.ts:createApprovalHandlers
B (Native Runtime)Telegram Bot native approval with inline buttonsextensions/telegram/src/approval-handler.runtime.ts

These two paths are meant to be mutually exclusive. Path A checks shouldSuppressForwardingFallback before delivering. The suppression logic is configured in telegramNativeApprovalCapability:

requireMatchingTurnSourceChannel: true,

This causes the suppression check (src/plugin-sdk/approval-delivery-helpers.ts) to short-circuit when turnSourceChannel is null:

shouldSuppressForwardingFallback: (input) => {
    // ...
    if (params.requireMatchingTurnSourceChannel) {  // true for Telegram
        const turnSourceChannel = normalizeMessageChannel(
            input.request.request.turnSourceChannel,  // null → undefined
        );
        if (turnSourceChannel !== params.channel) {   // undefined !== "telegram" → true
            return false;  // ❌ Suppression FAILS immediately
        }
    }
    // isNativeDeliveryEnabled check — never reached when turnSourceChannel is null
}

Meanwhile, Path B (native runtime) has different logic in matchesTelegramRequestAccount:

function matchesTelegramRequestAccount(params) {
    const turnSourceChannel = normalizeLowercaseStringOrEmpty(
      params.request.request.turnSourceChannel
    );
    // When turnSourceChannel is null → "" (falsy)
    
    if (turnSourceChannel && turnSourceChannel !== "telegram" && !boundAccountId) {
        // Skipped because turnSourceChannel is ""
    }
    
    // Falls through to session binding check:
    return !boundAccountId || !params.accountId || 
           normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId);
    // With valid sessionKey, boundAccountId resolves from session entry → matches → handles
}

Result: When turnSourceChannel is null:

  • Path A: does NOT suppress → delivers message ✅
  • Path B: DOES match via session binding → delivers message ✅
  • User receives 2 identical messages

Code Example

requireMatchingTurnSourceChannel: true,

---

shouldSuppressForwardingFallback: (input) => {
    // ...
    if (params.requireMatchingTurnSourceChannel) {  // true for Telegram
        const turnSourceChannel = normalizeMessageChannel(
            input.request.request.turnSourceChannel,  // null → undefined
        );
        if (turnSourceChannel !== params.channel) {   // undefined !== "telegram" → true
            return false;  // ❌ Suppression FAILS immediately
        }
    }
    // isNativeDeliveryEnabled check — never reached when turnSourceChannel is null
}

---

function matchesTelegramRequestAccount(params) {
    const turnSourceChannel = normalizeLowercaseStringOrEmpty(
      params.request.request.turnSourceChannel
    );
    // When turnSourceChannel is null → "" (falsy)
    
    if (turnSourceChannel && turnSourceChannel !== "telegram" && !boundAccountId) {
        // Skipped because turnSourceChannel is ""
    }
    
    // Falls through to session binding check:
    return !boundAccountId || !params.accountId || 
           normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId);
    // With valid sessionKey, boundAccountId resolves from session entry → matches → handles
}

---

if (params.requireMatchingTurnSourceChannel) {
      const turnSourceChannel = normalizeMessageChannel(
          input.request.request.turnSourceChannel,
      );
-     if (turnSourceChannel !== params.channel) {
+     const effectiveChannel = turnSourceChannel ?? resolveEffectiveChannelFromSession(input);
+     if (effectiveChannel !== params.channel) {
          return false;
      }
  }
RAW_BUFFERClick to expand / collapse

Bug Description

When a plugin approval request is created without turnSourceChannel set (or set to null), two identical approval messages are delivered to the Telegram chat — one from the forwarding fallback path and one from the Telegram native approval runtime.

This happens consistently: every plugin approval request produces a duplicate prompt in the Telegram DM.

Root Cause

Two independent delivery paths exist for plugin approvals on Telegram:

PathMechanismCode
A (Forwarder fallback)exec-approval-forwarder.tsdeliverOutboundPayloadssrc/infra/exec-approval-forwarder.ts:createApprovalHandlers
B (Native Runtime)Telegram Bot native approval with inline buttonsextensions/telegram/src/approval-handler.runtime.ts

These two paths are meant to be mutually exclusive. Path A checks shouldSuppressForwardingFallback before delivering. The suppression logic is configured in telegramNativeApprovalCapability:

requireMatchingTurnSourceChannel: true,

This causes the suppression check (src/plugin-sdk/approval-delivery-helpers.ts) to short-circuit when turnSourceChannel is null:

shouldSuppressForwardingFallback: (input) => {
    // ...
    if (params.requireMatchingTurnSourceChannel) {  // true for Telegram
        const turnSourceChannel = normalizeMessageChannel(
            input.request.request.turnSourceChannel,  // null → undefined
        );
        if (turnSourceChannel !== params.channel) {   // undefined !== "telegram" → true
            return false;  // ❌ Suppression FAILS immediately
        }
    }
    // isNativeDeliveryEnabled check — never reached when turnSourceChannel is null
}

Meanwhile, Path B (native runtime) has different logic in matchesTelegramRequestAccount:

function matchesTelegramRequestAccount(params) {
    const turnSourceChannel = normalizeLowercaseStringOrEmpty(
      params.request.request.turnSourceChannel
    );
    // When turnSourceChannel is null → "" (falsy)
    
    if (turnSourceChannel && turnSourceChannel !== "telegram" && !boundAccountId) {
        // Skipped because turnSourceChannel is ""
    }
    
    // Falls through to session binding check:
    return !boundAccountId || !params.accountId || 
           normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId);
    // With valid sessionKey, boundAccountId resolves from session entry → matches → handles
}

Result: When turnSourceChannel is null:

  • Path A: does NOT suppress → delivers message ✅
  • Path B: DOES match via session binding → delivers message ✅
  • User receives 2 identical messages

Why turnSourceChannel can be null

The field is populated by the caller of plugin.approval.request. Currently, before_tool_call hooks do not forward the routing metadata — tracked separately in #74003.

But even after #74003 is fixed, any other code path that calls plugin.approval.request without setting turnSourceChannel would still trigger this duplicate-delivery bug.

Steps to Reproduce

  1. Configure Telegram with execApprovals enabled and an approver set
  2. Configure approvals.plugin.enabled: true
  3. Trigger a plugin approval from a Telegram session
  4. Two identical approval prompt messages appear in the Telegram chat

Proposed Fix

In shouldSuppressForwardingFallback, when turnSourceChannel is null/undefined, do not immediately short-circuit. Instead, fall back to the session binding to determine the effective channel:

  if (params.requireMatchingTurnSourceChannel) {
      const turnSourceChannel = normalizeMessageChannel(
          input.request.request.turnSourceChannel,
      );
-     if (turnSourceChannel !== params.channel) {
+     const effectiveChannel = turnSourceChannel ?? resolveEffectiveChannelFromSession(input);
+     if (effectiveChannel !== params.channel) {
          return false;
      }
  }

Where resolveEffectiveChannelFromSession would look up the session entry from the request's sessionKey and return the session's channel provider.

Alternatively: when turnSourceChannel is null, resolve the account ID first and check isNativeDeliveryEnabled — if native delivery is active, suppress the forwarder fallback.

Affected Files

  • src/plugin-sdk/approval-delivery-helpers.tsshouldSuppressForwardingFallback logic
  • extensions/telegram/src/approval-native.tstelegramNativeApprovalCapability config
  • extensions/telegram/src/exec-approvals.tsmatchesTelegramRequestAccount

Environment

  • OpenClaw: 2026.4.29
  • Channel: Telegram
  • OS: Linux

Related

  • #74003 — turnSourceChannel not passed in before_tool_call plugin approval flow
  • #57339 — Telegram plugin approval handler missing events
  • #64977 — Grammy sequentializer deadlock with plugin approvals

extent analysis

TL;DR

The most likely fix is to modify the shouldSuppressForwardingFallback logic to handle null or undefined turnSourceChannel values by falling back to session binding or checking native delivery enablement.

Guidance

  • Review the shouldSuppressForwardingFallback function in src/plugin-sdk/approval-delivery-helpers.ts to ensure it correctly handles null or undefined turnSourceChannel values.
  • Consider implementing the proposed fix by introducing a fallback to session binding or native delivery enablement checks when turnSourceChannel is null.
  • Verify that the resolveEffectiveChannelFromSession function is correctly implemented to retrieve the session's channel provider.
  • Test the changes with the provided steps to reproduce the issue to ensure the duplicate message delivery is resolved.

Example

const effectiveChannel = turnSourceChannel ?? resolveEffectiveChannelFromSession(input);
if (effectiveChannel !== params.channel) {
  return false;
}

Notes

The fix relies on the correct implementation of resolveEffectiveChannelFromSession and may require additional modifications to handle edge cases.

Recommendation

Apply the proposed workaround by modifying the shouldSuppressForwardingFallback logic to handle null or undefined turnSourceChannel values, as it directly addresses the identified root cause of the issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix [Bug]: Plugin approval delivers duplicate messages on Telegram — forwarder fallback and native runtime both send when turnSourceChannel is null [1 comments, 2 participants]