diff --git a/src/auto-reply/reply/session-delivery.test.ts b/src/auto-reply/reply/session-delivery.test.ts index bd5f0d7c27b..526a8b161be 100644 --- a/src/auto-reply/reply/session-delivery.test.ts +++ b/src/auto-reply/reply/session-delivery.test.ts @@ -1,6 +1,63 @@ import { describe, expect, it } from "vitest"; import { resolveLastChannelRaw, resolveLastToRaw } from "./session-delivery.js"; +describe("session delivery bound channel routing", () => { + it("delivers to bound external channel when persistedLastTo is set", () => { + // Session has deliveryContext bound to Telegram channel + const sessionKey = "agent:main:telegram:default:channel:C123456"; + + expect( + resolveLastChannelRaw({ + originatingChannelRaw: "INTERNAL_MESSAGE_CHANNEL", + persistedLastChannel: "telegram", + sessionKey, + }), + ).toBe("telegram"); + + expect( + resolveLastToRaw({ + originatingChannelRaw: "INTERNAL_MESSAGE_CHANNEL", + originatingToRaw: "session:inter-session-123", + persistedLastChannel: "telegram", + persistedLastTo: "C123456", + sessionKey, + }), + ).toBe("C123456"); + }); + + it("falls back to originatingToRaw when persistedLastTo is not set", () => { + // Session bound to external channel but lastTo not yet persisted + const sessionKey = "agent:main:telegram:default:channel:C123456"; + + expect( + resolveLastToRaw({ + originatingChannelRaw: "INTERNAL_MESSAGE_CHANNEL", + originatingToRaw: "session:inter-session-123", + persistedLastChannel: "telegram", + persistedLastTo: undefined, + sessionKey, + }), + ).toBe("session:inter-session-123"); + }); + + it("does not drop delivery when persistedLastTo is falsy", () => { + // Regression test: ensure we don't silently drop messages + const sessionKey = "agent:main:telegram:default:channel:C123456"; + + const result = resolveLastToRaw({ + originatingChannelRaw: "INTERNAL_MESSAGE_CHANNEL", + originatingToRaw: "session:inter-session-123", + persistedLastChannel: "telegram", + persistedLastTo: undefined, + sessionKey, + }); + + // Should NOT return undefined (which would drop the message) + expect(result).not.toBeUndefined(); + expect(result).toBe("session:inter-session-123"); + }); +}); + describe("session delivery direct-session routing overrides", () => { it.each([ "agent:main:direct:user-1", diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index 9a112a6829e..08da775f603 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -151,6 +151,16 @@ export function resolveLastToRaw(params: { } } + // Fix #34308: When channel is INTERNAL but session has a persisted external channel + // (deliveryContext) AND a persisted lastTo address, use the persisted to address for reply delivery. + if ( + originatingChannel === INTERNAL_MESSAGE_CHANNEL && + isExternalRoutingChannel(persistedChannel) && + params.persistedLastTo + ) { + return params.persistedLastTo; + } + return params.originatingToRaw || params.toRaw || params.persistedLastTo; } diff --git a/ui/src/ui/chat/copy-as-markdown.ts b/ui/src/ui/chat/copy-as-markdown.ts index 12aeb6999e6..4065f5afda9 100644 --- a/ui/src/ui/chat/copy-as-markdown.ts +++ b/ui/src/ui/chat/copy-as-markdown.ts @@ -17,11 +17,33 @@ async function copyTextToClipboard(text: string): Promise { return false; } + // Try Clipboard API first (works in secure contexts/HTTPS) try { await navigator.clipboard.writeText(text); return true; + } catch { + // Fallback for non-secure contexts (HTTP on Windows/localhost) + return copyViaExecCommand(text); + } +} + +function copyViaExecCommand(text: string): boolean { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "-9999px"; + document.body.appendChild(textarea); + + try { + textarea.focus(); + textarea.select(); + return document.execCommand("copy"); } catch { return false; + } finally { + // Always clean up the textarea element to prevent DOM leak + document.body.removeChild(textarea); } }