Fix/telegram writeback admin scope gate (#54561)

* fix(telegram): require operator.admin for legacy target writeback persistence

* Address claude feedback

* Update extensions/telegram/src/target-writeback.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Remove stray brace

* Add updated docs

* Add missing test file, address codex concerns

* Fix test formatting error

* Address comments, fix tests

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Devin Robison
2026-03-25 11:12:09 -07:00
committed by GitHub
parent 89c4c674d1
commit b7d70ade3b
18 changed files with 808 additions and 73 deletions

View File

@@ -130,6 +130,7 @@ type ChannelHandlerParams = {
forceDocument?: boolean;
silent?: boolean;
mediaLocalRoots?: readonly string[];
gatewayClientScopes?: readonly string[];
};
// Channel docking: outbound delivery delegates to plugin.outbound adapters.
@@ -250,6 +251,7 @@ function createChannelOutboundContextBase(
deps: params.deps,
silent: params.silent,
mediaLocalRoots: params.mediaLocalRoots,
gatewayClientScopes: params.gatewayClientScopes,
};
}
@@ -275,6 +277,7 @@ type DeliverOutboundPayloadsCoreParams = {
session?: OutboundSessionContext;
mirror?: DeliveryMirror;
silent?: boolean;
gatewayClientScopes?: readonly string[];
};
function collectPayloadMediaSources(payloads: ReplyPayload[]): string[] {
@@ -508,6 +511,7 @@ export async function deliverOutboundPayloads(
forceDocument: params.forceDocument,
silent: params.silent,
mirror: params.mirror,
gatewayClientScopes: params.gatewayClientScopes,
}).catch(() => null); // Best-effort — don't block delivery if queue write fails.
// Wrap onError to detect partial failures under bestEffort mode.
@@ -576,6 +580,7 @@ async function deliverOutboundPayloadsCore(
forceDocument: params.forceDocument,
silent: params.silent,
mediaLocalRoots,
gatewayClientScopes: params.gatewayClientScopes,
});
const configuredTextLimit = handler.chunker
? resolveTextChunkLimit(cfg, channel, accountId, {

View File

@@ -75,6 +75,7 @@ function buildRecoveryDeliverParams(entry: QueuedDelivery, cfg: OpenClawConfig)
forceDocument: entry.forceDocument,
silent: entry.silent,
mirror: entry.mirror,
gatewayClientScopes: entry.gatewayClientScopes,
skipQueue: true, // Prevent re-enqueueing during recovery.
} satisfies Parameters<DeliverFn>[0];
}

View File

@@ -26,6 +26,8 @@ export type QueuedDeliveryPayload = {
forceDocument?: boolean;
silent?: boolean;
mirror?: OutboundMirror;
/** Gateway caller scopes at enqueue time, preserved for recovery replay. */
gatewayClientScopes?: readonly string[];
};
export interface QueuedDelivery extends QueuedDeliveryPayload {
@@ -142,6 +144,7 @@ export async function enqueueDelivery(
forceDocument: params.forceDocument,
silent: params.silent,
mirror: params.mirror,
gatewayClientScopes: params.gatewayClientScopes,
retryCount: 0,
});
return id;

View File

@@ -125,6 +125,7 @@ describe("delivery-queue recovery", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "a",
@@ -142,6 +143,7 @@ describe("delivery-queue recovery", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "a",

View File

@@ -23,6 +23,7 @@ describe("delivery-queue storage", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "hello",
@@ -45,6 +46,7 @@ describe("delivery-queue storage", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "hello",
@@ -157,6 +159,21 @@ describe("delivery-queue storage", () => {
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(2);
});
it("persists gateway caller scopes for replay", async () => {
const id = await enqueueDelivery(
{
channel: "telegram",
to: "2",
payloads: [{ text: "b" }],
gatewayClientScopes: ["operator.write"],
},
tmpDir(),
);
const entry = readQueuedEntry(tmpDir(), id);
expect(entry.gatewayClientScopes).toEqual(["operator.write"]);
});
it("backfills lastAttemptAt for legacy retry entries during load", async () => {
const id = await enqueueDelivery(
{ channel: "whatsapp", to: "+1", payloads: [{ text: "legacy" }] },