mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-25 02:43:02 +00:00
* msteams: persist sent-message markers best-effort * docs: clarify Teams restart persistence changelog * msteams: remove redundant sent-message TTL comment * msteams: preserve sent-message marker TTL on recovery
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import { getOptionalMSTeamsRuntime } from "./runtime.js";
|
|
|
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
const PERSISTENT_MAX_ENTRIES = 1000;
|
|
const PERSISTENT_NAMESPACE = "msteams.sent-messages";
|
|
const MSTEAMS_SENT_MESSAGES_KEY = Symbol.for("openclaw.msteamsSentMessages");
|
|
|
|
type MSTeamsSentMessageRecord = {
|
|
sentAt: number;
|
|
};
|
|
|
|
type MSTeamsSentMessageStore = {
|
|
register(key: string, value: MSTeamsSentMessageRecord, opts?: { ttlMs?: number }): Promise<void>;
|
|
lookup(key: string): Promise<MSTeamsSentMessageRecord | undefined>;
|
|
};
|
|
|
|
let sentMessageCache: Map<string, Map<string, number>> | undefined;
|
|
let persistentStore: MSTeamsSentMessageStore | undefined;
|
|
let persistentStoreDisabled = false;
|
|
|
|
function getSentMessageCache(): Map<string, Map<string, number>> {
|
|
if (!sentMessageCache) {
|
|
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
|
sentMessageCache =
|
|
(globalStore[MSTEAMS_SENT_MESSAGES_KEY] as Map<string, Map<string, number>> | undefined) ??
|
|
new Map<string, Map<string, number>>();
|
|
globalStore[MSTEAMS_SENT_MESSAGES_KEY] = sentMessageCache;
|
|
}
|
|
return sentMessageCache;
|
|
}
|
|
|
|
function makePersistentKey(conversationId: string, messageId: string): string {
|
|
return `${conversationId}:${messageId}`;
|
|
}
|
|
|
|
function reportPersistentSentMessageError(error: unknown): void {
|
|
try {
|
|
getOptionalMSTeamsRuntime()
|
|
?.logging.getChildLogger({ plugin: "msteams", feature: "sent-message-state" })
|
|
.warn("Microsoft Teams persistent sent-message state failed", { error: String(error) });
|
|
} catch {
|
|
// Best effort only: persistent state must never break Teams routing.
|
|
}
|
|
}
|
|
|
|
function disablePersistentSentMessageStore(error: unknown): void {
|
|
persistentStoreDisabled = true;
|
|
persistentStore = undefined;
|
|
reportPersistentSentMessageError(error);
|
|
}
|
|
|
|
function getPersistentSentMessageStore(): MSTeamsSentMessageStore | undefined {
|
|
if (persistentStoreDisabled) {
|
|
return undefined;
|
|
}
|
|
if (persistentStore) {
|
|
return persistentStore;
|
|
}
|
|
const runtime = getOptionalMSTeamsRuntime();
|
|
if (!runtime) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
persistentStore = runtime.state.openKeyedStore<MSTeamsSentMessageRecord>({
|
|
namespace: PERSISTENT_NAMESPACE,
|
|
maxEntries: PERSISTENT_MAX_ENTRIES,
|
|
defaultTtlMs: TTL_MS,
|
|
});
|
|
return persistentStore;
|
|
} catch (error) {
|
|
disablePersistentSentMessageStore(error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function cleanupExpired(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) {
|
|
getSentMessageCache().delete(scopeKey);
|
|
}
|
|
}
|
|
|
|
function rememberSentMessageInMemory(
|
|
conversationId: string,
|
|
messageId: string,
|
|
sentAt: number,
|
|
): void {
|
|
const store = getSentMessageCache();
|
|
let entry = store.get(conversationId);
|
|
if (!entry) {
|
|
entry = new Map<string, number>();
|
|
store.set(conversationId, entry);
|
|
}
|
|
entry.set(messageId, sentAt);
|
|
if (entry.size > 200) {
|
|
cleanupExpired(conversationId, entry, sentAt);
|
|
}
|
|
}
|
|
|
|
function rememberPersistentSentMessage(params: {
|
|
conversationId: string;
|
|
messageId: string;
|
|
sentAt: number;
|
|
}): void {
|
|
const store = getPersistentSentMessageStore();
|
|
if (!store) {
|
|
return;
|
|
}
|
|
void store
|
|
.register(makePersistentKey(params.conversationId, params.messageId), { sentAt: params.sentAt })
|
|
.catch(disablePersistentSentMessageStore);
|
|
}
|
|
|
|
async function lookupPersistentSentMessage(params: {
|
|
conversationId: string;
|
|
messageId: string;
|
|
}): Promise<number | undefined> {
|
|
const store = getPersistentSentMessageStore();
|
|
if (!store) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return (await store.lookup(makePersistentKey(params.conversationId, params.messageId)))?.sentAt;
|
|
} catch (error) {
|
|
disablePersistentSentMessageStore(error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void {
|
|
if (!conversationId || !messageId) {
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
rememberSentMessageInMemory(conversationId, messageId, now);
|
|
rememberPersistentSentMessage({ conversationId, messageId, sentAt: now });
|
|
}
|
|
|
|
export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean {
|
|
const entry = getSentMessageCache().get(conversationId);
|
|
if (!entry) {
|
|
return false;
|
|
}
|
|
cleanupExpired(conversationId, entry, Date.now());
|
|
return entry.has(messageId);
|
|
}
|
|
|
|
export async function wasMSTeamsMessageSentWithPersistence(params: {
|
|
conversationId: string;
|
|
messageId: string;
|
|
}): Promise<boolean> {
|
|
if (!params.conversationId || !params.messageId) {
|
|
return false;
|
|
}
|
|
if (wasMSTeamsMessageSent(params.conversationId, params.messageId)) {
|
|
return true;
|
|
}
|
|
const sentAt = await lookupPersistentSentMessage(params);
|
|
if (sentAt == null) {
|
|
return false;
|
|
}
|
|
rememberSentMessageInMemory(params.conversationId, params.messageId, sentAt);
|
|
return wasMSTeamsMessageSent(params.conversationId, params.messageId);
|
|
}
|
|
|
|
export function clearMSTeamsSentMessageCache(): void {
|
|
getSentMessageCache().clear();
|
|
persistentStore = undefined;
|
|
persistentStoreDisabled = false;
|
|
}
|