mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user