mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 07:50:22 +00:00
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:
@@ -141,6 +141,7 @@ export type ChannelOutboundContext = {
|
||||
identity?: OutboundIdentity;
|
||||
deps?: OutboundSendDeps;
|
||||
silent?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
};
|
||||
|
||||
export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
|
||||
|
||||
@@ -552,6 +552,7 @@ export type ChannelPollContext = {
|
||||
threadId?: string | null;
|
||||
silent?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
};
|
||||
|
||||
/** Minimal base for all channel probe results. Channel-specific probes extend this. */
|
||||
|
||||
@@ -101,26 +101,40 @@ const makeContext = (): GatewayRequestContext =>
|
||||
}) as unknown as GatewayRequestContext;
|
||||
|
||||
async function runSend(params: Record<string, unknown>) {
|
||||
return await runSendWithClient(params);
|
||||
}
|
||||
|
||||
async function runSendWithClient(
|
||||
params: Record<string, unknown>,
|
||||
client?: { connect?: { scopes?: string[] } } | null,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await sendHandlers.send({
|
||||
params: params as never,
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "1", method: "send" },
|
||||
client: null,
|
||||
client: (client ?? null) as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return { respond };
|
||||
}
|
||||
|
||||
async function runPoll(params: Record<string, unknown>) {
|
||||
return await runPollWithClient(params);
|
||||
}
|
||||
|
||||
async function runPollWithClient(
|
||||
params: Record<string, unknown>,
|
||||
client?: { connect?: { scopes?: string[] } } | null,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await sendHandlers.poll({
|
||||
params: params as never,
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "1", method: "poll" },
|
||||
client: null,
|
||||
client: (client ?? null) as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return { respond };
|
||||
@@ -185,6 +199,48 @@ describe("gateway send mirroring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards gateway client scopes into outbound delivery", async () => {
|
||||
mockDeliverySuccess("m-telegram-scope");
|
||||
|
||||
await runSendWithClient(
|
||||
{
|
||||
to: "https://t.me/mychannel",
|
||||
message: "hi",
|
||||
channel: "telegram",
|
||||
idempotencyKey: "idem-telegram-scope",
|
||||
},
|
||||
{ connect: { scopes: ["operator.write"] } },
|
||||
);
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an empty gateway scope array into outbound delivery", async () => {
|
||||
mockDeliverySuccess("m-telegram-empty-scope");
|
||||
|
||||
await runSendWithClient(
|
||||
{
|
||||
to: "https://t.me/mychannel",
|
||||
message: "hi",
|
||||
channel: "telegram",
|
||||
idempotencyKey: "idem-telegram-empty-scope",
|
||||
},
|
||||
{ connect: { scopes: [] } },
|
||||
);
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
gatewayClientScopes: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty sends when neither text nor media is present", async () => {
|
||||
const { respond } = await runSend({
|
||||
to: "channel:C1",
|
||||
@@ -268,6 +324,48 @@ describe("gateway send mirroring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards gateway client scopes into outbound poll delivery", async () => {
|
||||
await runPollWithClient(
|
||||
{
|
||||
to: "https://t.me/mychannel",
|
||||
question: "Q?",
|
||||
options: ["A", "B"],
|
||||
channel: "telegram",
|
||||
idempotencyKey: "idem-poll-scope",
|
||||
},
|
||||
{ connect: { scopes: ["operator.admin"] } },
|
||||
);
|
||||
|
||||
expect(mocks.sendPoll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
to: "resolved",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an empty gateway scope array into outbound poll delivery", async () => {
|
||||
await runPollWithClient(
|
||||
{
|
||||
to: "https://t.me/mychannel",
|
||||
question: "Q?",
|
||||
options: ["A", "B"],
|
||||
channel: "telegram",
|
||||
idempotencyKey: "idem-poll-empty-scope",
|
||||
},
|
||||
{ connect: { scopes: [] } },
|
||||
);
|
||||
|
||||
expect(mocks.sendPoll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
to: "resolved",
|
||||
gatewayClientScopes: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-picks the single configured channel for poll", async () => {
|
||||
const { respond } = await runPoll({
|
||||
to: "x",
|
||||
|
||||
@@ -89,7 +89,7 @@ async function resolveRequestedChannel(params: {
|
||||
}
|
||||
|
||||
export const sendHandlers: GatewayRequestHandlers = {
|
||||
send: async ({ params, respond, context }) => {
|
||||
send: async ({ params, respond, context, client }) => {
|
||||
const p = params;
|
||||
if (!validateSendParams(p)) {
|
||||
respond(
|
||||
@@ -263,6 +263,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
gifPlayback: request.gifPlayback,
|
||||
threadId: threadId ?? null,
|
||||
deps: outboundDeps,
|
||||
gatewayClientScopes: client?.connect?.scopes ?? [],
|
||||
mirror: providedSessionKey
|
||||
? {
|
||||
sessionKey: providedSessionKey,
|
||||
@@ -332,7 +333,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
inflightMap.delete(dedupeKey);
|
||||
}
|
||||
},
|
||||
poll: async ({ params, respond, context }) => {
|
||||
poll: async ({ params, respond, context, client }) => {
|
||||
const p = params;
|
||||
if (!validatePollParams(p)) {
|
||||
respond(
|
||||
@@ -444,6 +445,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
threadId,
|
||||
silent: request.silent,
|
||||
isAnonymous: request.isAnonymous,
|
||||
gatewayClientScopes: client?.connect?.scopes ?? [],
|
||||
});
|
||||
const payload: Record<string, unknown> = {
|
||||
runId: idem,
|
||||
|
||||
296
src/gateway/server.send-telegram-target-writeback-scope.test.ts
Normal file
296
src/gateway/server.send-telegram-target-writeback-scope.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
type TelegramApiOverride,
|
||||
} from "../../extensions/telegram/src/send.js";
|
||||
import {
|
||||
clearConfigCache,
|
||||
loadConfig,
|
||||
writeConfigFile,
|
||||
type OpenClawConfig,
|
||||
} from "../config/config.js";
|
||||
import { loadCronStore, saveCronStore } from "../cron/store.js";
|
||||
import type { CronStoreFile } from "../cron/types.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import {
|
||||
getActivePluginRegistry,
|
||||
releasePinnedPluginChannelRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js";
|
||||
import { withServer } from "./test-with-server.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
type TelegramGetChat = NonNullable<TelegramApiOverride["getChat"]>;
|
||||
type TelegramSendMessage = NonNullable<TelegramApiOverride["sendMessage"]>;
|
||||
type TelegramSendPoll = NonNullable<TelegramApiOverride["sendPoll"]>;
|
||||
|
||||
function createCronStore(): CronStoreFile {
|
||||
const now = Date.now();
|
||||
return {
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "telegram-writeback-job",
|
||||
name: "Telegram writeback job",
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
state: {},
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "@mychannel",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function withTelegramGatewayWritebackFixture(
|
||||
run: (params: {
|
||||
cronStorePath: string;
|
||||
getChatMock: ReturnType<typeof vi.fn>;
|
||||
sendMessageMock: ReturnType<typeof vi.fn>;
|
||||
sendPollMock: ReturnType<typeof vi.fn>;
|
||||
installTelegramTestPlugin: () => void;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry();
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-writeback-"));
|
||||
const cronStorePath = path.join(tempDir, "cron", "jobs.json");
|
||||
const getChatMock = vi.fn();
|
||||
const sendMessageMock = vi.fn();
|
||||
const sendPollMock = vi.fn();
|
||||
const getChat: TelegramGetChat = async (...args) => {
|
||||
getChatMock(...args);
|
||||
return { id: -100321 } as unknown as Awaited<ReturnType<TelegramGetChat>>;
|
||||
};
|
||||
const sendMessage: TelegramSendMessage = async (...args) => {
|
||||
sendMessageMock(...args);
|
||||
return {
|
||||
message_id: 17,
|
||||
date: 1,
|
||||
chat: { id: "-100321" },
|
||||
} as unknown as Awaited<ReturnType<TelegramSendMessage>>;
|
||||
};
|
||||
const sendPoll: TelegramSendPoll = async (...args) => {
|
||||
sendPollMock(...args);
|
||||
return {
|
||||
message_id: 19,
|
||||
date: 1,
|
||||
chat: { id: "-100321" },
|
||||
poll: { id: "poll-1" },
|
||||
} as unknown as Awaited<ReturnType<TelegramSendPoll>>;
|
||||
};
|
||||
|
||||
const installTelegramTestPlugin = () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ cfg, to, text, accountId, gatewayClientScopes }) =>
|
||||
({
|
||||
channel: "telegram",
|
||||
...(await sendMessageTelegram(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
gatewayClientScopes,
|
||||
token: "123:abc",
|
||||
api: {
|
||||
getChat,
|
||||
sendMessage,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
sendPoll: async ({ cfg, to, poll, accountId, gatewayClientScopes, threadId }) =>
|
||||
({
|
||||
channel: "telegram",
|
||||
...(await sendPollTelegram(to, poll, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
gatewayClientScopes,
|
||||
messageThreadId:
|
||||
typeof threadId === "number" && Number.isFinite(threadId)
|
||||
? Math.trunc(threadId)
|
||||
: undefined,
|
||||
token: "123:abc",
|
||||
api: {
|
||||
getChat,
|
||||
sendPoll,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
"telegram-target-writeback-scope",
|
||||
);
|
||||
};
|
||||
|
||||
installTelegramTestPlugin();
|
||||
|
||||
try {
|
||||
await saveCronStore(cronStorePath, createCronStore());
|
||||
clearConfigCache();
|
||||
await writeConfigFile({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "gpt-5.4",
|
||||
workspace: path.join(process.env.HOME ?? ".", "openclaw"),
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123:abc",
|
||||
defaultTo: "https://t.me/mychannel",
|
||||
},
|
||||
},
|
||||
cron: {
|
||||
store: cronStorePath,
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
clearConfigCache();
|
||||
|
||||
await run({
|
||||
cronStorePath,
|
||||
getChatMock,
|
||||
sendMessageMock,
|
||||
sendPollMock,
|
||||
installTelegramTestPlugin,
|
||||
});
|
||||
} finally {
|
||||
setActivePluginRegistry(previousRegistry);
|
||||
clearConfigCache();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway Telegram target writeback scope enforcement", () => {
|
||||
it("allows operator.write delivery but skips config and cron persistence", async () => {
|
||||
await withTelegramGatewayWritebackFixture(async (params) => {
|
||||
const { cronStorePath, getChatMock, sendMessageMock } = params;
|
||||
await withServer(async (ws) => {
|
||||
await connectOk(ws, { token: "secret", scopes: ["operator.write"] });
|
||||
|
||||
const current = await rpcReq<{ hash?: string }>(ws, "config.get", {});
|
||||
expect(current.ok).toBe(true);
|
||||
expect(typeof current.payload?.hash).toBe("string");
|
||||
|
||||
const directPatch = await rpcReq(ws, "config.patch", {
|
||||
raw: JSON.stringify({
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "-100321",
|
||||
},
|
||||
},
|
||||
}),
|
||||
baseHash: current.payload?.hash,
|
||||
});
|
||||
expect(directPatch.ok).toBe(false);
|
||||
expect(directPatch.error?.message).toBe("missing scope: operator.admin");
|
||||
|
||||
const viaSend = await rpcReq(ws, "send", {
|
||||
to: "https://t.me/mychannel",
|
||||
message: "hello from send scope test",
|
||||
channel: "telegram",
|
||||
sessionKey: "main",
|
||||
idempotencyKey: "idem-send-telegram-target-writeback-operator-write",
|
||||
});
|
||||
expect(viaSend.ok).toBe(true);
|
||||
|
||||
clearConfigCache();
|
||||
const stored = loadConfig();
|
||||
const cronStore = await loadCronStore(cronStorePath);
|
||||
|
||||
expect(stored.channels?.telegram?.defaultTo).toBe("https://t.me/mychannel");
|
||||
expect(cronStore.jobs[0]?.delivery?.to).toBe("@mychannel");
|
||||
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
|
||||
expect(sendMessageMock).toHaveBeenCalledWith("-100321", "hello from send scope test", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("persists config and cron rewrites for operator.admin delivery", async () => {
|
||||
await withTelegramGatewayWritebackFixture(async (params) => {
|
||||
const { cronStorePath, getChatMock, sendMessageMock } = params;
|
||||
await withServer(async (ws) => {
|
||||
await connectOk(ws, { token: "secret", scopes: ["operator.write", "operator.admin"] });
|
||||
|
||||
const viaSend = await rpcReq(ws, "send", {
|
||||
to: "https://t.me/mychannel",
|
||||
message: "hello from admin scope test",
|
||||
channel: "telegram",
|
||||
sessionKey: "main",
|
||||
idempotencyKey: "idem-send-telegram-target-writeback-operator-admin",
|
||||
});
|
||||
expect(viaSend.ok).toBe(true);
|
||||
|
||||
clearConfigCache();
|
||||
const stored = loadConfig();
|
||||
const cronStore = await loadCronStore(cronStorePath);
|
||||
|
||||
expect(stored.channels?.telegram?.defaultTo).toBe("-100321");
|
||||
expect(cronStore.jobs[0]?.delivery?.to).toBe("-100321");
|
||||
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
|
||||
expect(sendMessageMock).toHaveBeenCalledWith("-100321", "hello from admin scope test", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("allows operator.write poll delivery but skips config and cron persistence", async () => {
|
||||
await withTelegramGatewayWritebackFixture(async (params) => {
|
||||
const { cronStorePath, getChatMock, sendPollMock, installTelegramTestPlugin } = params;
|
||||
await withServer(async (ws) => {
|
||||
releasePinnedPluginChannelRegistry();
|
||||
installTelegramTestPlugin();
|
||||
await connectOk(ws, { token: "secret", scopes: ["operator.write"] });
|
||||
|
||||
const viaPoll = await rpcReq(ws, "poll", {
|
||||
to: "https://t.me/mychannel",
|
||||
question: "Which one?",
|
||||
options: ["A", "B"],
|
||||
channel: "telegram",
|
||||
idempotencyKey: "idem-poll-telegram-target-writeback-operator-write",
|
||||
});
|
||||
if (!viaPoll.ok) {
|
||||
throw new Error(`poll failed: ${viaPoll.error?.message ?? "unknown error"}`);
|
||||
}
|
||||
expect(viaPoll.ok).toBe(true);
|
||||
|
||||
clearConfigCache();
|
||||
const stored = loadConfig();
|
||||
const cronStore = await loadCronStore(cronStorePath);
|
||||
|
||||
expect(stored.channels?.telegram?.defaultTo).toBe("https://t.me/mychannel");
|
||||
expect(cronStore.jobs[0]?.delivery?.to).toBe("@mychannel");
|
||||
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
|
||||
expect(sendPollMock).toHaveBeenCalledWith("-100321", "Which one?", ["A", "B"], {
|
||||
allows_multiple_answers: false,
|
||||
is_anonymous: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }] },
|
||||
|
||||
Reference in New Issue
Block a user