From 6ae09d029c389271e1c983f8be9016fa102d068f Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Sun, 3 May 2026 17:25:20 +1000 Subject: [PATCH] msteams: persist sent-message markers best-effort (#75585) * 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 --- CHANGELOG.md | 1 + .../src/monitor-handler/message-handler.ts | 9 +- extensions/msteams/src/runtime.ts | 15 +- .../msteams/src/sent-message-cache.test.ts | 94 +++++++++++- extensions/msteams/src/sent-message-cache.ts | 134 ++++++++++++++++-- 5 files changed, 235 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d785c6daf..48899d58478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -425,6 +425,7 @@ Docs: https://docs.openclaw.ai - Slack: publish a safe default App Home tab view on `app_home_opened` and include the Home tab event in setup manifests. Fixes #11655; refs #52020. Thanks @TinyTb. - Slack: keep track of bot-participated threads across restarts, so ongoing threaded conversations can continue auto-replying after the Gateway is restarted. Thanks @amknight. - Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok. +- Microsoft Teams: keep follow-up replies to recent bot messages working after Gateway updates or restarts, so conversations do not lose track of which bot message a user is answering. Thanks @amknight. - BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris. - CLI/proxy: add `openclaw proxy validate` so operators can verify effective proxy configuration, proxy reachability, and expected allow/deny destination behavior before deploying proxy-routed OpenClaw commands. (#73438) Thanks @jesse-merhi. - Agents/Codex: default Codex app-server dynamic tools to native-first, keeping OpenClaw integration tools while leaving file, patch, exec, and process ownership to the Codex harness. (#75308) Thanks @pashpashpash. diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index f9488f48eba..beb0481b544 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -98,7 +98,10 @@ import { extractMSTeamsPollVote } from "../polls.js"; import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js"; import { getMSTeamsRuntime } from "../runtime.js"; import type { MSTeamsTurnContext } from "../sdk-types.js"; -import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js"; +import { + recordMSTeamsSentMessage, + wasMSTeamsMessageSentWithPersistence, +} from "../sent-message-cache.js"; import { resolveMSTeamsSenderAccess } from "./access.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; import { resolveMSTeamsRouteSessionKey } from "./thread-session.js"; @@ -984,7 +987,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? ""); const replyToId = activity.replyToId ?? undefined; const implicitMentionKinds: Array<"reply_to_bot"> = - conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId) + conversationId && + replyToId && + (await wasMSTeamsMessageSentWithPersistence({ conversationId, messageId: replyToId })) ? ["reply_to_bot"] : []; diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts index 3184069fcd0..52370e2a037 100644 --- a/extensions/msteams/src/runtime.ts +++ b/extensions/msteams/src/runtime.ts @@ -1,9 +1,12 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; -const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } = - createPluginRuntimeStore({ - pluginId: "msteams", - errorMessage: "MSTeams runtime not initialized", - }); -export { getMSTeamsRuntime, setMSTeamsRuntime }; +const { + setRuntime: setMSTeamsRuntime, + getRuntime: getMSTeamsRuntime, + tryGetRuntime: getOptionalMSTeamsRuntime, +} = createPluginRuntimeStore({ + pluginId: "msteams", + errorMessage: "MSTeams runtime not initialized", +}); +export { getMSTeamsRuntime, getOptionalMSTeamsRuntime, setMSTeamsRuntime }; diff --git a/extensions/msteams/src/sent-message-cache.test.ts b/extensions/msteams/src/sent-message-cache.test.ts index 6892c0e1762..72f530ccd4f 100644 --- a/extensions/msteams/src/sent-message-cache.test.ts +++ b/extensions/msteams/src/sent-message-cache.test.ts @@ -1,15 +1,105 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setMSTeamsRuntime } from "./runtime.js"; import { clearMSTeamsSentMessageCache, recordMSTeamsSentMessage, wasMSTeamsMessageSent, + wasMSTeamsMessageSentWithPersistence, } from "./sent-message-cache.js"; +const TTL_MS = 24 * 60 * 60 * 1000; + describe("msteams sent message cache", () => { - it("records and resolves sent message ids", () => { + afterEach(() => { clearMSTeamsSentMessageCache(); + vi.restoreAllMocks(); + }); + + it("records and resolves sent message ids", () => { recordMSTeamsSentMessage("conv-1", "msg-1"); expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true); expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false); }); + + it("persists sent message ids when runtime state is available", async () => { + const register = vi.fn().mockResolvedValue(undefined); + const lookup = vi.fn().mockResolvedValue({ sentAt: Date.now() }); + const openKeyedStore = vi.fn(() => ({ + register, + lookup, + consume: vi.fn(), + delete: vi.fn(), + entries: vi.fn(), + clear: vi.fn(), + })); + setMSTeamsRuntime({ + state: { openKeyedStore }, + logging: { getChildLogger: () => ({ warn: vi.fn() }) }, + } as never); + + recordMSTeamsSentMessage("conv-1", "msg-2"); + + await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1)); + expect(register).toHaveBeenCalledWith("conv-1:msg-2", { sentAt: expect.any(Number) }); + + clearMSTeamsSentMessageCache(); + await expect( + wasMSTeamsMessageSentWithPersistence({ conversationId: "conv-1", messageId: "msg-2" }), + ).resolves.toBe(true); + expect(openKeyedStore).toHaveBeenCalledTimes(2); + expect(lookup).toHaveBeenCalledWith("conv-1:msg-2"); + + lookup.mockClear(); + await expect( + wasMSTeamsMessageSentWithPersistence({ conversationId: "conv-1", messageId: "msg-2" }), + ).resolves.toBe(true); + expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(true); + expect(lookup).not.toHaveBeenCalled(); + }); + + it("preserves the original TTL when recovering sent-message ids from persistent state", async () => { + const sentAt = 1_000_000; + const lookup = vi.fn().mockResolvedValue({ sentAt }); + const openKeyedStore = vi.fn(() => ({ + register: vi.fn(), + lookup, + consume: vi.fn(), + delete: vi.fn(), + entries: vi.fn(), + clear: vi.fn(), + })); + setMSTeamsRuntime({ + state: { openKeyedStore }, + logging: { getChildLogger: () => ({ warn: vi.fn() }) }, + } as never); + + vi.spyOn(Date, "now").mockReturnValue(sentAt + TTL_MS - 1); + await expect( + wasMSTeamsMessageSentWithPersistence({ conversationId: "conv-1", messageId: "msg-4" }), + ).resolves.toBe(true); + expect(wasMSTeamsMessageSent("conv-1", "msg-4")).toBe(true); + + lookup.mockClear(); + vi.mocked(Date.now).mockReturnValue(sentAt + TTL_MS + 1); + + expect(wasMSTeamsMessageSent("conv-1", "msg-4")).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it("falls back to in-memory sent-message markers when persistent state cannot open", () => { + const warn = vi.fn(); + setMSTeamsRuntime({ + state: { + openKeyedStore: vi.fn(() => { + throw new Error("sqlite unavailable"); + }), + }, + logging: { getChildLogger: () => ({ warn }) }, + } as never); + + recordMSTeamsSentMessage("conv-1", "msg-3"); + + expect(wasMSTeamsMessageSent("conv-1", "msg-3")).toBe(true); + expect(warn).toHaveBeenCalled(); + }); }); diff --git a/extensions/msteams/src/sent-message-cache.ts b/extensions/msteams/src/sent-message-cache.ts index 41240c52f05..12d0483e909 100644 --- a/extensions/msteams/src/sent-message-cache.ts +++ b/extensions/msteams/src/sent-message-cache.ts @@ -1,7 +1,22 @@ -const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +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; + lookup(key: string): Promise; +}; + let sentMessageCache: Map> | undefined; +let persistentStore: MSTeamsSentMessageStore | undefined; +let persistentStoreDisabled = false; function getSentMessageCache(): Map> { if (!sentMessageCache) { @@ -14,6 +29,50 @@ function getSentMessageCache(): Map> { 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({ + namespace: PERSISTENT_NAMESPACE, + maxEntries: PERSISTENT_MAX_ENTRIES, + defaultTtlMs: TTL_MS, + }); + return persistentStore; + } catch (error) { + disablePersistentSentMessageStore(error); + return undefined; + } +} + function cleanupExpired(scopeKey: string, entry: Map, now: number): void { for (const [id, timestamp] of entry) { if (now - timestamp > TTL_MS) { @@ -25,23 +84,62 @@ function cleanupExpired(scopeKey: string, entry: Map, now: numbe } } -export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void { - if (!conversationId || !messageId) { - return; - } - const now = Date.now(); +function rememberSentMessageInMemory( + conversationId: string, + messageId: string, + sentAt: number, +): void { const store = getSentMessageCache(); let entry = store.get(conversationId); if (!entry) { entry = new Map(); store.set(conversationId, entry); } - entry.set(messageId, now); + entry.set(messageId, sentAt); if (entry.size > 200) { - cleanupExpired(conversationId, entry, now); + 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 { + 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) { @@ -51,6 +149,26 @@ export function wasMSTeamsMessageSent(conversationId: string, messageId: string) return entry.has(messageId); } +export async function wasMSTeamsMessageSentWithPersistence(params: { + conversationId: string; + messageId: string; +}): Promise { + 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; }