mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 08:01:41 +00:00
* 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
264 lines
8.2 KiB
TypeScript
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,
|
|
},
|
|
]
|
|
: [];
|
|
}),
|
|
);
|
|
}
|