Files
openclaw/extensions/telegram/src/sent-message-cache.ts
Peter Steinberger 82d1a03f25 refactor(agents): move implicit-main fallback into load-time roster injection (#112678)
* refactor(agents): require explicit roster defaults

* feat(onboard): create named first roster agent

* refactor(agents): remove runtime main fallbacks

* style(agents): apply roster refactor formatting

* refactor(agents): finish roster-only runtime sweep

* fix(doctor): migrate legacy main session sqlite

* fix(doctor): harden roster session migrations

* fix(onboard): commit first agent atomically

* fix(config): support empty-roster analysis

* fix(agents): preserve legacy main state during creation

* fix(setup): materialize baseline agent roster

* fix(agents): harden legacy default transfer recovery

* fix(agents): simplify roster-only legacy compatibility

* fix(agents): preserve staged first-agent entries

* fix(config): migrate persisted implicit-main rosters

* fix(config): preserve staged empty rosters

* fix(agents): finalize roster-only upgrade paths

* fix(sessions): close legacy main migration outcomes

* fix(config): migrate legacy roster markers at load

* fix(sessions): preserve roster upgrade history

* refactor(sessions): restore lean legacy main compatibility

* fix(setup): prepare first-agent credentials before publish

* fix(config): stabilize roster snapshot migration

* refactor(sessions): shrink legacy main compatibility

* fix(agents): restore roster compatibility fidelity

* fix(sessions): preserve divergent legacy history

* refactor(agents): narrow roster-only scope

* fix(config): isolate roster migration

* test(agents): align roster-only fixtures

* fix(agents): keep main agent undeletable

* fix(agents): harden roster migration invariants

* fix(agents): close setup and audit scope gaps

* fix(cron): scope session reaper throttles by agent

* fix(agents): preserve scoped owner precedence

* fix(config): preserve authored config ownership

* fix(setup): keep default workspace and roster in sync

* fix(setup): preserve default entry workspace on bare runs

* fix(agents): adapt roster rebase to keyed entries

* fix(agents): honor both roster representations

* fix(agents): route roster reads through shared helpers

* fix(config): preserve canonical roster writes

* fix(cron): resolve dynamic default for session reaper

* fix(agents): close dynamic default migration gaps

* fix(agents): align scoped session ownership

* fix(sessions): preserve legacy main directory casing

* fix(agents): align cron and legacy auth ownership

* fix(setup): provision the committed default workspace

* fix(cron): align scoped ownership and reaping

* fix(cron): treat blank agent ids as absent

* fix(cron): retain configured session-store owners

* fix(agents): repair roster-aware CI boundaries

* fix(cron): preserve scoped ownership resolution

* fix(agents): preserve rosterless maintenance paths

* fix(agents): propagate roster ownership through runtime boundaries

* fix(agents): preserve roster ownership across runtime paths

* fix(agents): harden roster diagnostics and legacy routing

* fix(agents): remove redundant diagnostic import

* test(agents): type CLI policy fixture explicitly

* fix(config): preserve canonical roster mutation identity

* fix(doctor): read canonical agent rosters consistently

* fix(config): resolve compound roster unsets safely

* fix(config): finalize main-session reconciliation

* fix(doctor): read canonical session state safely

* fix(sessions): preserve current visibility alias

* fix(config): track roster include provenance

* test(config): type roster provenance cases

* fix(config): refine roster include ownership

* fix(agents): preserve staged roster invariants

* test(config): align fixtures with explicit roster ownership

* test(node-host): preserve optional plan typing

* fix(config): preserve authored roster projections

* test(config): keep raw roster fixtures explicit

* test(config): normalize rosters at runtime fixtures

* fix(config): protect authored roster ownership

* fix(agents): require explicit session ownership

* fix(agents): enforce scoped roster ownership

* fix(sessions): merge fixed-store agent partitions

* fix(agents): harden roster ownership boundaries

* fix(config): reject ambiguous roster projections

* fix(sessions): preserve persisted store ownership

* fix(sessions): keep collision diagnostics additive

* fix(security): scan malformed roster workspaces

* test(config): align snapshot fixtures after rebase

* test(agents): use explicit roster fixtures

* fix(config): harden roster diagnostic boundaries

* fix(sessions): isolate fixed-store agent databases

* test(agents): type malformed default markers

* refactor(sessions): extract store collision resolution

* test(system-agent): split oversized setup coverage

* style(system-agent): format split setup suite

* fix(sessions): preserve promoted store ownership

* fix(sessions): derive scoped owner before target

* fix(sessions): preserve explicit sqlite ownership

* fix(agents): restore roster compatibility across CI

* fix(agents): enforce roster-owned runtime boundaries

* fix(agents): satisfy default lookup lint

* test(sessions): split known-owner coverage

* fix(state): satisfy path identity lint

* fix(agents): preserve malformed roster safety boundaries

* fix(agents): restore roster compatibility at runtime boundaries

* fix(config): satisfy roster boundary type checks

* fix(agents): preserve roster ownership across runtime probes

Setup inference probes now execute as the configured roster owner. Malformed agent-prefixed session rows are intentionally omitted by the fail-closed visibility contract rather than normalized by tests.

* fix(agents): satisfy session list owner lint

* fix(agents): preserve roster-owned runtime boundaries

Restore shared logical rows for exact SQLite session locators while keeping their physical database owner separate. The ownership regression test now constructs an explicit sole-owner database directly instead of relying on first-touch capture, matching the intentional shared-store contract.

* fix(sessions): preserve multiply owned exact stores

* fix(sessions): restore runtime owner boundaries

Keep incognito sentinels agent-owned, fold default-agent approvals into the global snapshot, and preserve the configless legacy-main CLI policy fallback. Also repair the existing CLI watchdog test lifecycle so the compact shard observes its timeout without an unawaited assertion or async timer stall; product behavior is unchanged by that test-only fix.

* test(ci): align owner-scoped fixtures

These assertions are unchanged. The fixtures now declare the intended non-default runner, expose the session-key constant imported by production status code, and select the main approvals bucket explicitly on Windows.

* fix(agents): close final roster ownership gaps
2026-07-24 22:38:09 -07:00

264 lines
8.2 KiB
TypeScript

// Telegram plugin module implements sent message cache behavior.
import { createHash } from "node:crypto";
import fs from "node:fs";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { getTelegramRuntime } from "./runtime.js";
const TTL_MS = 24 * 60 * 60 * 1000;
export const TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE = "telegram.sent-messages";
export const TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES = 10_000;
const TELEGRAM_SENT_MESSAGES_STATE_KEY = Symbol.for("openclaw.telegramSentMessagesState");
type PersistedSentMessage = {
scopeKey: string;
chatId: string;
messageId: string;
timestamp: number;
};
type SentMessageStore = Map<string, Map<string, number>>;
type SentMessagePersistentStore = PluginStateSyncKeyedStore<PersistedSentMessage>;
type SentMessageBucket = {
scopeKey: string;
store: SentMessageStore;
};
type SentMessageState = {
bucketsByScope: Map<string, SentMessageBucket>;
};
type SentMessageConfig = Pick<OpenClawConfig, "agents" | "session">;
function getSentMessageState(): SentMessageState {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const existing = globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] as SentMessageState | undefined;
if (existing) {
return existing;
}
const state: SentMessageState = {
bucketsByScope: new Map(),
};
globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] = state;
return state;
}
function createSentMessageStore(): SentMessageStore {
return new Map<string, Map<string, number>>();
}
function resolveSentMessageAgentId(cfg?: SentMessageConfig, agentId?: string): string {
return agentId?.trim() || (cfg?.agents ? resolveDefaultAgentId(cfg as OpenClawConfig) : "main");
}
function resolveSentMessageStorePath(cfg?: SentMessageConfig, agentId?: string): string {
return `${resolveStorePath(cfg?.session?.store, {
agentId: resolveSentMessageAgentId(cfg, agentId),
})}.telegram-sent-messages.json`;
}
function sentMessageScopeKeyForStorePath(storePath: string): string {
return createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24);
}
function resolveSentMessageScopeKey(cfg?: SentMessageConfig, agentId?: string): string {
// This 24-hour cache follows the current agent owner. Do not revive a prior owner's
// transient bucket when the configured default changes.
return sentMessageScopeKeyForStorePath(
resolveStorePath(cfg?.session?.store, {
agentId: resolveSentMessageAgentId(cfg, agentId),
}),
);
}
function sentMessageEntryKey(scopeKey: string, chatId: string, messageId: string): string {
return createHash("sha256")
.update(`${scopeKey}\0${chatId}\0${messageId}`, "utf8")
.digest("hex")
.slice(0, 32);
}
function openSentMessageStore(): SentMessagePersistentStore {
return getTelegramRuntime().state.openSyncKeyedStore<PersistedSentMessage>({
namespace: TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE,
maxEntries: TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES,
});
}
function cleanupExpired(
store: SentMessageStore,
scopeKey: string,
entry: Map<string, number>,
now: number,
): void {
for (const [id, timestamp] of entry) {
if (now - timestamp >= TTL_MS) {
entry.delete(id);
}
}
if (entry.size === 0) {
store.delete(scopeKey);
}
}
function cleanupExpiredSentMessages(store: SentMessageStore, now: number): void {
for (const [scopeKey, entry] of store) {
cleanupExpired(store, scopeKey, entry, now);
}
}
function readLegacySentMessages(filePath: string): SentMessageStore {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, Record<string, number>>;
const now = Date.now();
const store = createSentMessageStore();
for (const [chatId, entry] of Object.entries(parsed)) {
const messages = new Map<string, number>();
for (const [messageId, timestamp] of Object.entries(entry)) {
if (
typeof timestamp === "number" &&
Number.isFinite(timestamp) &&
now - timestamp < TTL_MS
) {
messages.set(messageId, timestamp);
}
}
if (messages.size > 0) {
store.set(chatId, messages);
}
}
return store;
} catch (error) {
logVerbose(`telegram: failed to read sent-message cache: ${String(error)}`);
return createSentMessageStore();
}
}
function readPersistedSentMessages(scopeKey: string): SentMessageStore {
const now = Date.now();
const store = createSentMessageStore();
try {
for (const entry of openSentMessageStore().entries()) {
if (entry.value.scopeKey !== scopeKey || now - entry.value.timestamp > TTL_MS) {
continue;
}
let messages = store.get(entry.value.chatId);
if (!messages) {
messages = new Map<string, number>();
store.set(entry.value.chatId, messages);
}
messages.set(entry.value.messageId, entry.value.timestamp);
}
} catch (error) {
logVerbose(`telegram: failed to read sent-message cache: ${String(error)}`);
}
return store;
}
function getSentMessageBucket(cfg?: SentMessageConfig): SentMessageBucket {
const state = getSentMessageState();
const scopeKey = resolveSentMessageScopeKey(cfg);
const existing = state.bucketsByScope.get(scopeKey);
if (existing) {
return existing;
}
const bucket = {
scopeKey,
store: readPersistedSentMessages(scopeKey),
};
state.bucketsByScope.set(scopeKey, bucket);
return bucket;
}
function getSentMessages(cfg?: SentMessageConfig): SentMessageStore {
return getSentMessageBucket(cfg).store;
}
function persistSentMessage(
bucket: SentMessageBucket,
chatId: string,
messageId: string,
timestamp: number,
): void {
openSentMessageStore().register(
sentMessageEntryKey(bucket.scopeKey, chatId, messageId),
{ scopeKey: bucket.scopeKey, chatId, messageId, timestamp },
{ ttlMs: TTL_MS },
);
}
export function recordSentMessage(
chatId: number | string,
messageId: number,
cfg?: SentMessageConfig,
): void {
const scopeKey = String(chatId);
const idKey = String(messageId);
const now = Date.now();
const bucket = getSentMessageBucket(cfg);
const { store } = bucket;
let entry = store.get(scopeKey);
if (!entry) {
entry = new Map<string, number>();
store.set(scopeKey, entry);
}
entry.set(idKey, now);
cleanupExpiredSentMessages(store, now);
try {
persistSentMessage(bucket, scopeKey, idKey, now);
} catch (error) {
logVerbose(`telegram: failed to persist sent-message cache: ${String(error)}`);
}
}
export function wasSentByBot(
chatId: number | string,
messageId: number,
cfg?: SentMessageConfig,
): boolean {
const scopeKey = String(chatId);
const idKey = String(messageId);
const store = getSentMessages(cfg);
const entry = store.get(scopeKey);
if (!entry) {
return false;
}
cleanupExpired(store, scopeKey, entry, Date.now());
return entry.has(idKey);
}
export function listTelegramLegacySentMessageCacheEntries(params: {
cfg?: SentMessageConfig;
agentId?: string;
persistedPath?: string;
targetStorePath?: string;
}): Array<{ key: string; value: PersistedSentMessage; ttlMs?: number; timestamp?: number }> {
const scopeKey = params.targetStorePath
? sentMessageScopeKeyForStorePath(params.targetStorePath)
: resolveSentMessageScopeKey(params.cfg, params.agentId);
const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg, params.agentId);
const legacy = fs.existsSync(filePath)
? readLegacySentMessages(filePath)
: createSentMessageStore();
return [...legacy.entries()].flatMap(([chatId, messages]) =>
[...messages.entries()].flatMap(([messageId, timestamp]) => {
const ttlMs = TTL_MS - Math.max(0, Date.now() - timestamp);
return ttlMs > 0
? [
{
key: sentMessageEntryKey(scopeKey, chatId, messageId),
value: { scopeKey, chatId, messageId, timestamp },
ttlMs,
timestamp,
},
]
: [];
}),
);
}