mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
refactor(channels): drop ingress exports orphaned by the whatsapp split
This commit is contained in:
@@ -558,17 +558,6 @@ export function createDirectSessionPayload(): TelegramMessageContext["ctxPayload
|
||||
} as TelegramMessageContext["ctxPayload"];
|
||||
}
|
||||
|
||||
export function observeDeliveredReply(text: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
deliverReplies.mockImplementation(async (params: { replies?: Array<{ text?: string }> }) => {
|
||||
if (params.replies?.some((reply) => reply.text === text)) {
|
||||
resolve();
|
||||
}
|
||||
return { delivered: true };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createBot(): Bot {
|
||||
return {
|
||||
api: {
|
||||
|
||||
@@ -10,7 +10,7 @@ type TelegramUpdateProcessingFrame = {
|
||||
result?: TelegramMessageProcessingResult;
|
||||
};
|
||||
|
||||
export type TelegramSpooledReplayLifecycle = {
|
||||
type TelegramSpooledReplayLifecycle = {
|
||||
abortSignal: AbortSignal;
|
||||
onAdopted: () => void | Promise<void>;
|
||||
onDeferred: () => void;
|
||||
|
||||
@@ -42,7 +42,7 @@ const shouldDeadLetterRetryableSpooledUpdate = (
|
||||
attempt: number,
|
||||
now?: number,
|
||||
) => shouldDeadLetterRetryableIngressEvent(update, attempt, undefined, now);
|
||||
import type { TelegramSpooledUpdate } from "./telegram-ingress-spool.types.js";
|
||||
import type { TelegramSpooledUpdate } from "./telegram-ingress-spool.test-support.js";
|
||||
import type { TelegramIngressWorkerMessage } from "./telegram-ingress-worker.js";
|
||||
|
||||
const runMock = vi.hoisted(() => vi.fn());
|
||||
@@ -110,10 +110,10 @@ const pollingSessionTesting = {
|
||||
};
|
||||
// Mirrors core INGRESS_CLAIM_LEASE_MS (ingress-claim-owner).
|
||||
const telegramSpooledUpdateClaimLeaseMs = 30 * 60 * 1000;
|
||||
let claimNextTelegramSpooledUpdate: typeof import("./telegram-ingress-spool.js").claimNextTelegramSpooledUpdate;
|
||||
let listTelegramSpooledUpdateClaims: typeof import("./telegram-ingress-spool.js").listTelegramSpooledUpdateClaims;
|
||||
let listTelegramSpooledUpdates: typeof import("./telegram-ingress-spool.js").listTelegramSpooledUpdates;
|
||||
let recoverStaleTelegramSpooledUpdateClaims: typeof import("./telegram-ingress-spool.js").recoverStaleTelegramSpooledUpdateClaims;
|
||||
let claimNextTelegramSpooledUpdate: typeof import("./telegram-ingress-spool.test-support.js").claimNextTelegramSpooledUpdate;
|
||||
let listTelegramSpooledUpdateClaims: typeof import("./telegram-ingress-spool.test-support.js").listTelegramSpooledUpdateClaims;
|
||||
let listTelegramSpooledUpdates: typeof import("./telegram-ingress-spool.test-support.js").listTelegramSpooledUpdates;
|
||||
let recoverStaleTelegramSpooledUpdateClaims: typeof import("./telegram-ingress-spool.test-support.js").recoverStaleTelegramSpooledUpdateClaims;
|
||||
let writeTelegramSpooledUpdate: typeof import("./telegram-ingress-spool.js").writeTelegramSpooledUpdate;
|
||||
let createTelegramSpooledReplayDeferredParticipant: typeof import("./bot-processing-outcome.js").createTelegramSpooledReplayDeferredParticipant;
|
||||
type TelegramMessageProcessingResult =
|
||||
@@ -739,13 +739,13 @@ function startIsolatedIngressSession(params: {
|
||||
describe("TelegramPollingSession", () => {
|
||||
beforeAll(async () => {
|
||||
({ TelegramPollingSession } = await import("./polling-session.js"));
|
||||
({ writeTelegramSpooledUpdate } = await import("./telegram-ingress-spool.js"));
|
||||
({
|
||||
claimNextTelegramSpooledUpdate,
|
||||
listTelegramSpooledUpdateClaims,
|
||||
listTelegramSpooledUpdates,
|
||||
recoverStaleTelegramSpooledUpdateClaims,
|
||||
writeTelegramSpooledUpdate,
|
||||
} = await import("./telegram-ingress-spool.js"));
|
||||
} = await import("./telegram-ingress-spool.test-support.js"));
|
||||
({ createTelegramSpooledReplayDeferredParticipant } =
|
||||
await import("./bot-processing-outcome.js"));
|
||||
});
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
} from "./telegram-ingress-drain.js";
|
||||
import { openTelegramIngressQueue } from "./telegram-ingress-spool.js";
|
||||
|
||||
export type TelegramSpooledBot = {
|
||||
type TelegramSpooledBot = {
|
||||
handleUpdate: (update: never) => Promise<void>;
|
||||
};
|
||||
|
||||
export type CreateTelegramTransportIngressDrainParams = {
|
||||
type CreateTelegramTransportIngressDrainParams = {
|
||||
spoolDir: string;
|
||||
bot: TelegramSpooledBot;
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { resolveTelegramIngressNonRetryableFailure } from "./telegram-ingress-no
|
||||
import type { TelegramSpooledUpdatePayload } from "./telegram-ingress-spool.payload.js";
|
||||
import { createShouldSupersedeTelegramSpooledPending } from "./telegram-ingress-supersede.js";
|
||||
|
||||
export const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS";
|
||||
const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS";
|
||||
const TELEGRAM_SPOOLED_DRAIN_START_LIMIT = 100;
|
||||
const TELEGRAM_SPOOLED_DRAIN_SCAN_LIMIT = TELEGRAM_SPOOLED_DRAIN_START_LIMIT * 10;
|
||||
|
||||
@@ -41,7 +41,7 @@ export function resolveTelegramAdoptionStallTimeoutMs(params: {
|
||||
return DEFAULT_INGRESS_ADOPTION_STALL_MS;
|
||||
}
|
||||
|
||||
export function telegramSpooledLaneKey(update: unknown, botInfo?: TelegramBotInfo): string {
|
||||
function telegramSpooledLaneKey(update: unknown, botInfo?: TelegramBotInfo): string {
|
||||
return getTelegramSequentialKey({
|
||||
update: update as Parameters<typeof getTelegramSequentialKey>[0]["update"],
|
||||
...(botInfo ? { me: botInfo } : {}),
|
||||
@@ -56,12 +56,12 @@ export type TelegramIngressDrainLifecycle = {
|
||||
onAbandoned: () => void;
|
||||
};
|
||||
|
||||
export type TelegramIngressDrainDispatch = (
|
||||
type TelegramIngressDrainDispatch = (
|
||||
update: unknown,
|
||||
lifecycle: TelegramIngressDrainLifecycle,
|
||||
) => Promise<TelegramMessageProcessingResult | void> | TelegramMessageProcessingResult | void;
|
||||
|
||||
export type CreateTelegramIngressDrainParams = {
|
||||
type CreateTelegramIngressDrainParams = {
|
||||
queue: ChannelIngressQueue<TelegramSpooledUpdatePayload>;
|
||||
/** Required for authorization-gated supersede (numeric allowlist). */
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isTelegramMessageDispatchReplayForgetError } from "./message-dispatch-d
|
||||
const MISSING_AGENT_HARNESS_ERROR_NAME = "MissingAgentHarnessError";
|
||||
const MISSING_AGENT_HARNESS_MESSAGE_RE = /Requested agent harness "[^"]+" is not registered\./u;
|
||||
|
||||
export type TelegramIngressNonRetryableFailure = {
|
||||
type TelegramIngressNonRetryableFailure = {
|
||||
reason: "missing-agent-harness" | "dispatch-dedupe-rollback-failed";
|
||||
message: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Test-only queue inspection/claim wrappers over openTelegramIngressQueue.
|
||||
// Prod claiming lives in the core ingress drain; transport tests use these to
|
||||
// assert spool contents and drive claim states without a running drain.
|
||||
import path from "node:path";
|
||||
import {
|
||||
INGRESS_CLAIM_PROCESS_ID,
|
||||
processPidFromOwnerId,
|
||||
type ChannelIngressQueueClaim,
|
||||
type ChannelIngressQueueCorruptClaim,
|
||||
type ChannelIngressQueueRecord,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { TelegramBotInfo } from "./bot-info.js";
|
||||
import {
|
||||
openTelegramIngressQueue,
|
||||
telegramSpooledUpdateLaneKey,
|
||||
} from "./telegram-ingress-spool.js";
|
||||
import {
|
||||
TELEGRAM_SPOOLED_UPDATE_PAYLOAD_VERSION,
|
||||
type TelegramSpooledUpdatePayload,
|
||||
} from "./telegram-ingress-spool.payload.js";
|
||||
|
||||
type TelegramSpooledUpdateClaimOwner = {
|
||||
processId: string;
|
||||
processPid: number;
|
||||
claimedAt: number;
|
||||
claimToken?: string;
|
||||
};
|
||||
|
||||
export type TelegramSpooledUpdate = {
|
||||
updateId: number;
|
||||
path: string;
|
||||
update: unknown;
|
||||
receivedAt: number;
|
||||
attempts?: number;
|
||||
lastAttemptAt?: number;
|
||||
lastError?: string;
|
||||
claim?: TelegramSpooledUpdateClaimOwner;
|
||||
};
|
||||
|
||||
export type ClaimedTelegramSpooledUpdate = TelegramSpooledUpdate & {
|
||||
pendingPath: string;
|
||||
};
|
||||
|
||||
export function telegramQueueEventId(updateId: number): string {
|
||||
return String(updateId).padStart(16, "0");
|
||||
}
|
||||
|
||||
function spoolFileName(updateId: number): string {
|
||||
return `${telegramQueueEventId(updateId)}.json`;
|
||||
}
|
||||
|
||||
function processingFileName(updateId: number): string {
|
||||
return `${spoolFileName(updateId)}.processing`;
|
||||
}
|
||||
|
||||
function isValidUpdateId(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function parsePendingRecord(
|
||||
spoolDir: string,
|
||||
record: ChannelIngressQueueRecord<TelegramSpooledUpdatePayload>,
|
||||
): TelegramSpooledUpdate | null {
|
||||
const payload = record.payload;
|
||||
if (
|
||||
payload.version !== TELEGRAM_SPOOLED_UPDATE_PAYLOAD_VERSION ||
|
||||
!isValidUpdateId(payload.updateId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
updateId: payload.updateId,
|
||||
path: path.join(spoolDir, spoolFileName(payload.updateId)),
|
||||
update: payload.update,
|
||||
receivedAt: payload.receivedAt,
|
||||
attempts: record.attempts,
|
||||
...(record.lastAttemptAt === undefined ? {} : { lastAttemptAt: record.lastAttemptAt }),
|
||||
...(record.lastError === undefined ? {} : { lastError: record.lastError }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseQueueClaim(
|
||||
spoolDir: string,
|
||||
record: ChannelIngressQueueClaim<TelegramSpooledUpdatePayload>,
|
||||
): ClaimedTelegramSpooledUpdate | null {
|
||||
const update = parsePendingRecord(spoolDir, record);
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
const claimRef = record.claim.token;
|
||||
return {
|
||||
...update,
|
||||
path: path.join(spoolDir, processingFileName(update.updateId)),
|
||||
pendingPath: path.join(spoolDir, spoolFileName(update.updateId)),
|
||||
claim: {
|
||||
processId: record.claim.ownerId,
|
||||
processPid: processPidFromOwnerId(record.claim.ownerId),
|
||||
claimedAt: record.claim.claimedAt,
|
||||
claimToken: claimRef,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTelegramSpooledUpdates(params: {
|
||||
spoolDir: string;
|
||||
limit?: number | "all";
|
||||
}): Promise<TelegramSpooledUpdate[]> {
|
||||
const records = await openTelegramIngressQueue(params.spoolDir).listPending({
|
||||
limit: params.limit ?? 100,
|
||||
orderBy: "id",
|
||||
});
|
||||
return records
|
||||
.flatMap((record) => {
|
||||
const update = parsePendingRecord(params.spoolDir, record);
|
||||
return update ? [update] : [];
|
||||
})
|
||||
.toSorted((a, b) => a.updateId - b.updateId);
|
||||
}
|
||||
|
||||
export async function claimNextTelegramSpooledUpdate(params: {
|
||||
spoolDir: string;
|
||||
blockedLaneKeys?: Iterable<string>;
|
||||
botInfo?: TelegramBotInfo;
|
||||
candidateUpdateIds?: Iterable<number>;
|
||||
scanLimit?: number;
|
||||
}): Promise<ClaimedTelegramSpooledUpdate | null> {
|
||||
const queue = openTelegramIngressQueue(params.spoolDir);
|
||||
const claimed = await queue.claimNext({
|
||||
ownerId: INGRESS_CLAIM_PROCESS_ID,
|
||||
blockedLaneKeys: params.blockedLaneKeys,
|
||||
...(params.candidateUpdateIds === undefined
|
||||
? {}
|
||||
: { candidateIds: [...params.candidateUpdateIds].map(telegramQueueEventId) }),
|
||||
orderBy: "id",
|
||||
scanLimit: params.scanLimit,
|
||||
deriveLaneKey: (record) => telegramSpooledUpdateLaneKey(record.payload.update, params.botInfo),
|
||||
});
|
||||
if (!claimed) {
|
||||
return null;
|
||||
}
|
||||
const update = parseQueueClaim(params.spoolDir, claimed);
|
||||
if (update) {
|
||||
return update;
|
||||
}
|
||||
await queue.fail(claimed, {
|
||||
reason: "invalid-spooled-update",
|
||||
message: "Telegram spooled update payload was invalid.",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listTelegramSpooledUpdateClaims(params: {
|
||||
spoolDir: string;
|
||||
}): Promise<ClaimedTelegramSpooledUpdate[]> {
|
||||
const claims = await openTelegramIngressQueue(params.spoolDir).listClaims();
|
||||
return claims
|
||||
.flatMap((claim) => {
|
||||
const update = parseQueueClaim(params.spoolDir, claim);
|
||||
return update ? [update] : [];
|
||||
})
|
||||
.toSorted((a, b) => a.updateId - b.updateId);
|
||||
}
|
||||
|
||||
export async function recoverStaleTelegramSpooledUpdateClaims(params: {
|
||||
spoolDir: string;
|
||||
staleMs?: number;
|
||||
now?: number;
|
||||
shouldRecover?: (claim: ClaimedTelegramSpooledUpdate) => boolean | Promise<boolean>;
|
||||
shouldRecoverCorrupt?: (claim: ChannelIngressQueueCorruptClaim) => boolean | Promise<boolean>;
|
||||
}): Promise<number> {
|
||||
const shouldRecover = params.shouldRecover;
|
||||
const shouldRecoverCorrupt = params.shouldRecoverCorrupt;
|
||||
return await openTelegramIngressQueue(params.spoolDir).recoverStaleClaims({
|
||||
staleMs: params.staleMs ?? 0,
|
||||
...(params.now === undefined ? {} : { now: params.now }),
|
||||
...(shouldRecover
|
||||
? {
|
||||
shouldRecover: async (claim) => {
|
||||
const update = parseQueueClaim(params.spoolDir, claim);
|
||||
return update ? await shouldRecover(update) : false;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(shouldRecoverCorrupt ? { shouldRecoverCorrupt } : {}),
|
||||
});
|
||||
}
|
||||
@@ -10,13 +10,15 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { setTelegramRuntime } from "./runtime.js";
|
||||
import { clearTelegramRuntimeForTest } from "./runtime.test-support.js";
|
||||
import {
|
||||
listTelegramSpooledUpdates,
|
||||
openTelegramIngressQueue,
|
||||
resolveTelegramIngressSpoolDir,
|
||||
telegramQueueEventId,
|
||||
telegramSpooledUpdateLaneKey,
|
||||
writeTelegramSpooledUpdate,
|
||||
} from "./telegram-ingress-spool.js";
|
||||
import {
|
||||
listTelegramSpooledUpdates,
|
||||
telegramQueueEventId,
|
||||
} from "./telegram-ingress-spool.test-support.js";
|
||||
|
||||
async function withTempState<T>(
|
||||
fn: (stateDir: string, spoolDir: string) => Promise<T>,
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
// Telegram plugin module implements durable ingress enqueue + update_id mapping.
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
INGRESS_CLAIM_PROCESS_ID,
|
||||
processPidFromOwnerId,
|
||||
type ChannelIngressQueue,
|
||||
type ChannelIngressQueueClaim,
|
||||
type ChannelIngressQueueClaimRef,
|
||||
type ChannelIngressQueueCorruptClaim,
|
||||
type ChannelIngressQueueRecord,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { ChannelIngressQueue } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { computeBackoff, type BackoffPolicy } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import type { TelegramBotInfo } from "./bot-info.js";
|
||||
@@ -20,17 +12,6 @@ import {
|
||||
TELEGRAM_SPOOLED_UPDATE_PAYLOAD_VERSION,
|
||||
type TelegramSpooledUpdatePayload,
|
||||
} from "./telegram-ingress-spool.payload.js";
|
||||
import type {
|
||||
ClaimedTelegramSpooledUpdate,
|
||||
TelegramSpooledUpdate,
|
||||
} from "./telegram-ingress-spool.types.js";
|
||||
|
||||
export type {
|
||||
ClaimedTelegramSpooledUpdate,
|
||||
TelegramSpooledUpdate,
|
||||
} from "./telegram-ingress-spool.types.js";
|
||||
export type { TelegramSpooledUpdatePayload } from "./telegram-ingress-spool.payload.js";
|
||||
|
||||
const TELEGRAM_INGRESS_SPOOL_PREFIX = "ingress-spool-";
|
||||
const TELEGRAM_SPOOLED_UPDATE_FAILED_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TELEGRAM_SPOOLED_UPDATE_FAILED_MAX_ENTRIES = 1000;
|
||||
@@ -67,14 +48,10 @@ function resolveTelegramUpdateId(update: unknown): number | null {
|
||||
return isValidUpdateId(value) ? value : null;
|
||||
}
|
||||
|
||||
export function telegramQueueEventId(updateId: number): string {
|
||||
function telegramQueueEventId(updateId: number): string {
|
||||
return String(updateId).padStart(16, "0");
|
||||
}
|
||||
|
||||
function spoolFileName(updateId: number): string {
|
||||
return `${telegramQueueEventId(updateId)}.json`;
|
||||
}
|
||||
|
||||
function resolveQueueParts(spoolDir: string): {
|
||||
accountId: string;
|
||||
stateDir: string;
|
||||
@@ -157,174 +134,7 @@ export async function writeTelegramSpooledUpdate(params: {
|
||||
return updateId;
|
||||
}
|
||||
|
||||
export async function listTelegramSpooledUpdates(params: {
|
||||
spoolDir: string;
|
||||
limit?: number | "all";
|
||||
}): Promise<TelegramSpooledUpdate[]> {
|
||||
const records = await openTelegramIngressQueue(params.spoolDir).listPending({
|
||||
limit: params.limit ?? 100,
|
||||
orderBy: "id",
|
||||
});
|
||||
return records
|
||||
.flatMap((record) => {
|
||||
const update = parsePendingRecord(params.spoolDir, record);
|
||||
return update ? [update] : [];
|
||||
})
|
||||
.toSorted((a, b) => a.updateId - b.updateId);
|
||||
}
|
||||
|
||||
function parsePendingRecord(
|
||||
spoolDir: string,
|
||||
record: ChannelIngressQueueRecord<TelegramSpooledUpdatePayload>,
|
||||
): TelegramSpooledUpdate | null {
|
||||
const payload = record.payload;
|
||||
if (
|
||||
payload.version !== TELEGRAM_SPOOLED_UPDATE_PAYLOAD_VERSION ||
|
||||
!isValidUpdateId(payload.updateId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
updateId: payload.updateId,
|
||||
path: path.join(spoolDir, spoolFileName(payload.updateId)),
|
||||
update: payload.update,
|
||||
receivedAt: payload.receivedAt,
|
||||
attempts: record.attempts,
|
||||
...(record.lastAttemptAt === undefined ? {} : { lastAttemptAt: record.lastAttemptAt }),
|
||||
...(record.lastError === undefined ? {} : { lastError: record.lastError }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Backoff for irrevocable-adoption completion retries (bot-message only). */
|
||||
export function resolveSpooledUpdatePersistenceRetryDelayMs(attempt: number): number {
|
||||
return computeBackoff(TELEGRAM_SPOOLED_COMPLETION_RETRY_POLICY, attempt);
|
||||
}
|
||||
|
||||
// --- Thin queue claim helpers (transport tests + recovery tools) ---
|
||||
// Drain loops live in core; these wrap openTelegramIngressQueue only.
|
||||
|
||||
function processingFileName(updateId: number): string {
|
||||
return `${spoolFileName(updateId)}.processing`;
|
||||
}
|
||||
|
||||
function parseQueueClaim(
|
||||
spoolDir: string,
|
||||
record: ChannelIngressQueueClaim<TelegramSpooledUpdatePayload>,
|
||||
): ClaimedTelegramSpooledUpdate | null {
|
||||
const update = parsePendingRecord(spoolDir, record);
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
const claimRef = record.claim.token;
|
||||
return {
|
||||
...update,
|
||||
path: path.join(spoolDir, processingFileName(update.updateId)),
|
||||
pendingPath: path.join(spoolDir, spoolFileName(update.updateId)),
|
||||
claim: {
|
||||
processId: record.claim.ownerId,
|
||||
processPid: processPidFromOwnerId(record.claim.ownerId),
|
||||
claimedAt: record.claim.claimedAt,
|
||||
claimToken: claimRef,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function queueMutationTarget(update: TelegramSpooledUpdate): string | ChannelIngressQueueClaimRef {
|
||||
const id = telegramQueueEventId(update.updateId);
|
||||
const claimRef = update.claim?.claimToken;
|
||||
return claimRef ? { id, claim: { token: claimRef } } : id;
|
||||
}
|
||||
|
||||
export async function claimNextTelegramSpooledUpdate(params: {
|
||||
spoolDir: string;
|
||||
blockedLaneKeys?: Iterable<string>;
|
||||
botInfo?: TelegramBotInfo;
|
||||
candidateUpdateIds?: Iterable<number>;
|
||||
scanLimit?: number;
|
||||
}): Promise<ClaimedTelegramSpooledUpdate | null> {
|
||||
const queue = openTelegramIngressQueue(params.spoolDir);
|
||||
const claimed = await queue.claimNext({
|
||||
ownerId: INGRESS_CLAIM_PROCESS_ID,
|
||||
blockedLaneKeys: params.blockedLaneKeys,
|
||||
...(params.candidateUpdateIds === undefined
|
||||
? {}
|
||||
: { candidateIds: [...params.candidateUpdateIds].map(telegramQueueEventId) }),
|
||||
orderBy: "id",
|
||||
scanLimit: params.scanLimit,
|
||||
deriveLaneKey: (record) => telegramSpooledUpdateLaneKey(record.payload.update, params.botInfo),
|
||||
});
|
||||
if (!claimed) {
|
||||
return null;
|
||||
}
|
||||
const update = parseQueueClaim(params.spoolDir, claimed);
|
||||
if (update) {
|
||||
return update;
|
||||
}
|
||||
await queue.fail(claimed, {
|
||||
reason: "invalid-spooled-update",
|
||||
message: "Telegram spooled update payload was invalid.",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listTelegramSpooledUpdateClaims(params: {
|
||||
spoolDir: string;
|
||||
}): Promise<ClaimedTelegramSpooledUpdate[]> {
|
||||
const claims = await openTelegramIngressQueue(params.spoolDir).listClaims();
|
||||
return claims
|
||||
.flatMap((claim) => {
|
||||
const update = parseQueueClaim(params.spoolDir, claim);
|
||||
return update ? [update] : [];
|
||||
})
|
||||
.toSorted((a, b) => a.updateId - b.updateId);
|
||||
}
|
||||
|
||||
export async function recoverStaleTelegramSpooledUpdateClaims(params: {
|
||||
spoolDir: string;
|
||||
staleMs?: number;
|
||||
now?: number;
|
||||
shouldRecover?: (claim: ClaimedTelegramSpooledUpdate) => boolean | Promise<boolean>;
|
||||
shouldRecoverCorrupt?: (claim: ChannelIngressQueueCorruptClaim) => boolean | Promise<boolean>;
|
||||
}): Promise<number> {
|
||||
const shouldRecover = params.shouldRecover;
|
||||
const shouldRecoverCorrupt = params.shouldRecoverCorrupt;
|
||||
return await openTelegramIngressQueue(params.spoolDir).recoverStaleClaims({
|
||||
staleMs: params.staleMs ?? 0,
|
||||
...(params.now === undefined ? {} : { now: params.now }),
|
||||
...(shouldRecover
|
||||
? {
|
||||
shouldRecover: async (claim) => {
|
||||
const update = parseQueueClaim(params.spoolDir, claim);
|
||||
return update ? await shouldRecover(update) : false;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(shouldRecoverCorrupt ? { shouldRecoverCorrupt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function releaseTelegramSpooledUpdateClaim(
|
||||
update: ClaimedTelegramSpooledUpdate,
|
||||
options?: { lastError?: string; releasedAt?: number },
|
||||
): Promise<void> {
|
||||
await openTelegramIngressQueue(path.dirname(update.pendingPath)).release(
|
||||
queueMutationTarget(update),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function failTelegramSpooledUpdateClaim(params: {
|
||||
update: ClaimedTelegramSpooledUpdate;
|
||||
reason: string;
|
||||
message: string;
|
||||
now?: number;
|
||||
}): Promise<boolean> {
|
||||
return await openTelegramIngressQueue(path.dirname(params.update.pendingPath)).fail(
|
||||
queueMutationTarget(params.update),
|
||||
{
|
||||
reason: params.reason,
|
||||
message: params.message,
|
||||
...(params.now === undefined ? {} : { failedAt: params.now }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Telegram plugin module defines ingress spool record contracts.
|
||||
|
||||
export type TelegramSpooledUpdateClaimOwner = {
|
||||
processId: string;
|
||||
processPid: number;
|
||||
claimedAt: number;
|
||||
claimToken?: string;
|
||||
};
|
||||
|
||||
export type TelegramSpooledUpdate = {
|
||||
updateId: number;
|
||||
path: string;
|
||||
update: unknown;
|
||||
receivedAt: number;
|
||||
attempts?: number;
|
||||
lastAttemptAt?: number;
|
||||
lastError?: string;
|
||||
claim?: TelegramSpooledUpdateClaimOwner;
|
||||
};
|
||||
|
||||
export type ClaimedTelegramSpooledUpdate = TelegramSpooledUpdate & {
|
||||
pendingPath: string;
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
// Telegram plugin module owns supersede sender authorization policy.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveTelegramDmAllow } from "./access-groups.js";
|
||||
import { mergeTelegramAccountConfig } from "./account-config.js";
|
||||
import {
|
||||
resolveTelegramCommandAuthorization,
|
||||
resolveTelegramGroupAllowFromContext,
|
||||
resolveTelegramMessageForumFlagHint,
|
||||
} from "./bot/helpers.js";
|
||||
import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js";
|
||||
import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
|
||||
|
||||
type UpdateSenderFacts = {
|
||||
senderId: string;
|
||||
senderUsername?: string;
|
||||
chatId: number;
|
||||
chatType?: string;
|
||||
isGroup: boolean;
|
||||
isTopicMessage?: boolean;
|
||||
isForum?: boolean;
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
function extractUpdateSenderFacts(update: unknown): UpdateSenderFacts | null {
|
||||
if (!update || typeof update !== "object") {
|
||||
return null;
|
||||
}
|
||||
const root = update as Record<string, unknown>;
|
||||
let message: Record<string, unknown> | undefined;
|
||||
for (const key of ["message", "edited_message", "channel_post", "edited_channel_post"] as const) {
|
||||
const candidate = root[key];
|
||||
if (candidate && typeof candidate === "object") {
|
||||
message = candidate as Record<string, unknown>;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!message) {
|
||||
const callback = root.callback_query;
|
||||
if (callback && typeof callback === "object") {
|
||||
const cb = callback as Record<string, unknown>;
|
||||
const from = cb.from;
|
||||
const msg = cb.message;
|
||||
if (from && typeof from === "object" && msg && typeof msg === "object") {
|
||||
message = msg as Record<string, unknown>;
|
||||
const chat = (msg as { chat?: { id?: unknown; type?: unknown; is_forum?: unknown } }).chat;
|
||||
const fromObj = from as { id?: unknown; username?: unknown };
|
||||
if (typeof chat?.id === "number" && typeof fromObj.id === "number") {
|
||||
const chatType = typeof chat.type === "string" ? chat.type : "private";
|
||||
return {
|
||||
senderId: String(fromObj.id),
|
||||
...(typeof fromObj.username === "string" ? { senderUsername: fromObj.username } : {}),
|
||||
chatId: chat.id,
|
||||
chatType,
|
||||
isGroup: chatType !== "private",
|
||||
...(typeof chat.is_forum === "boolean" ? { isForum: chat.is_forum } : {}),
|
||||
...(typeof (msg as { is_topic_message?: unknown }).is_topic_message === "boolean"
|
||||
? {
|
||||
isTopicMessage: (msg as { is_topic_message: boolean }).is_topic_message,
|
||||
}
|
||||
: {}),
|
||||
...(typeof (msg as { message_thread_id?: unknown }).message_thread_id === "number"
|
||||
? {
|
||||
messageThreadId: (msg as { message_thread_id: number }).message_thread_id,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const chat = message.chat as { id?: unknown; type?: unknown; is_forum?: unknown } | undefined;
|
||||
const from = message.from as { id?: unknown; username?: unknown } | undefined;
|
||||
if (typeof chat?.id !== "number" || typeof from?.id !== "number") {
|
||||
return null;
|
||||
}
|
||||
const chatType = typeof chat.type === "string" ? chat.type : "private";
|
||||
return {
|
||||
senderId: String(from.id),
|
||||
...(typeof from.username === "string" ? { senderUsername: from.username } : {}),
|
||||
chatId: chat.id,
|
||||
chatType,
|
||||
isGroup: chatType !== "private",
|
||||
...(typeof chat.is_forum === "boolean" ? { isForum: chat.is_forum } : {}),
|
||||
...(typeof message.is_topic_message === "boolean"
|
||||
? { isTopicMessage: message.is_topic_message as boolean }
|
||||
: {}),
|
||||
...(typeof message.message_thread_id === "number"
|
||||
? { messageThreadId: message.message_thread_id as number }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ambient room_event-shaped updates (no user text body) stay supersedable. */
|
||||
export function isTelegramAmbientSpooledUpdate(update: unknown): boolean {
|
||||
if (!update || typeof update !== "object") {
|
||||
return false;
|
||||
}
|
||||
const root = update as Record<string, unknown>;
|
||||
return (
|
||||
root.message_reaction != null ||
|
||||
root.message_reaction_count != null ||
|
||||
root.chat_member != null ||
|
||||
root.my_chat_member != null ||
|
||||
root.chat_join_request != null ||
|
||||
root.chat_boost != null ||
|
||||
root.removed_chat_boost != null
|
||||
);
|
||||
}
|
||||
|
||||
export type TelegramSupersedeAuthContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
/** Bot username for @bot command targeting (from getMe / botInfo). */
|
||||
botUsername?: string;
|
||||
/** Test seam / preloaded pairing-store ids; defaults to live pairing store. */
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the raw update's sender is command-authorized.
|
||||
* Reuses resolveTelegramGroupAllowFromContext — same group/topic allowFrom
|
||||
* overrides and access-group expansion as normal message ingress.
|
||||
*/
|
||||
export async function isTelegramSpooledUpdateSenderAuthorized(
|
||||
update: unknown,
|
||||
auth: TelegramSupersedeAuthContext,
|
||||
): Promise<boolean> {
|
||||
const facts = extractUpdateSenderFacts(update);
|
||||
if (!facts) {
|
||||
return false;
|
||||
}
|
||||
const accountCfg = mergeTelegramAccountConfig(auth.cfg, auth.accountId);
|
||||
const dmPolicy = accountCfg.dmPolicy ?? "pairing";
|
||||
const allowFrom = accountCfg.allowFrom;
|
||||
const groupAllowFrom = accountCfg.groupAllowFrom ?? accountCfg.allowFrom;
|
||||
const isForum =
|
||||
resolveTelegramMessageForumFlagHint({
|
||||
chatType: facts.chatType as "private" | "group" | "supergroup" | "channel" | undefined,
|
||||
isForum: facts.isForum,
|
||||
isTopicMessage: facts.isTopicMessage,
|
||||
}) ?? false;
|
||||
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
cfg: auth.cfg,
|
||||
chatId: facts.chatId,
|
||||
accountId: auth.accountId,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
senderId: facts.senderId,
|
||||
isGroup: facts.isGroup,
|
||||
isForum,
|
||||
messageThreadId: facts.messageThreadId,
|
||||
groupAllowFrom,
|
||||
resolveTelegramGroupConfig: (chatId, messageThreadId, cfg) => {
|
||||
const telegramCfg = mergeTelegramAccountConfig(cfg, auth.accountId);
|
||||
return resolveTelegramScopedGroupConfig(telegramCfg, chatId, messageThreadId);
|
||||
},
|
||||
});
|
||||
|
||||
const { resolvedThreadId, storeAllowFrom, groupAllowOverride, effectiveGroupAllow } =
|
||||
groupAllowContext;
|
||||
|
||||
const dmAllow = await resolveTelegramDmAllow({
|
||||
cfg: auth.cfg,
|
||||
groupAllowOverride,
|
||||
allowFrom,
|
||||
accountId: auth.accountId,
|
||||
senderId: facts.senderId,
|
||||
storeAllowFrom: facts.isGroup ? [] : storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const ownerAccess = resolveTelegramCommandAuthorization({
|
||||
cfg: auth.cfg,
|
||||
accountId: auth.accountId,
|
||||
chatId: facts.chatId,
|
||||
isGroup: facts.isGroup,
|
||||
...(resolvedThreadId !== undefined ? { resolvedThreadId } : {}),
|
||||
senderId: facts.senderId,
|
||||
...(facts.senderUsername !== undefined ? { senderUsername: facts.senderUsername } : {}),
|
||||
});
|
||||
const gate = await resolveTelegramCommandIngressAuthorization({
|
||||
accountId: auth.accountId,
|
||||
cfg: auth.cfg,
|
||||
dmPolicy,
|
||||
isGroup: facts.isGroup,
|
||||
chatId: facts.chatId,
|
||||
...(resolvedThreadId !== undefined ? { resolvedThreadId } : {}),
|
||||
senderId: facts.senderId,
|
||||
effectiveDmAllow: dmAllow.effectiveAllow,
|
||||
effectiveGroupAllow,
|
||||
ownerAccess,
|
||||
eventKind: "message",
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
modeWhenAccessGroupsOff: "allow",
|
||||
includeDmAllowForGroupCommands: false,
|
||||
});
|
||||
return gate.authorized;
|
||||
}
|
||||
@@ -27,10 +27,10 @@ afterEach(() => {
|
||||
});
|
||||
import type { TelegramSpooledUpdatePayload } from "./telegram-ingress-spool.payload.js";
|
||||
import {
|
||||
createShouldSupersedeTelegramSpooledPending,
|
||||
isTelegramAmbientSpooledUpdate,
|
||||
isTelegramSpooledUpdateSenderAuthorized,
|
||||
} from "./telegram-ingress-supersede.js";
|
||||
} from "./telegram-ingress-supersede-auth.js";
|
||||
import { createShouldSupersedeTelegramSpooledPending } from "./telegram-ingress-supersede.js";
|
||||
|
||||
const OWNER_ID = "111";
|
||||
const STRANGER_ID = "999";
|
||||
|
||||
@@ -11,18 +11,13 @@ import {
|
||||
isBtwRequestText,
|
||||
} from "openclaw/plugin-sdk/command-primitives-runtime";
|
||||
// Telegram plugin module owns pre-adoption supersede policy for durable ingress.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveTelegramDmAllow } from "./access-groups.js";
|
||||
import { mergeTelegramAccountConfig } from "./account-config.js";
|
||||
import {
|
||||
resolveTelegramCommandAuthorization,
|
||||
resolveTelegramGroupAllowFromContext,
|
||||
resolveTelegramMessageForumFlagHint,
|
||||
} from "./bot/helpers.js";
|
||||
import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js";
|
||||
import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
|
||||
import { isTelegramReadOnlyControlLaneText } from "./sequential-key.js";
|
||||
import type { TelegramSpooledUpdatePayload } from "./telegram-ingress-spool.payload.js";
|
||||
import {
|
||||
isTelegramAmbientSpooledUpdate,
|
||||
isTelegramSpooledUpdateSenderAuthorized,
|
||||
type TelegramSupersedeAuthContext,
|
||||
} from "./telegram-ingress-supersede-auth.js";
|
||||
|
||||
function isRecognizedTelegramTextCommand(rawText: string, botUsername?: string): boolean {
|
||||
return (
|
||||
@@ -135,195 +130,6 @@ function extractUpdateText(update: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
type UpdateSenderFacts = {
|
||||
senderId: string;
|
||||
senderUsername?: string;
|
||||
chatId: number;
|
||||
chatType?: string;
|
||||
isGroup: boolean;
|
||||
isTopicMessage?: boolean;
|
||||
isForum?: boolean;
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
function extractUpdateSenderFacts(update: unknown): UpdateSenderFacts | null {
|
||||
if (!update || typeof update !== "object") {
|
||||
return null;
|
||||
}
|
||||
const root = update as Record<string, unknown>;
|
||||
let message: Record<string, unknown> | undefined;
|
||||
for (const key of ["message", "edited_message", "channel_post", "edited_channel_post"] as const) {
|
||||
const candidate = root[key];
|
||||
if (candidate && typeof candidate === "object") {
|
||||
message = candidate as Record<string, unknown>;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!message) {
|
||||
const callback = root.callback_query;
|
||||
if (callback && typeof callback === "object") {
|
||||
const cb = callback as Record<string, unknown>;
|
||||
const from = cb.from;
|
||||
const msg = cb.message;
|
||||
if (from && typeof from === "object" && msg && typeof msg === "object") {
|
||||
message = msg as Record<string, unknown>;
|
||||
const chat = (msg as { chat?: { id?: unknown; type?: unknown; is_forum?: unknown } }).chat;
|
||||
const fromObj = from as { id?: unknown; username?: unknown };
|
||||
if (typeof chat?.id === "number" && typeof fromObj.id === "number") {
|
||||
const chatType = typeof chat.type === "string" ? chat.type : "private";
|
||||
return {
|
||||
senderId: String(fromObj.id),
|
||||
...(typeof fromObj.username === "string" ? { senderUsername: fromObj.username } : {}),
|
||||
chatId: chat.id,
|
||||
chatType,
|
||||
isGroup: chatType !== "private",
|
||||
...(typeof chat.is_forum === "boolean" ? { isForum: chat.is_forum } : {}),
|
||||
...(typeof (msg as { is_topic_message?: unknown }).is_topic_message === "boolean"
|
||||
? {
|
||||
isTopicMessage: (msg as { is_topic_message: boolean }).is_topic_message,
|
||||
}
|
||||
: {}),
|
||||
...(typeof (msg as { message_thread_id?: unknown }).message_thread_id === "number"
|
||||
? {
|
||||
messageThreadId: (msg as { message_thread_id: number }).message_thread_id,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const chat = message.chat as { id?: unknown; type?: unknown; is_forum?: unknown } | undefined;
|
||||
const from = message.from as { id?: unknown; username?: unknown } | undefined;
|
||||
if (typeof chat?.id !== "number" || typeof from?.id !== "number") {
|
||||
return null;
|
||||
}
|
||||
const chatType = typeof chat.type === "string" ? chat.type : "private";
|
||||
return {
|
||||
senderId: String(from.id),
|
||||
...(typeof from.username === "string" ? { senderUsername: from.username } : {}),
|
||||
chatId: chat.id,
|
||||
chatType,
|
||||
isGroup: chatType !== "private",
|
||||
...(typeof chat.is_forum === "boolean" ? { isForum: chat.is_forum } : {}),
|
||||
...(typeof message.is_topic_message === "boolean"
|
||||
? { isTopicMessage: message.is_topic_message as boolean }
|
||||
: {}),
|
||||
...(typeof message.message_thread_id === "number"
|
||||
? { messageThreadId: message.message_thread_id as number }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ambient room_event-shaped updates (no user text body) stay supersedable. */
|
||||
export function isTelegramAmbientSpooledUpdate(update: unknown): boolean {
|
||||
if (!update || typeof update !== "object") {
|
||||
return false;
|
||||
}
|
||||
const root = update as Record<string, unknown>;
|
||||
return (
|
||||
root.message_reaction != null ||
|
||||
root.message_reaction_count != null ||
|
||||
root.chat_member != null ||
|
||||
root.my_chat_member != null ||
|
||||
root.chat_join_request != null ||
|
||||
root.chat_boost != null ||
|
||||
root.removed_chat_boost != null
|
||||
);
|
||||
}
|
||||
|
||||
export type TelegramSupersedeAuthContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
/** Bot username for @bot command targeting (from getMe / botInfo). */
|
||||
botUsername?: string;
|
||||
/** Test seam / preloaded pairing-store ids; defaults to live pairing store. */
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the raw update's sender is command-authorized.
|
||||
* Reuses resolveTelegramGroupAllowFromContext — same group/topic allowFrom
|
||||
* overrides and access-group expansion as normal message ingress.
|
||||
*/
|
||||
export async function isTelegramSpooledUpdateSenderAuthorized(
|
||||
update: unknown,
|
||||
auth: TelegramSupersedeAuthContext,
|
||||
): Promise<boolean> {
|
||||
const facts = extractUpdateSenderFacts(update);
|
||||
if (!facts) {
|
||||
return false;
|
||||
}
|
||||
const accountCfg = mergeTelegramAccountConfig(auth.cfg, auth.accountId);
|
||||
const dmPolicy = accountCfg.dmPolicy ?? "pairing";
|
||||
const allowFrom = accountCfg.allowFrom;
|
||||
const groupAllowFrom = accountCfg.groupAllowFrom ?? accountCfg.allowFrom;
|
||||
const isForum =
|
||||
resolveTelegramMessageForumFlagHint({
|
||||
chatType: facts.chatType as "private" | "group" | "supergroup" | "channel" | undefined,
|
||||
isForum: facts.isForum,
|
||||
isTopicMessage: facts.isTopicMessage,
|
||||
}) ?? false;
|
||||
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
cfg: auth.cfg,
|
||||
chatId: facts.chatId,
|
||||
accountId: auth.accountId,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
senderId: facts.senderId,
|
||||
isGroup: facts.isGroup,
|
||||
isForum,
|
||||
messageThreadId: facts.messageThreadId,
|
||||
groupAllowFrom,
|
||||
resolveTelegramGroupConfig: (chatId, messageThreadId, cfg) => {
|
||||
const telegramCfg = mergeTelegramAccountConfig(cfg, auth.accountId);
|
||||
return resolveTelegramScopedGroupConfig(telegramCfg, chatId, messageThreadId);
|
||||
},
|
||||
});
|
||||
|
||||
const { resolvedThreadId, storeAllowFrom, groupAllowOverride, effectiveGroupAllow } =
|
||||
groupAllowContext;
|
||||
|
||||
const dmAllow = await resolveTelegramDmAllow({
|
||||
cfg: auth.cfg,
|
||||
groupAllowOverride,
|
||||
allowFrom,
|
||||
accountId: auth.accountId,
|
||||
senderId: facts.senderId,
|
||||
storeAllowFrom: facts.isGroup ? [] : storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const ownerAccess = resolveTelegramCommandAuthorization({
|
||||
cfg: auth.cfg,
|
||||
accountId: auth.accountId,
|
||||
chatId: facts.chatId,
|
||||
isGroup: facts.isGroup,
|
||||
...(resolvedThreadId !== undefined ? { resolvedThreadId } : {}),
|
||||
senderId: facts.senderId,
|
||||
...(facts.senderUsername !== undefined ? { senderUsername: facts.senderUsername } : {}),
|
||||
});
|
||||
const gate = await resolveTelegramCommandIngressAuthorization({
|
||||
accountId: auth.accountId,
|
||||
cfg: auth.cfg,
|
||||
dmPolicy,
|
||||
isGroup: facts.isGroup,
|
||||
chatId: facts.chatId,
|
||||
...(resolvedThreadId !== undefined ? { resolvedThreadId } : {}),
|
||||
senderId: facts.senderId,
|
||||
effectiveDmAllow: dmAllow.effectiveAllow,
|
||||
effectiveGroupAllow,
|
||||
ownerAccess,
|
||||
eventKind: "message",
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
modeWhenAccessGroupsOff: "allow",
|
||||
includeDmAllowForGroupCommands: false,
|
||||
});
|
||||
return gate.authorized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain-level supersede predicate over raw spooled payloads.
|
||||
* Authorization is resolved from the new event's numeric sender via the same
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
import { setTelegramRuntime } from "./runtime.js";
|
||||
import { clearTelegramRuntimeForTest as clearTelegramRuntime } from "./runtime.test-support.js";
|
||||
import type { TelegramRuntime } from "./runtime.types.js";
|
||||
import { writeTelegramSpooledUpdate } from "./telegram-ingress-spool.js";
|
||||
import {
|
||||
listTelegramSpooledUpdateClaims,
|
||||
listTelegramSpooledUpdates,
|
||||
writeTelegramSpooledUpdate,
|
||||
} from "./telegram-ingress-spool.js";
|
||||
} from "./telegram-ingress-spool.test-support.js";
|
||||
|
||||
const telegramSpooledRetryDeadLetterMinAgeMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export type QueuedReplyDeliveryCorrelation = {
|
||||
* Exclusive: each lifecycle is its own collect-admission identity.
|
||||
* Cancel-only: share collect identity via ownerKey (gateway chat.send).
|
||||
*/
|
||||
export type TurnAdoptionAdmission = "exclusive" | "cancel-only";
|
||||
type TurnAdoptionAdmission = "exclusive" | "cancel-only";
|
||||
|
||||
/**
|
||||
* Canonical turn-ownership lifecycle (adopt / defer / abandon / settle).
|
||||
|
||||
@@ -40,11 +40,7 @@ export {
|
||||
createTypingCallbacks,
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "./reply-pipeline.js";
|
||||
export type {
|
||||
ChannelIngressDispatchLifecycle,
|
||||
ChannelIngressDrain,
|
||||
ChannelIngressDrainDispatchResult,
|
||||
} from "./ingress-drain.js";
|
||||
export type { ChannelIngressDrain } from "./ingress-drain.js";
|
||||
export type {
|
||||
ChannelIngressQueue,
|
||||
ChannelIngressQueueClaim,
|
||||
@@ -53,7 +49,6 @@ export type {
|
||||
ChannelIngressQueueRecord,
|
||||
} from "./ingress-queue.js";
|
||||
export type { MessageAckPolicy, MessageReceiveContext } from "./receive.js";
|
||||
export type { IngressNonRetryableFailure } from "./ingress-retry-policy.js";
|
||||
export type {
|
||||
ChannelMessageAdapterShape,
|
||||
ChannelMessageDurableFinalAdapter,
|
||||
|
||||
@@ -13,13 +13,13 @@ import type { ChannelIngressQueueClaim, ChannelIngressQueueCorruptClaim } from "
|
||||
// so recovery can reclaim it even when the owner process still exists.
|
||||
export const INGRESS_CLAIM_LEASE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type IngressClaimOwnerIdentity = {
|
||||
type IngressClaimOwnerIdentity = {
|
||||
processId: string;
|
||||
processPid: number;
|
||||
claimedAt: number;
|
||||
};
|
||||
|
||||
export type IngressClaimLivenessOptions = {
|
||||
type IngressClaimLivenessOptions = {
|
||||
maxAgeMs?: number;
|
||||
now?: number;
|
||||
/** Test seam for PID existence (including Linux TID impersonation). */
|
||||
@@ -87,7 +87,7 @@ export function processPidFromOwnerId(ownerId: string): number {
|
||||
}
|
||||
|
||||
/** Instance UUID from ownerId `pid:startToken:uuid`. */
|
||||
export function processInstanceIdFromOwnerId(ownerId: string): string | null {
|
||||
function processInstanceIdFromOwnerId(ownerId: string): string | null {
|
||||
const parts = ownerId.split(":");
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
createChannelIngressDrain,
|
||||
DEFAULT_INGRESS_ADOPTION_STALL_MS,
|
||||
isIngressAdoptionLostError,
|
||||
type ChannelIngressDispatchLifecycle,
|
||||
} from "./ingress-drain.js";
|
||||
|
||||
// Module-private in ingress-drain.ts; derive from the factory signature.
|
||||
type ChannelIngressDispatchLifecycle = Parameters<
|
||||
Parameters<typeof createChannelIngressDrain>[0]["dispatchClaimedEvent"]
|
||||
>[1];
|
||||
import { createChannelIngressQueue } from "./ingress-queue.js";
|
||||
import {
|
||||
DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS,
|
||||
|
||||
@@ -49,14 +49,14 @@ class IngressAdoptionLostError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type IngressAdoptionLost = IngressAdoptionLostError;
|
||||
type IngressAdoptionLost = IngressAdoptionLostError;
|
||||
|
||||
export function isIngressAdoptionLostError(error: unknown): error is IngressAdoptionLostError {
|
||||
return error instanceof IngressAdoptionLostError;
|
||||
}
|
||||
|
||||
/** Full pre-adoption → adoption ownership lifecycle for one claimed event. */
|
||||
export type ChannelIngressDispatchLifecycle = {
|
||||
type ChannelIngressDispatchLifecycle = {
|
||||
/** Pre-adoption only. After adopt the drain treats this signal as inert. */
|
||||
abortSignal: AbortSignal;
|
||||
/**
|
||||
@@ -83,7 +83,7 @@ export type ChannelIngressDispatchLifecycle = {
|
||||
onAbandoned: () => void;
|
||||
};
|
||||
|
||||
export type ChannelIngressDrainDispatchResult =
|
||||
type ChannelIngressDrainDispatchResult =
|
||||
| { kind: "completed" }
|
||||
| { kind: "deferred" }
|
||||
| { kind: "failed-retryable"; error: unknown };
|
||||
|
||||
@@ -3,19 +3,12 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS,
|
||||
DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS,
|
||||
resolveIngressAttemptNumber,
|
||||
resolveIngressFailureDisposition,
|
||||
resolveIngressRetryDelayMs,
|
||||
shouldDeadLetterRetryableIngressEvent,
|
||||
} from "./ingress-retry-policy.js";
|
||||
|
||||
describe("ingress retry policy", () => {
|
||||
it("resolves attempt numbers from stored attempts", () => {
|
||||
expect(resolveIngressAttemptNumber({ receivedAt: 0 })).toBe(1);
|
||||
expect(resolveIngressAttemptNumber({ receivedAt: 0, attempts: 0 })).toBe(1);
|
||||
expect(resolveIngressAttemptNumber({ receivedAt: 0, attempts: 3 })).toBe(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "no prior error → immediate",
|
||||
|
||||
@@ -16,7 +16,7 @@ export type IngressRetryPolicyConfig = {
|
||||
maxMs?: number;
|
||||
};
|
||||
|
||||
export type IngressRetryEventFacts = {
|
||||
type IngressRetryEventFacts = {
|
||||
receivedAt: number;
|
||||
attempts?: number;
|
||||
lastAttemptAt?: number;
|
||||
@@ -28,7 +28,7 @@ export type IngressNonRetryableFailure = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type IngressFailureDisposition =
|
||||
type IngressFailureDisposition =
|
||||
| {
|
||||
kind: "fail";
|
||||
reason: string;
|
||||
@@ -51,7 +51,7 @@ function resolveConfig(config?: IngressRetryPolicyConfig) {
|
||||
}
|
||||
|
||||
/** Next attempt number after a failed dispatch (1-based for the attempt just finished). */
|
||||
export function resolveIngressAttemptNumber(event: IngressRetryEventFacts): number {
|
||||
function resolveIngressAttemptNumber(event: IngressRetryEventFacts): number {
|
||||
return (event.attempts ?? 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user