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
This commit is contained in:
Alex Knight
2026-05-03 17:25:20 +10:00
committed by GitHub
parent 882ddc4665
commit 6ae09d029c
5 changed files with 235 additions and 18 deletions

View File

@@ -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/<guid>`) 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.

View File

@@ -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"]
: [];

View File

@@ -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<PluginRuntime>({
pluginId: "msteams",
errorMessage: "MSTeams runtime not initialized",
});
export { getMSTeamsRuntime, setMSTeamsRuntime };
const {
setRuntime: setMSTeamsRuntime,
getRuntime: getMSTeamsRuntime,
tryGetRuntime: getOptionalMSTeamsRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "msteams",
errorMessage: "MSTeams runtime not initialized",
});
export { getMSTeamsRuntime, getOptionalMSTeamsRuntime, setMSTeamsRuntime };

View File

@@ -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();
});
});

View File

@@ -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<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) {
@@ -14,6 +29,50 @@ function getSentMessageCache(): Map<string, Map<string, number>> {
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) {
@@ -25,23 +84,62 @@ function cleanupExpired(scopeKey: string, entry: Map<string, number>, 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<string, number>();
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<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) {
@@ -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<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;
}