refactor(channels): centralize inbound orchestration and remove internal compat (#109716)

* refactor(channels): centralize inbound turn orchestration

* refactor(runtime): remove stale compatibility paths

* chore(guards): reject internal deprecated API use

* refactor(channels): simplify core turn planning

* chore(guards): keep deprecated checks boundary-focused

* refactor(memory): keep modern config off compat barrel

* fix(msteams): preserve feedback learning

* test(channels): align modern inbound fixtures

* refactor(channels): finish modern inbound migration

* refactor(channels): tighten core inbound kernel

* fix(channels): preserve turn assembly narrowing

* test(sdk): keep runtime mock binding immutable

* test(matrix): isolate read policy runtime

* test(msteams): mock canonical reply factory

* test(slack): mock core inbound turn dispatch

* test(telegram): inject core session recorder

* test(signal): inject core session recorder

* test(googlechat): assert canonical inbound routing

* test(synology-chat): align core turn fixture

* fix(sdk): preserve direct DM runtime compat

* refactor(channels): own inbound envelope compat in core

* refactor(channels): trim inbound dispatch seams

* refactor(channels): remove redundant async wrappers

* test(synology-chat): type canonical dispatcher mock

* refactor(channels): remove remaining dead compat seams

* chore(sdk): refresh API baseline after rebase

* fix(channels): preserve direct DM identity metadata
This commit is contained in:
Peter Steinberger
2026-07-17 00:56:46 -07:00
committed by GitHub
parent 765bb37364
commit 0e792b6de3
159 changed files with 3884 additions and 4332 deletions
@@ -1,2 +1,2 @@
cd7189431f2805258afc4ecb993fe286a2606035636a5f4055b1158a76c27d62 plugin-sdk-api-baseline.json
660dbaa276792415bf7eb87c19240b19decd400b7e58745e87fe0f1f1cc33353 plugin-sdk-api-baseline.jsonl
7e2ff4dedb7b220a133cb419ee91e67585b9b9f2799706f336cdea973be80bfb plugin-sdk-api-baseline.json
0a85effc98fb17a65d463956704bcb2dab31076cf0fa0d85b463a765ec9dc439 plugin-sdk-api-baseline.jsonl
+1 -1
View File
@@ -571,7 +571,7 @@ Matrix inherits global defaults from `session.threadBindings` and supports per-c
- `threadBindings.idleHours`
- `threadBindings.maxAgeHours`
- `threadBindings.spawnSessions`: gates both subagent and ACP thread spawns.
- `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions`: narrower overrides for subagent-only or ACP-only spawns.
- Deprecated `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions` keys are migrated to `spawnSessions` by `openclaw doctor --fix`.
- `threadBindings.defaultSpawnContext`
Matrix thread-bound session spawns default on. Set `threadBindings.spawnSessions: false` to block top-level `/focus` and `/acp spawn --thread auto|here` from creating/binding Matrix threads. Set `threadBindings.defaultSpawnContext: "isolated"` when native subagent thread spawns should not fork the parent transcript.
+27 -25
View File
@@ -174,7 +174,7 @@ describe("handleClickClackInbound", () => {
correlationId: "fakeco.case_1",
});
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0];
expect(completionRequest?.agentId).toBe("service-bot");
@@ -270,9 +270,9 @@ describe("handleClickClackInbound", () => {
message: createMessage(),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(1);
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchTurn).toHaveBeenCalledTimes(1);
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("propagates account toolsAllow into agent reply dispatch", async () => {
@@ -297,9 +297,9 @@ describe("handleClickClackInbound", () => {
message: createMessage(),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(1);
const dispatchParams = dispatchReply.mock.calls[0]?.[0] as
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchTurn).toHaveBeenCalledTimes(1);
const dispatchParams = dispatchTurn.mock.calls[0]?.[0] as
| (Record<string, unknown> & {
toolsAllow?: unknown;
})
@@ -335,12 +335,12 @@ describe("handleClickClackInbound", () => {
}),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(2);
const withoutOptIn = dispatchReply.mock.calls[0]?.[0] as {
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchTurn).toHaveBeenCalledTimes(2);
const withoutOptIn = dispatchTurn.mock.calls[0]?.[0] as {
replyOptions?: { runId?: unknown; onItemEvent?: unknown; onModelSelected?: unknown };
};
const withOptIn = dispatchReply.mock.calls[1]?.[0] as {
const withOptIn = dispatchTurn.mock.calls[1]?.[0] as {
replyOptions?: {
onItemEvent?: unknown;
onModelSelected?: unknown;
@@ -377,7 +377,7 @@ describe("handleClickClackInbound", () => {
correlationId: "fakeco.case_2",
});
const dispatchParams = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0];
const dispatchParams = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0];
expect(dispatchParams?.replyOptions?.runId).toBe(`clickclack:${VALID_MESSAGE_ID}`);
await dispatchParams?.delivery.deliver({ text: "correlated reply" }, {} as never);
@@ -404,7 +404,7 @@ describe("handleClickClackInbound", () => {
}),
});
const delivery = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].delivery;
const delivery = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].delivery;
if (typeof delivery?.durable !== "function") {
throw new Error("expected ClickClack media durable delivery resolver");
}
@@ -437,7 +437,7 @@ describe("handleClickClackInbound", () => {
message: createMessage({ id: "msg_invalid" }),
});
expect(vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].replyOptions).toBe(
expect(vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].replyOptions).toBe(
undefined,
);
});
@@ -461,15 +461,15 @@ describe("handleClickClackInbound", () => {
}),
config: cfg,
message: createMessage({
channel_id: undefined,
channel_id: "",
direct_conversation_id: "dcn_1",
}),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(1);
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchTurn).toHaveBeenCalledTimes(1);
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("preserves session policy when an account overrides the routed agent", async () => {
@@ -503,8 +503,8 @@ describe("handleClickClackInbound", () => {
}),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply.mock.calls[0]?.[0].routeSessionKey).toBe(
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchTurn.mock.calls[0]?.[0].route.sessionKey).toBe(
"agent:service-bot:clickclack:direct:alice",
);
expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({
@@ -546,10 +546,12 @@ describe("handleClickClackInbound", () => {
}),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply.mock.calls[0]?.[0]).toMatchObject({
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchTurn.mock.calls[0]?.[0]).toMatchObject({
route: {
agentId: "service-bot",
routeSessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner",
sessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner",
},
});
});
@@ -584,7 +586,7 @@ describe("handleClickClackInbound", () => {
}),
});
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});
+46 -45
View File
@@ -1,3 +1,4 @@
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
/**
* Converts authorized ClickClack messages into OpenClaw agent/model replies and
@@ -150,6 +151,10 @@ export async function handleClickClackInbound(params: {
if (!access.shouldDispatch) {
return;
}
const conversationId = message.channel_id || message.direct_conversation_id;
if (!conversationId) {
return;
}
const isDirect = Boolean(message.direct_conversation_id);
const target = buildClickClackTarget(
isDirect
@@ -200,52 +205,53 @@ export async function handleClickClackInbound(params: {
});
}
const senderName = message.author?.display_name || message.author_id;
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, {
agentId: route.agentId,
}),
sessionKey: route.sessionKey,
});
// Preserve both normalized channel fields and ClickClack-native ids so reply
// routing, session recovery, and command authorization see the same message.
const body = runtime.channel.reply.formatAgentEnvelope({
const body = createChannelInboundEnvelopeBuilder({
cfg: params.config as OpenClawConfig,
route,
})({
channel: "ClickClack",
from: senderName,
timestamp: new Date(message.created_at),
previousTimestamp,
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
body: message.body,
});
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
const ctxPayload = runtime.channel.inbound.buildContext({
channel: CHANNEL_ID,
accountId: route.accountId ?? params.account.accountId,
messageId: message.id,
messageIdFull: message.id,
timestamp: new Date(message.created_at).getTime(),
from: target,
sender: { id: message.author_id, name: senderName },
conversation: {
kind: isDirect ? "direct" : "group",
id: conversationId,
label: isDirect ? senderName : message.channel_id,
threadId: message.parent_message_id ? message.thread_root_id : undefined,
nativeChannelId: conversationId,
},
route: {
agentId: route.agentId,
});
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: message.body,
RawBody: message.body,
CommandBody: message.body,
From: target,
To: target,
SessionKey: route.sessionKey,
AccountId: route.accountId ?? params.account.accountId,
ChatType: isDirect ? "direct" : "group",
WasMentioned: isDirect ? undefined : true,
ConversationLabel: isDirect ? senderName : message.channel_id,
GroupChannel: message.channel_id,
NativeChannelId: message.channel_id || message.direct_conversation_id,
MessageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
ThreadParentId: message.parent_message_id ? message.thread_root_id : undefined,
SenderName: senderName,
SenderId: message.author_id,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
MessageSid: message.id,
MessageSidFull: message.id,
ReplyToId: message.id,
Timestamp: message.created_at,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: target,
CommandAuthorized: access.commandAuthorized,
accountId: route.accountId,
routeSessionKey: route.sessionKey,
},
reply: {
to: target,
originatingTo: target,
replyToId: message.id,
messageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
threadParentId: message.parent_message_id ? message.thread_root_id : undefined,
},
message: { body, bodyForAgent: message.body, rawBody: message.body, commandBody: message.body },
access: {
commands: { authorized: access.commandAuthorized },
mentions: {
canDetectMention: !isDirect,
wasMentioned: !isDirect,
},
},
extra: { GroupChannel: message.channel_id },
});
const runId = resolveClickClackAgentRunId(message.id);
const activityReplyOptions = activity
@@ -266,17 +272,12 @@ export async function handleClickClackInbound(params: {
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
}
: undefined;
const dispatchPromise = runtime.channel.inbound.dispatchReply({
const dispatchPromise = runtime.channel.inbound.dispatch({
cfg: params.config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: params.account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
toolsAllow: params.account.toolsAllow,
// Provenance stamping shares the agentActivity opt-in: with the flag off
// the extension's wire payloads stay byte-identical to pre-activity
+1 -1
View File
@@ -66,7 +66,7 @@ export function createCodexAppServerAgentHarness(options: {
delegatedExecutionPluginIds: ["voice-call"],
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
deliveryDefaults: {
sourceVisibleReplies: "message_tool",
visibleReplies: "message_tool",
},
authBootstrap: "harness",
authBinding: {
+2 -2
View File
@@ -121,7 +121,7 @@ describe("codex plugin", () => {
expect(agentHarnessRegistration.id).toBe("codex");
expect(agentHarnessRegistration.label).toBe("Codex agent harness");
expect(agentHarnessRegistration.deliveryDefaults).toEqual({
sourceVisibleReplies: "message_tool",
visibleReplies: "message_tool",
});
expect(typeof agentHarnessRegistration.dispose).toBe("function");
expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function");
@@ -419,7 +419,7 @@ describe("codex plugin", () => {
bindingStore: testCodexAppServerBindingStore,
});
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(harness.deliveryDefaults?.visibleReplies).toBe("message_tool");
expect(
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
.supported,
@@ -153,9 +153,7 @@ export function createCodexAttemptLifecycleController(
startedAt: attemptStartedAt,
endedAt: Date.now(),
...data,
...((params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd)
? { phase: "finishing" }
: {}),
...(params.deferTerminalLifecycle ? { phase: "finishing" } : {}),
},
});
state.lifecycleTerminalEmitted = true;
@@ -51,25 +51,15 @@ setupRunAttemptTestHooks();
describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
it.each([
{ label: "completed", status: "completed" as const, error: undefined, legacy: false },
{ label: "failed", status: "failed" as const, error: "codex exploded", legacy: false },
{
label: "completed legacy alias",
status: "completed" as const,
error: undefined,
legacy: true,
},
])("defers $label lifecycle terminal ownership", async ({ status, error, legacy }) => {
{ label: "completed", status: "completed" as const, error: undefined },
{ label: "failed", status: "failed" as const, error: "codex exploded" },
])("defers $label lifecycle terminal ownership", async ({ status, error }) => {
const onRunAgentEvent = vi.fn();
const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`);
const workspaceDir = path.join(tempDir, `workspace-${status}`);
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
if (legacy) {
params.deferTerminalLifecycleEnd = true;
} else {
params.deferTerminalLifecycle = true;
}
params.onAgentEvent = onRunAgentEvent;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
@@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import {
adaptMessagePresentationForChannel,
normalizeInteractiveReply,
normalizeLegacyInteractiveReply,
normalizeMessagePresentation,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
@@ -153,7 +153,7 @@ export async function handleDiscordMessageAction(
? undefined
: (params.components ??
presentationComponents ??
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive)));
buildDiscordInteractiveComponents(normalizeLegacyInteractiveReply(params.interactive)));
const hasComponents =
Boolean(rawComponents) &&
(typeof rawComponents === "function" || typeof rawComponents === "object");
@@ -178,11 +178,9 @@ export async function dispatchDiscordComponentEvent(params: {
const {
createReplyReferencePlanner,
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext,
resolveChunkMode,
resolveTextChunkLimit,
recordInboundSession,
} = await (async () => {
const conversationRuntime = await loadConversationRuntime();
return {
@@ -273,12 +271,8 @@ export async function dispatchDiscordComponentEvent(params: {
cfg: ctx.cfg,
channel: "discord",
accountId,
agentId,
routeSessionKey: sessionKey,
storePath,
route: { agentId, sessionKey },
ctxPayload,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
record: {
updateLastRoute: interactionCtx.isDirectMessage
? {
@@ -369,6 +369,55 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
},
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
const replyRuntime = await import("openclaw/plugin-sdk/reply-runtime");
return {
...actual,
dispatchChannelInboundTurn: async (
plan: Parameters<typeof actual.dispatchChannelInboundTurn>[0],
) => {
const { cfg, route, delivery, sessionInitRetry, ...prepared } = plan;
const runDispatch = async () => {
for (let retryIndex = 0; ; retryIndex += 1) {
try {
return await replyRuntime.dispatchReplyWithBufferedBlockDispatcher({
ctx: plan.ctxPayload,
cfg,
dispatcherOptions: {
...plan.dispatcherOptions,
deliver: delivery.deliver,
onError: delivery.onError,
},
toolsAllow: plan.toolsAllow,
replyOptions: plan.replyOptions,
replyResolver: plan.replyResolver,
});
} catch (error) {
const delayMs = sessionInitRetry?.delaysMs[retryIndex];
const message = error instanceof Error ? error.message : String(error);
if (
delayMs === undefined ||
sessionInitRetry?.signal?.aborted === true ||
!/^reply session initialization conflicted for \S+$/u.test(message)
) {
throw error;
}
await sessionInitRetry?.sleep?.(delayMs, sessionInitRetry.signal);
}
}
};
return await actual.runPreparedInboundReply({
...prepared,
routeSessionKey: route.sessionKey,
storePath: resolveStorePath(cfg.session?.store, { agentId: route.agentId }),
recordInboundSession,
runDispatch,
});
},
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSession(...args),
resolvePinnedMainDmOwnerFromAllowlist: (params: {
@@ -8,7 +8,7 @@ import {
shouldAckReaction as shouldAckReactionGate,
} from "openclaw/plugin-sdk/channel-feedback";
import {
dispatchChannelInboundReply,
dispatchChannelInboundTurn,
hasFinalInboundReplyDispatch,
} from "openclaw/plugin-sdk/channel-inbound";
import {
@@ -24,8 +24,6 @@ import {
resolveChannelStreamingBlockEnabled,
resolveTranscriptBackedChannelFinalText,
} from "openclaw/plugin-sdk/channel-outbound";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
@@ -36,7 +34,13 @@ import {
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, shouldLogVerbose, sleep } from "openclaw/plugin-sdk/runtime-env";
import {
danger,
logVerbose,
shouldLogVerbose,
sleep,
sleepWithAbort,
} from "openclaw/plugin-sdk/runtime-env";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
@@ -57,15 +61,11 @@ import {
import { buildDiscordMessageProcessContext } from "./message-handler.context.js";
import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import {
completeDiscordSessionConflict,
withDiscordSessionRetry,
} from "./message-handler.retry.js";
import { completeDiscordSessionConflict } from "./message-handler.retry.js";
import { deliverDiscordReply, formatDiscordReplyDeliveryFailure } from "./reply-delivery.js";
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
const loadReplyRuntime = createLazyRuntimeModule(() => import("openclaw/plugin-sdk/reply-runtime"));
const TARGETED_ONLY_ALLOWED_MENTIONS = {
parse: ["users", "roles"],
} as APIAllowedMentions;
@@ -185,7 +185,6 @@ async function processDiscordMessageInner(
if (boundThreadId && typeof threadBindings.touchThread === "function") {
threadBindings.touchThread({ threadId: boundThreadId });
}
const { dispatchReplyWithBufferedBlockDispatcher: dispatchReply } = await loadReplyRuntime();
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
cfg,
ctx: {
@@ -991,7 +990,11 @@ async function processDiscordMessageInner(
);
};
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
let dispatchResult: Awaited<ReturnType<typeof dispatchReply>> | null = null;
let dispatchResult: {
queuedFinal: boolean;
counts: Record<ReplyDispatchKind, number>;
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
} | null = null;
let dispatchError = false;
let dispatchAborted = false;
const deliverPendingToolWarningFinalIfNeeded = async () => {
@@ -1015,17 +1018,18 @@ async function processDiscordMessageInner(
dispatchAborted = true;
return;
}
const preparedResult = await dispatchChannelInboundReply({
const preparedResult = await dispatchChannelInboundTurn({
cfg,
channel: "discord",
accountId: route.accountId,
agentId: route.agentId,
routeSessionKey: persistedSessionKey,
storePath: turn.storePath,
route: { agentId: route.agentId, sessionKey: persistedSessionKey },
ctxPayload,
recordInboundSession,
afterRecord: queueInitialAckReactionAfterRecord,
dispatchReplyWithBufferedBlockDispatcher: withDiscordSessionRetry(dispatchReply, abortSignal),
sessionInitRetry: {
delaysMs: [250, 1_000, 2_500],
signal: abortSignal,
sleep: sleepWithAbort,
},
dispatcherOptions: {
...replyPipeline,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
@@ -1,13 +1,9 @@
// Discord plugin module implements narrow inbound dispatch retry behavior.
import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { DiscordRetryableInboundError } from "./inbound-dedupe.js";
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /^reply session initialization conflicted for \S+$/u;
const DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS = [250, 1_000, 2_500] as const;
const DISCORD_SESSION_CONFLICT_FAILURE_TEXT =
"⚠️ Couldn't process this message because the session stayed busy. Please try again in a moment.";
type AsyncDispatch<TParams, TResult> = (params: TParams) => Promise<TResult>;
type TerminalFailureDelivery = (
payload: { text: string; isError: true },
info: { kind: "final" },
@@ -19,63 +15,12 @@ function isReplySessionInitConflictError(error: unknown): boolean {
return REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(message);
}
class DiscordReplySessionConflictExhaustedError extends DiscordRetryableInboundError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "DiscordReplySessionConflictExhaustedError";
}
}
async function dispatchDiscordReplyWithSessionConflictRetry<T>(params: {
dispatch: () => Promise<T>;
abortSignal?: AbortSignal;
onRetry?: (attempt: number, delayMs: number) => void;
}): Promise<T> {
for (let retryIndex = 0; ; retryIndex += 1) {
try {
return await params.dispatch();
} catch (error) {
if (!isReplySessionInitConflictError(error)) {
throw error;
}
const delayMs = DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS[retryIndex];
if (delayMs === undefined) {
const message = error instanceof Error ? error.message : String(error);
// Let the caller either complete with a visible terminal notice or
// reopen replay ownership when that notice cannot land.
throw new DiscordReplySessionConflictExhaustedError(
`discord: reply session init conflict persisted after shared and channel retries: ${message}`,
{ cause: error },
);
}
params.onRetry?.(retryIndex + 1, delayMs);
await sleepWithAbort(delayMs, params.abortSignal);
}
}
}
export function withDiscordSessionRetry<TParams, TResult>(
dispatch: AsyncDispatch<TParams, TResult>,
abortSignal: AbortSignal | undefined,
): AsyncDispatch<TParams, TResult> {
return (dispatchParams) =>
dispatchDiscordReplyWithSessionConflictRetry({
dispatch: () => dispatch(dispatchParams),
abortSignal,
onRetry: (attempt, delayMs) => {
logVerbose(
`discord: reply session init conflict; retrying dispatch ${attempt} after ${delayMs}ms`,
);
},
});
}
export async function completeDiscordSessionConflict(
error: unknown,
deliver: TerminalFailureDelivery,
onDeliveryError: DeliveryErrorHandler,
): Promise<boolean> {
if (!(error instanceof DiscordReplySessionConflictExhaustedError)) {
if (!isReplySessionInitConflictError(error)) {
return false;
}
try {
@@ -87,7 +32,10 @@ export async function completeDiscordSessionConflict(
} catch (deliveryError) {
// Keep the conflict retryable when its visible terminal notice cannot land.
onDeliveryError(deliveryError, { kind: "final" });
return false;
throw new DiscordRetryableInboundError(
`discord: reply session init conflict exhausted and terminal notice failed: ${String(deliveryError)}`,
{ cause: error },
);
}
}
+4 -4
View File
@@ -1,12 +1,12 @@
// Discord plugin module implements shared interactive behavior.
import {
reduceInteractiveReply,
reduceLegacyInteractiveReply,
resolveMessagePresentationButtonAction,
resolveMessagePresentationOptionAction,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveButtonStyle,
InteractiveReply,
LegacyInteractiveReply,
MessagePresentation,
MessagePresentationButton,
MessagePresentationOption,
@@ -141,9 +141,9 @@ function appendDiscordButtonBlocks(
* @deprecated Use buildDiscordPresentationComponents with MessagePresentation.
*/
export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply,
interactive?: LegacyInteractiveReply,
): DiscordComponentMessageSpec | undefined {
const blocks = reduceInteractiveReply(
const blocks = reduceLegacyInteractiveReply(
interactive,
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
(state, block) => {
+84 -75
View File
@@ -1,5 +1,4 @@
// Feishu tests cover bot.broadcast plugin behavior.
import type { EnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
import { feishuGroupNameCache } from "./bot-group-name-state.js";
@@ -7,8 +6,16 @@ import type { FeishuMessageEvent } from "./bot.js";
import { handleFeishuMessage } from "./bot.js";
import { setFeishuRuntime } from "./runtime.js";
const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } =
vi.hoisted(() => ({
const {
builtInboundContextCalls,
mockCreateFeishuReplyDispatcher,
mockCreateFeishuClient,
mockDispatchInboundMessage,
mockRecordInboundSession,
mockResolveAgentRoute,
mockResolveStorePath,
} = vi.hoisted(() => ({
builtInboundContextCalls: [] as Array<Record<string, unknown>>,
mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({
dispatcher: {
sendToolResult: vi.fn(),
@@ -24,9 +31,47 @@ const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgen
ensureNoVisibleReplyFallback: vi.fn(),
})),
mockCreateFeishuClient: vi.fn(),
mockDispatchInboundMessage: vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }),
mockRecordInboundSession: vi.fn().mockResolvedValue(undefined),
mockResolveAgentRoute: vi.fn(),
mockResolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
return {
...actual,
buildChannelInboundEventContext: (
params: Parameters<typeof actual.buildChannelInboundEventContext>[0],
) =>
actual.buildChannelInboundEventContext({
...params,
finalize: (ctx) => {
builtInboundContextCalls.push(ctx);
return ctx as never;
},
}),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage };
});
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
"openclaw/plugin-sdk/session-store-runtime",
);
return { ...actual, resolveStorePath: mockResolveStorePath };
});
vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
}));
@@ -48,43 +93,7 @@ function createRuntimeEnv() {
}
describe("broadcast dispatch", () => {
const finalizeInboundContextCalls: Array<Record<string, unknown>> = [];
const mockGetChatInfo = vi.fn();
const mockFinalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"] = (
ctx,
) => {
finalizeInboundContextCalls.push(ctx);
return {
...ctx,
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
CommandTurn: {
kind: "normal",
source: "message",
authorized: false,
},
};
};
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockWithReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"] = async ({
dispatcher,
run,
onSettled,
}) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
};
const resolveEnvelopeFormatOptionsMock: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"] =
() => ({}) satisfies EnvelopeFormatOptions;
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/inbound-clip.mp4",
@@ -99,18 +108,10 @@ describe("broadcast dispatch", () => {
resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
recordInboundSession: vi.fn().mockResolvedValue(undefined),
},
reply: {
resolveEnvelopeFormatOptions: resolveEnvelopeFormatOptionsMock,
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext:
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
withReplyDispatcher:
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
resolveStorePath: mockResolveStorePath,
recordInboundSession: mockRecordInboundSession,
},
reply: {},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
@@ -135,9 +136,13 @@ describe("broadcast dispatch", () => {
if (!("runDispatch" in turn)) {
throw new Error("feishu broadcast test runtime only supports prepared turns");
}
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
const routeSessionKey = "route" in turn ? turn.route.sessionKey : turn.routeSessionKey;
const storePath = "storePath" in turn ? turn.storePath : mockResolveStorePath();
const recordInboundSession =
"recordInboundSession" in turn ? turn.recordInboundSession : mockRecordInboundSession;
await recordInboundSession({
storePath,
sessionKey: turn.ctxPayload.SessionKey ?? routeSessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
@@ -148,7 +153,7 @@ describe("broadcast dispatch", () => {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
routeSessionKey,
dispatchResult: await turn.runDispatch(),
};
}),
@@ -219,8 +224,12 @@ describe("broadcast dispatch", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDispatchInboundMessage.mockReset().mockResolvedValue({
queuedFinal: false,
counts: { final: 1 },
});
feishuGroupNameCache.clear();
finalizeInboundContextCalls.length = 0;
builtInboundContextCalls.length = 0;
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
@@ -277,8 +286,8 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey);
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
const sessionKeys = builtInboundContextCalls.map((call) => call.SessionKey);
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
const recordCalls = (
@@ -320,7 +329,7 @@ describe("broadcast dispatch", () => {
]);
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
expect(
finalizeInboundContextCalls
builtInboundContextCalls
.map((call) => ({
sessionKey: call.SessionKey,
groupSubject: call.GroupSubject,
@@ -347,7 +356,7 @@ describe("broadcast dispatch", () => {
});
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
mockDispatchReplyFromConfig
mockDispatchInboundMessage
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({
queuedFinal: false,
@@ -389,7 +398,7 @@ describe("broadcast dispatch", () => {
});
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
mockDispatchReplyFromConfig
mockDispatchInboundMessage
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({
queuedFinal: true,
@@ -430,7 +439,7 @@ describe("broadcast dispatch", () => {
});
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
mockDispatchReplyFromConfig
mockDispatchInboundMessage
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({
queuedFinal: false,
@@ -484,7 +493,7 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
expect(mockGetChatInfo).not.toHaveBeenCalled();
});
@@ -502,7 +511,7 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
expect(mockGetChatInfo).not.toHaveBeenCalled();
});
@@ -539,14 +548,14 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
expect(finalizeInboundContextCalls).toHaveLength(1);
expect(finalizeInboundContextCalls[0]?.SessionKey).toBe(
expect(builtInboundContextCalls).toHaveLength(1);
expect(builtInboundContextCalls[0]?.SessionKey).toBe(
"agent:main:feishu:group:oc-broadcast-group",
);
expect(finalizeInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
expect(finalizeInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
expect(builtInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
expect(builtInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
});
@@ -584,11 +593,11 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(),
accountId: "account-A",
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
mockDispatchReplyFromConfig.mockClear();
mockDispatchInboundMessage.mockClear();
mockGetChatInfo.mockClear();
finalizeInboundContextCalls.length = 0;
builtInboundContextCalls.length = 0;
await handleFeishuMessage({
cfg,
@@ -596,7 +605,7 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(),
accountId: "account-B",
});
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
expect(mockGetChatInfo).not.toHaveBeenCalled();
});
@@ -634,10 +643,10 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(),
});
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
const sessionKey =
typeof finalizeInboundContextCalls[0]?.SessionKey === "string"
? finalizeInboundContextCalls[0].SessionKey
typeof builtInboundContextCalls[0]?.SessionKey === "string"
? builtInboundContextCalls[0].SessionKey
: "";
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
});
+59 -38
View File
@@ -169,7 +169,7 @@ function buildDefaultResolveRoute(): ResolvedAgentRoute {
let currentRuntimeConfig = {} as ClawdbotConfig;
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
return {
const runtime = {
config: {
current: vi.fn(() => currentRuntimeConfig),
},
@@ -209,9 +209,14 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
kind: "message",
canStartAgentTurn: true,
});
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
if (!("route" in turn) || !("runDispatch" in turn)) {
throw new Error("expected a prepared channel turn plan");
}
await runtime.channel.session.recordInboundSession({
storePath: runtime.channel.session.resolveStorePath(turn.cfg.session?.store, {
agentId: turn.route.agentId,
}),
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
@@ -229,6 +234,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
} as unknown as PluginRuntime;
return runtime;
}
const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
@@ -239,7 +245,6 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
mockResolveStorePath(params);
const resolveEnvelopeFormatOptionsMock = () => ({});
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const withReplyDispatcherMock = async ({
run,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
@@ -299,6 +304,8 @@ const {
mockResolveFeishuReasoningPreviewEnabled,
mockTranscribeFirstAudio,
mockMaybeCreateDynamicAgent,
mockBuildChannelInboundEventContext,
mockDispatchInboundMessage,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: createReplyDispatcher(),
@@ -336,8 +343,49 @@ const {
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
mockTranscribeFirstAudio: vi.fn(),
mockMaybeCreateDynamicAgent: vi.fn(),
mockBuildChannelInboundEventContext: vi.fn(),
mockDispatchInboundMessage: vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }),
}));
const finalizeInboundContextMock = mockBuildChannelInboundEventContext;
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
return {
...actual,
formatAgentEnvelope: ({ body }: { body: string }) => body,
resolveEnvelopeFormatOptions: () => ({}),
buildChannelInboundEventContext: (
params: Parameters<typeof actual.buildChannelInboundEventContext>[0],
) =>
actual.buildChannelInboundEventContext({
...params,
finalize: (ctx) => {
mockBuildChannelInboundEventContext(ctx);
return ctx as never;
},
}),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage };
});
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
"openclaw/plugin-sdk/session-store-runtime",
);
return { ...actual, resolveStorePath: mockResolveStorePath };
});
vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
}));
@@ -966,42 +1014,11 @@ describe("handleFeishuMessage ACP routing", () => {
);
expect(dispatcherOptions.allowReasoningPreview).toBe(true);
});
it("falls back to full runtime channel when partial channelRuntime lacks inbound", async () => {
const partialChannelRuntime = {
runtimeContexts: {} as PluginRuntime["channel"]["runtimeContexts"],
} as PluginRuntime["channel"];
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-partial-runtime",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
channelRuntime: partialChannelRuntime,
});
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1);
});
});
describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({
...ctx,
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
}));
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockFinalizeInboundContext = mockBuildChannelInboundEventContext;
const mockDispatchReplyFromConfig = mockDispatchInboundMessage;
const mockWithReplyDispatcher = vi.fn(
async ({
dispatcher,
@@ -1037,6 +1054,10 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDispatchReplyFromConfig.mockReset().mockResolvedValue({
queuedFinal: false,
counts: { final: 1 },
});
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
+22 -37
View File
@@ -1,7 +1,8 @@
// Feishu plugin module implements bot behavior.
import {
buildChannelInboundEventContext,
formatAgentEnvelope,
formatInboundMediaUnavailableText,
resolveEnvelopeFormatOptions,
toInboundMediaFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/channel-outbound";
@@ -17,6 +18,7 @@ import {
createChannelHistoryWindow,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import {
resolveDefaultGroupPolicy,
@@ -24,6 +26,7 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
@@ -977,7 +980,7 @@ export async function handleFeishuMessage(params: {
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender");
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx: agentFacingCtx,
quotedContent,
@@ -990,7 +993,7 @@ export async function handleFeishuMessage(params: {
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
}
const body = core.channel.reply.formatAgentEnvelope({
const body = formatAgentEnvelope({
channel: "Feishu",
from: envelopeFrom,
timestamp: new Date(),
@@ -1008,7 +1011,7 @@ export async function handleFeishuMessage(params: {
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
formatAgentEnvelope({
channel: "Feishu",
// Preserve speaker identity in group history as well.
from: `${ctx.chatId}:${entry.sender}`,
@@ -1116,7 +1119,7 @@ export async function handleFeishuMessage(params: {
return threadContext;
}
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: agentSessionKey,
@@ -1182,7 +1185,7 @@ export async function handleFeishuMessage(params: {
: relevantMessages.slice(1);
const historyParts = historyMessages.map((msg) => {
const role = msg.senderType === "app" ? "assistant" : "user";
return core.channel.reply.formatAgentEnvelope({
return formatAgentEnvelope({
channel: "Feishu",
from: `${msg.senderId ?? "Unknown"} (${role})`,
timestamp: msg.createTime,
@@ -1216,7 +1219,6 @@ export async function handleFeishuMessage(params: {
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
return buildChannelInboundEventContext({
channel: "feishu",
finalize: core.channel.reply.finalizeInboundContext,
supplemental: {
quote: quotedContent ? { id: ctx.parentId, body: quotedContent } : undefined,
thread: {
@@ -1388,7 +1390,7 @@ export async function handleFeishuMessage(params: {
}
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, {
const agentStorePath = resolveStorePath(cfg.session?.store, {
agentId,
});
const agentRecord = {
@@ -1456,12 +1458,11 @@ export async function handleFeishuMessage(params: {
raw: ctx,
}),
resolveTurn: () => ({
cfg,
channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
route: { agentId, sessionKey: agentSessionKey },
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
@@ -1469,18 +1470,14 @@ export async function handleFeishuMessage(params: {
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
run: () =>
core.channel.reply.dispatchReplyFromConfig({
dispatchInboundMessage({
ctx: agentCtx,
cfg,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions,
}),
}),
}),
},
});
if (
@@ -1524,24 +1521,19 @@ export async function handleFeishuMessage(params: {
raw: ctx,
}),
resolveTurn: () => ({
cfg,
channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
route: { agentId, sessionKey: agentSessionKey },
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher: noopDispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
dispatchInboundMessage({
ctx: agentCtx,
cfg,
dispatcher: noopDispatcher,
}),
}),
}),
},
});
}
@@ -1592,7 +1584,7 @@ export async function handleFeishuMessage(params: {
);
const identity = resolveAgentOutboundIdentity(effectiveCfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
const storePath = resolveStorePath(effectiveCfg.session?.store, {
agentId: route.agentId,
});
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
@@ -1637,12 +1629,11 @@ export async function handleFeishuMessage(params: {
raw: ctx,
}),
resolveTurn: () => ({
cfg: effectiveCfg,
channel: "feishu",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute: buildFeishuInboundLastRouteUpdate({
sessionKey: route.sessionKey,
@@ -1666,20 +1657,14 @@ export async function handleFeishuMessage(params: {
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
dispatchInboundMessage({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions,
}),
}),
}),
},
});
if (!turnResult.dispatched) {
+6 -6
View File
@@ -30,10 +30,10 @@ import {
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import {
interactiveReplyToPresentation,
normalizeInteractiveReply,
legacyInteractiveReplyToPresentation,
normalizeLegacyInteractiveReply,
normalizeMessagePresentation,
resolveInteractiveTextFallback,
resolveLegacyInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
@@ -1074,10 +1074,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
const textCard = readNativeFeishuCardJson(text, {
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
});
const interactive = normalizeInteractiveReply(ctx.params.interactive);
const interactive = normalizeLegacyInteractiveReply(ctx.params.interactive);
const presentation =
normalizeMessagePresentation(ctx.params.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
const mediaUrl = readFeishuMediaParam(ctx.params);
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
if (textCard && !presentation) {
@@ -1088,7 +1088,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
presentation,
fallbackText: textCard
? undefined
: resolveInteractiveTextFallback({ text, interactive }),
: resolveLegacyInteractiveTextFallback({ text, interactive }),
})
: undefined;
const presentationCard =
+4 -2
View File
@@ -1,5 +1,7 @@
// Feishu plugin module implements comment dispatcher behavior.
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import {
@@ -56,10 +58,10 @@ export function createFeishuCommentReplyDispatcher(
});
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({
createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
onReplyStart: async () => {
await typingReaction.start();
},
+43 -77
View File
@@ -1,5 +1,4 @@
// Feishu tests cover comment handler plugin behavior.
import type { PreparedInboundReply } from "openclaw/plugin-sdk/channel-inbound";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
import { handleFeishuCommentEvent } from "./comment-handler.js";
@@ -10,6 +9,7 @@ const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
const dispatchInboundMessageMock = vi.hoisted(() => vi.fn());
vi.mock("./monitor.comment.js", () => ({
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
@@ -31,6 +31,11 @@ vi.mock("./drive.js", () => ({
deliverCommentThreadText: deliverCommentThreadTextMock,
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>()),
dispatchInboundMessage: dispatchInboundMessageMock,
}));
async function raceWithNextMacrotask<T>(promise: Promise<T>): Promise<T | "pending"> {
return await Promise.race([
promise,
@@ -83,38 +88,19 @@ function createTestRuntime(overrides?: {
readAllowFromStore?: () => Promise<unknown[]>;
upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>;
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
dispatchReplyFromConfig?: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"];
withReplyDispatcher?: PluginRuntime["channel"]["reply"]["withReplyDispatcher"];
}) {
const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ctx);
const dispatchReplyFromConfig =
overrides?.dispatchReplyFromConfig ??
vi.fn(async () => ({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
}));
const withReplyDispatcher =
overrides?.withReplyDispatcher ??
vi.fn(
async ({
run,
onSettled,
}: {
run: () => Promise<unknown>;
onSettled?: () => Promise<void> | void;
}) => {
try {
return await run();
} finally {
await onSettled?.();
}
},
);
const recordInboundSession = vi.fn(async () => {});
const dispatchPreparedForTest = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
const recordInboundSession = vi.fn(async (_params: unknown) => {});
type PreparedCommentTurnPlan = {
route: { agentId: string; sessionKey: string };
ctxPayload: { SessionKey?: string };
record?: Record<string, unknown> & { onRecordError?: (error: unknown) => void };
runDispatch: () => Promise<unknown>;
};
const dispatchPreparedForTest = vi.fn(async (turn: PreparedCommentTurnPlan) => {
const storePath = "/tmp/feishu-session-store.json";
await recordInboundSession({
storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
@@ -126,7 +112,7 @@ function createTestRuntime(overrides?: {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
routeSessionKey: turn.route.sessionKey,
dispatchResult,
};
});
@@ -151,9 +137,11 @@ function createTestRuntime(overrides?: {
resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())),
},
reply: {
finalizeInboundContext,
dispatchReplyFromConfig,
withReplyDispatcher,
settleReplyDispatcher: vi.fn(async ({ dispatcher, onSettled }) => {
dispatcher.markComplete();
await dispatcher.waitForIdle();
await onSettled?.();
}),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
@@ -176,7 +164,7 @@ function createTestRuntime(overrides?: {
if (!("runDispatch" in turn)) {
throw new Error("feishu comment test runtime only supports prepared turns");
}
return await dispatchPreparedForTest(turn as PreparedInboundReply<unknown>);
return await dispatchPreparedForTest(turn as PreparedCommentTurnPlan);
}) as unknown as PluginRuntime["channel"]["inbound"]["run"],
},
pairing: {
@@ -206,6 +194,10 @@ describe("handleFeishuCommentEvent", () => {
beforeEach(() => {
vi.clearAllMocks();
dispatchInboundMessageMock.mockResolvedValue({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
});
currentRuntimeConfig = buildConfig();
maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({
created: false,
@@ -270,20 +262,16 @@ describe("handleFeishuCommentEvent", () => {
);
const runtime = (await import("./runtime.js")).getFeishuRuntime();
const finalizeInboundContext = runtime.channel.reply.finalizeInboundContext as ReturnType<
typeof vi.fn
>;
const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType<
typeof vi.fn
>;
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(finalizeInboundContext).toHaveBeenCalledTimes(1);
const finalizedContext = mockCallArg(finalizeInboundContext, "finalizeInboundContext") as
| Record<string, unknown>
| undefined;
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
const finalizedContext = (
mockCallArg(dispatchInboundMessageMock, "dispatchInboundMessage") as {
ctx?: Record<string, unknown>;
}
).ctx;
expect({
from: finalizedContext?.From,
to: finalizedContext?.To,
@@ -306,7 +294,6 @@ describe("handleFeishuCommentEvent", () => {
| { sessionKey?: string }
| undefined;
expect(recordArgs?.sessionKey).toBe("agent:main:feishu:direct:comment-doc:docx:doc_token_1");
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("allows comment senders matched by user_id allowlist entries", async () => {
@@ -332,10 +319,7 @@ describe("handleFeishuCommentEvent", () => {
} as never,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
});
@@ -376,10 +360,7 @@ describe("handleFeishuCommentEvent", () => {
| undefined;
expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender");
expect(dynamicAgentArgs?.accountId).toBe("default");
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
it("drops a comment denied by refreshed dynamic-agent policy", async () => {
@@ -410,12 +391,9 @@ describe("handleFeishuCommentEvent", () => {
} as never,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
});
it("issues a pairing challenge before dynamic comment-agent creation", async () => {
@@ -446,12 +424,9 @@ describe("handleFeishuCommentEvent", () => {
} as never,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
expect(deliverCommentThreadTextMock).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
});
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
@@ -506,10 +481,7 @@ describe("handleFeishuCommentEvent", () => {
].join("\n"),
is_whole_comment: false,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
});
it("passes whole-comment metadata to the comment reply dispatcher", async () => {
@@ -565,10 +537,8 @@ describe("handleFeishuCommentEvent", () => {
});
it("always finalizes comment typing cleanup even when dispatch fails", async () => {
const dispatchReplyFromConfig = vi.fn(async () => {
throw new Error("dispatch failed");
});
const runtime = createTestRuntime({ dispatchReplyFromConfig });
dispatchInboundMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
const runtime = createTestRuntime();
setFeishuRuntime(runtime);
const markRunComplete = vi.fn();
const markDispatchIdle = vi.fn();
@@ -669,10 +639,6 @@ describe("handleFeishuCommentEvent", () => {
});
expect(startTypingReaction).not.toHaveBeenCalled();
const runtime = (await import("./runtime.js")).getFeishuRuntime();
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
});
+33 -36
View File
@@ -1,5 +1,7 @@
// Feishu plugin module implements comment handler behavior.
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
@@ -214,36 +216,36 @@ export async function handleFeishuCommentEvent(
fileToken: turn.fileToken,
});
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: bodyForAgent,
BodyForAgent: bodyForAgent,
RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
From: `feishu:${turn.senderId}`,
To: commentTarget,
SessionKey: commentSessionKey,
AccountId: route.accountId,
ChatType: "direct",
ConversationLabel: turn.documentTitle
const rawBody = turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt;
const conversationLabel = turn.documentTitle
? `Feishu comment · ${turn.documentTitle}`
: "Feishu comment",
SenderName: turn.senderId,
SenderId: turn.senderId,
Provider: "feishu",
Surface: "feishu-comment",
MessageSid: turn.messageId,
// For Feishu comment turns, MessageThreadId carries the inbound reply_id so
// comment-aware tools can clean typing reaction before sending visible output.
MessageThreadId: turn.replyId,
Timestamp: parseTimestampMs(turn.timestamp),
WasMentioned: turn.isMentioned,
CommandAuthorized: false,
OriginatingChannel: "feishu",
OriginatingTo: commentTarget,
});
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
: "Feishu comment";
const ctxPayload = buildChannelInboundEventContext({
channel: "feishu",
accountId: route.accountId,
surface: "feishu-comment",
messageId: turn.messageId,
timestamp: parseTimestampMs(turn.timestamp),
from: `feishu:${turn.senderId}`,
sender: { id: turn.senderId, name: turn.senderId },
conversation: { kind: "direct", id: commentTarget, label: conversationLabel },
route: {
agentId: route.agentId,
accountId: route.accountId,
routeSessionKey: commentSessionKey,
dispatchSessionKey: commentSessionKey,
},
reply: {
to: commentTarget,
originatingTo: commentTarget,
// Comment-aware tools use the inbound reply id as the native thread id.
messageThreadId: turn.replyId,
},
message: { body: bodyForAgent, bodyForAgent, rawBody, commandBody: rawBody },
access: {
commands: { authorized: false },
mentions: { canDetectMention: true, wasMentioned: turn.isMentioned ?? false },
},
});
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
@@ -279,12 +281,11 @@ export async function handleFeishuCommentEvent(
raw: turn,
}),
resolveTurn: () => ({
cfg: effectiveCfg,
channel: "feishu",
accountId: route.accountId,
routeSessionKey: commentSessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: commentSessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
error(
@@ -303,17 +304,13 @@ export async function handleFeishuCommentEvent(
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
dispatchInboundMessage({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
}),
}),
},
});
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
+10 -10
View File
@@ -7,11 +7,11 @@ import {
} from "openclaw/plugin-sdk/channel-send-result";
import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime";
import {
interactiveReplyToPresentation,
normalizeInteractiveReply,
legacyInteractiveReplyToPresentation,
normalizeLegacyInteractiveReply,
normalizeMessagePresentation,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
resolveLegacyInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
@@ -179,10 +179,10 @@ function buildFeishuPayloadCard(params: {
const rawText = params.text ?? params.payload.text;
const textCard = readNativeFeishuCardJson(rawText);
const interactive = normalizeInteractiveReply(params.payload.interactive);
const interactive = normalizeLegacyInteractiveReply(params.payload.interactive);
const presentation =
normalizeMessagePresentation(params.payload.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
if (!presentation && !interactive) {
if (!textCard) {
return undefined;
@@ -193,7 +193,7 @@ function buildFeishuPayloadCard(params: {
const text = textCard
? undefined
: resolveInteractiveTextFallback({
: resolveLegacyInteractiveTextFallback({
text: rawText,
interactive,
});
@@ -600,10 +600,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload);
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
if (parseFeishuCommentTarget(ctx.to)) {
const interactive = normalizeInteractiveReply(payload.interactive);
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
const normalizedPresentation =
normalizeMessagePresentation(payload.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
// Document comments cannot render cards. Resolve the text path before
// validating card limits so unused native card data cannot block delivery.
const textCard = readNativeFeishuCardJson(payload.text);
@@ -652,10 +652,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
if (ttsSupplement) {
return await sendFeishuTtsSupplementPayload({ ctx, payload, supplement: ttsSupplement });
}
const interactive = normalizeInteractiveReply(payload.interactive);
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
const presentation =
normalizeMessagePresentation(payload.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
const fallbackPayload = presentation
? {
...payload,
+6 -10
View File
@@ -1,5 +1,5 @@
// Feishu plugin module implements reply dispatcher behavior.
import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
import { formatReasoningMessage, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
import {
@@ -14,6 +14,7 @@ import {
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js";
@@ -637,11 +638,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
return nextIdleSideEffects;
};
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
humanDelay: resolveHumanDelayConfig(cfg, agentId),
silentReplyContext: {
cfg,
sessionKey: params.sessionKey,
@@ -708,8 +708,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
streamingEnabled &&
!finalTextExceedsStreamingLimit &&
(info?.kind === "final" || useStaticCard);
const finalTextWouldUseStreamingCard =
info?.kind === "final" && hasText && streamingEnabled;
const finalTextWouldUseStreamingCard = info?.kind === "final" && hasText && streamingEnabled;
const useCard = useStaticCard || useStreamingCard;
const skipTextForDuplicateFinal =
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
@@ -720,10 +719,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
!streamingCloseErroredForReply &&
finalTextWouldUseStreamingCard;
const shouldDeliverText =
hasText &&
!hasVoiceMedia &&
!skipTextForDuplicateFinal &&
!skipTextForClosedStreamingFinal;
hasText && !hasVoiceMedia && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
const shouldDiscardStreamingPreview =
info?.kind === "final" &&
(finalTextExceedsStreamingLimit ||
-1
View File
@@ -40,7 +40,6 @@ export type {
} from "openclaw/plugin-sdk/config-contracts";
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
export {
registerWebhookTargetWithPluginRoute,
+29 -22
View File
@@ -21,6 +21,19 @@ const routingMocks = vi.hoisted(() => ({
| undefined,
}));
const inboundMocks = vi.hoisted(() => ({
buildEnvelope: vi.fn(({ body }: { body: string }) => body),
resolveChannelInboundRouteEnvelope: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
resolveChannelInboundRouteEnvelope: inboundMocks.resolveChannelInboundRouteEnvelope,
};
});
vi.mock("./api.js", () => ({
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
@@ -43,34 +56,29 @@ beforeEach(() => {
apiMocks.downloadGoogleChatMedia.mockReset();
apiMocks.sendGoogleChatMessage.mockReset();
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
inboundMocks.buildEnvelope.mockReset().mockImplementation(({ body }: { body: string }) => body);
inboundMocks.resolveChannelInboundRouteEnvelope
.mockReset()
.mockImplementation(({ accountId }: { accountId: string }) => ({
route: {
agentId: "agent-1",
accountId,
sessionKey: "session-1",
},
buildEnvelope: inboundMocks.buildEnvelope,
}));
});
function createInboundClassificationHarness() {
const resolveAgentRoute = vi.fn(() => ({
agentId: "agent-1",
accountId: "work",
sessionKey: "session-1",
}));
const buildContext = vi.fn((payload: unknown) => payload);
const runTurn = vi.fn();
const core = {
logging: { shouldLogVerbose: () => false },
channel: {
routing: { resolveAgentRoute },
session: {
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
readSessionUpdatedAt: () => undefined,
recordInboundSession: vi.fn(),
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: ({ body }: { body: string }) => body,
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
},
inbound: { buildContext, run: runTurn },
},
} as unknown as GoogleChatCoreRuntime;
return { buildContext, core, resolveAgentRoute, runTurn };
return { buildContext, core, runTurn };
}
async function processGoogleChatTestEvent(params: {
@@ -177,7 +185,7 @@ describe("googlechat monitor inbound space classification", () => {
] as const;
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness();
const { buildContext, core, runTurn } = createInboundClassificationHarness();
const account = {
accountId: "work",
config: {},
@@ -214,7 +222,7 @@ describe("googlechat monitor inbound space classification", () => {
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
expect.objectContaining({ isGroup }),
);
expect(resolveAgentRoute).toHaveBeenCalledWith({
expect(inboundMocks.resolveChannelInboundRouteEnvelope).toHaveBeenCalledWith({
cfg: {},
channel: "googlechat",
accountId: "work",
@@ -509,7 +517,6 @@ describe("googlechat monitor direct messages", () => {
it("drops invalid event timestamps from inbound runtime payloads", async () => {
const runTurn = vi.fn();
const buildContext = vi.fn((payload: unknown) => payload);
const formatAgentEnvelope = vi.fn(({ body }: { body: string }) => body);
const core = {
logging: { shouldLogVerbose: () => false },
channel: {
@@ -527,7 +534,7 @@ describe("googlechat monitor direct messages", () => {
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope,
formatAgentEnvelope: ({ body }: { body: string }) => body,
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
},
inbound: { buildContext, run: runTurn },
@@ -569,7 +576,7 @@ describe("googlechat monitor direct messages", () => {
mediaMaxMb: 10,
});
expect(formatAgentEnvelope).toHaveBeenCalledWith(
expect(inboundMocks.buildEnvelope).toHaveBeenCalledWith(
expect.objectContaining({ timestamp: undefined }),
);
expect(buildContext).toHaveBeenCalledWith(expect.objectContaining({ timestamp: undefined }));
+5 -14
View File
@@ -1,15 +1,13 @@
// Googlechat plugin module implements monitor behavior.
import {
recordChannelBotPairLoopAndCheckSuppression,
resolveChannelInboundRouteEnvelope,
type ChannelBotLoopProtectionFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import {
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveWebhookPath,
} from "../runtime-api.js";
import { resolveWebhookPath } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
@@ -252,7 +250,7 @@ async function processMessageWithPipeline(params: {
return;
}
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config,
channel: "googlechat",
accountId: account.accountId,
@@ -260,8 +258,6 @@ async function processMessageWithPipeline(params: {
kind: isGroup ? ("group" as const) : ("direct" as const),
id: spaceId,
},
runtime: core.channel,
sessionStore: config.session?.store,
});
let mediaPath: string | undefined;
@@ -279,7 +275,7 @@ async function processMessageWithPipeline(params: {
? space.displayName || `space:${spaceId}`
: senderName || `user:${senderId}`;
const timestampMs = resolveGoogleChatTimestampMs(event.eventTime);
const { storePath, body } = buildEnvelope({
const body = buildEnvelope({
channel: "Google Chat",
from: fromLabel,
timestamp: timestampMs,
@@ -399,13 +395,8 @@ async function processMessageWithPipeline(params: {
cfg: config,
channel: "googlechat",
accountId: route.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: (payload, info) =>
resolveGoogleChatDurableReplyOptions({
@@ -23,7 +23,6 @@ import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
@@ -1453,12 +1452,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
raw: decision,
}),
resolveTurn: () => ({
cfg,
channel: "imessage",
accountId: decision.route.accountId,
routeSessionKey: decision.route.sessionKey,
storePath,
route: {
agentId: decision.route.agentId,
sessionKey: decision.route.sessionKey,
},
ctxPayload,
recordInboundSession,
record: {
updateLastRoute:
!decision.isGroup && updateTarget
+37 -33
View File
@@ -1,5 +1,9 @@
// Irc plugin module implements inbound behavior.
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import {
buildChannelInboundEventContext,
logInboundDrop,
resolveChannelInboundRouteEnvelope,
} from "openclaw/plugin-sdk/channel-inbound";
import {
channelIngressRoutes,
createChannelIngressResolver,
@@ -9,7 +13,6 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import {
deliverFormattedTextWithAttachments,
type OutboundReplyPayload,
@@ -371,7 +374,7 @@ export async function handleIrcInbound(params: {
? message.target
: `#${message.target}`;
const peerId = message.isGroup ? channelTarget : message.senderNick;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
@@ -379,12 +382,10 @@ export async function handleIrcInbound(params: {
kind: message.isGroup ? "group" : "direct",
id: peerId,
},
runtime: core.channel,
sessionStore: config.session?.store,
});
const fromLabel = message.isGroup ? message.target : senderDisplay;
const { storePath, body } = buildEnvelope({
const body = buildEnvelope({
channel: "IRC",
from: fromLabel,
timestamp: message.timestamp,
@@ -394,41 +395,44 @@ export async function handleIrcInbound(params: {
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: message.isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: message.senderNick || undefined,
SenderId: senderDisplay,
const ctxPayload = buildChannelInboundEventContext({
channel: CHANNEL_ID,
accountId: route.accountId,
messageId: message.messageId,
timestamp: message.timestamp,
from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
sender: { id: senderDisplay, name: message.senderNick || undefined },
conversation: {
kind: message.isGroup ? "group" : "direct",
id: peerId,
label: fromLabel,
},
route: {
agentId: route.agentId,
accountId: route.accountId,
routeSessionKey: route.sessionKey,
},
reply: {
to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
},
message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody },
access: {
commands: { authorized: commandAuthorized },
mentions: { canDetectMention: message.isGroup, wasMentioned },
},
extra: {
GroupSubject: message.isGroup ? message.target : undefined,
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
WasMentioned: message.isGroup ? wasMentioned : undefined,
MessageSid: message.messageId,
Timestamp: message.timestamp,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
CommandAuthorized: commandAuthorized,
},
});
await core.channel.inbound.dispatchReply({
await core.channel.inbound.dispatch({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await deliverIrcReply({
+1 -6
View File
@@ -191,13 +191,8 @@ export async function monitorLineProvider(
cfg: config,
channel: "line",
accountId: route.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath: ctx.turn.storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
record: ctx.turn.record,
replyPipeline: {},
...(deliveryControl.abortSignal
+2 -2
View File
@@ -161,7 +161,7 @@ async function setupMatrixTrace(recorder: WireRecorder) {
}
};
// The scripted steps stand in for the model run: dispatchReplyFromConfig
// The scripted steps stand in for the model run: dispatchInboundMessage
// stays pending until the script's final/cancel step settles it, so the
// handler's post-dispatch flow (including the finally-block draft abandon
// path) runs exactly where the real run would settle.
@@ -188,7 +188,7 @@ async function setupMatrixTrace(recorder: WireRecorder) {
markRunComplete: () => {},
};
},
dispatchReplyFromConfig: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
dispatchInboundMessage: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
capturedReplyOptions = args?.replyOptions;
notifyCaptured();
const result = await runGate;
@@ -42,7 +42,6 @@ function createAudioPreflightHarness(
matchedBy: "binding.account",
}),
resolveStorePath: () => "/tmp/openclaw-test-session.json",
readSessionUpdatedAt: () => 123,
getRoomInfo: async () => ({
name: "Audio Room",
canonicalAlias: "#audio:example.org",
@@ -110,7 +110,7 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -119,24 +119,17 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
}) => {
capturedOnError = params?.onError;
return {
dispatcher: {},
dispatcher: {
markComplete: () => {},
waitForIdle: async () => {
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
},
},
replyOptions: {},
markDispatchIdle: () => {},
markRunComplete: () => {},
};
},
withReplyDispatcher: async <T>(params: {
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const result = await params.run();
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
await params.onSettled?.();
return result;
},
});
}
@@ -173,7 +166,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -200,7 +193,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
isDirectMessage: false,
threadReplies: "off",
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -233,7 +226,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
isDirectMessage: false,
threadReplies: "always",
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -267,7 +260,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
isDirectMessage: false,
finalizeInboundContext,
resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)),
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -313,7 +306,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -341,7 +334,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -371,7 +364,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
return "@bot:example.org";
},
},
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -394,7 +387,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
historyLimit: 20,
isDirectMessage: true,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -428,7 +421,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
historyLimit: 20,
isDirectMessage: true,
getMemberDisplayName,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -458,7 +451,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -520,7 +513,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
getRelations,
},
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -560,7 +553,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
let firstDispatchStarted = false;
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const dispatchReplyFromConfig = vi.fn(async () => {
const dispatchInboundMessage = vi.fn(async () => {
if (!firstDispatchStarted) {
firstDispatchStarted = true;
await new Promise<void>((resolve) => {
@@ -575,7 +568,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig,
dispatchInboundMessage,
});
// Step 1: trigger msg A — don't await, let it block in dispatch
@@ -687,7 +680,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
isDirectMessage: false,
getMemberDisplayName,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -739,7 +732,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
getEvent: async () => ({ sender: "@bot:example.org" }),
},
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
@@ -42,7 +42,6 @@ function createMediaFailureHarness() {
matchedBy: "binding.account",
}),
resolveStorePath: () => "/tmp/openclaw-test-session.json",
readSessionUpdatedAt: () => 123,
getRoomInfo: async () => ({
name: "Media Room",
canonicalAlias: "#media:example.org",
@@ -14,6 +14,15 @@ import { createMatrixRoomMessageHandler } from "./handler.js";
import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js";
type MatrixMonitorHandlerParams = Parameters<typeof createMatrixRoomMessageHandler>[0];
type MatrixDispatchInboundMessage = (params: {
ctx: unknown;
cfg: unknown;
dispatcher: unknown;
replyOptions?: Record<string, unknown>;
}) => Promise<{
queuedFinal: boolean;
counts: { final: number; block: number; tool: number };
}>;
const DEFAULT_ROUTE = {
agentId: "ops",
@@ -68,9 +77,7 @@ type MatrixHandlerTestHarnessOptions = {
resolveMarkdownTableMode?: () => string;
resolveAgentRoute?: () => typeof DEFAULT_ROUTE;
resolveStorePath?: () => string;
readSessionUpdatedAt?: () => number | undefined;
recordInboundSession?: (...args: unknown[]) => Promise<void>;
resolveEnvelopeFormatOptions?: () => Record<string, never>;
formatAgentEnvelope?: ({ body }: { body: string }) => string;
finalizeInboundContext?: (ctx: unknown) => unknown;
createReplyDispatcherWithTyping?: (params?: {
@@ -82,19 +89,8 @@ type MatrixHandlerTestHarnessOptions = {
markRunComplete: () => void;
};
resolveHumanDelayConfig?: () => undefined;
dispatchReplyFromConfig?: () => Promise<{
queuedFinal: boolean;
counts: { final: number; block: number; tool: number };
}>;
dispatchInboundMessage?: MatrixDispatchInboundMessage;
runPrepared?: MatrixRunPreparedMock;
withReplyDispatcher?: <T>(params: {
dispatcher: {
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => Promise<T>;
inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"];
shouldAckReaction?: () => boolean;
enqueueSystemEvent?: (...args: unknown[]) => void;
@@ -104,10 +100,7 @@ type MatrixHandlerTestHarnessOptions = {
};
type MatrixHandlerTestHarness = {
dispatchReplyFromConfig: () => Promise<{
queuedFinal: boolean;
counts: { final: number; block: number; tool: number };
}>;
dispatchInboundMessage: MatrixDispatchInboundMessage;
enqueueSystemEvent: (...args: unknown[]) => void;
finalizeInboundContext: (ctx: unknown) => unknown;
handler: ReturnType<typeof createMatrixRoomMessageHandler>;
@@ -137,12 +130,55 @@ export function createMatrixHandlerTestHarness(
? finalizeCoreInboundContext(ctx as Record<string, unknown>)
: ctx,
);
const dispatchReplyFromConfig =
options.dispatchReplyFromConfig ??
const dispatchInboundMessage =
options.dispatchInboundMessage ??
(async () => ({
queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 },
}));
const createReplyDispatcherWithTyping =
options.createReplyDispatcherWithTyping ??
(() => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
markRunComplete: () => {},
}));
const dispatchInboundMessageWithBufferedDispatcher = (async ({
ctx,
cfg,
dispatcherOptions,
replyOptions,
}: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: Record<string, unknown>;
replyOptions?: Record<string, unknown>;
}) => {
const prepared = createReplyDispatcherWithTyping(dispatcherOptions);
try {
return await dispatchInboundMessage({
ctx,
cfg,
dispatcher: prepared.dispatcher,
replyOptions: { ...replyOptions, ...prepared.replyOptions },
} as never);
} finally {
const dispatcher = prepared.dispatcher as {
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
dispatcher.markComplete?.();
await dispatcher.waitForIdle?.();
await (dispatcherOptions.onSettled as (() => Promise<void> | void) | undefined)?.();
prepared.markRunComplete();
prepared.markDispatchIdle();
}
}) as NonNullable<MatrixMonitorHandlerParams["dispatchInboundMessageWithBufferedDispatcher"]>;
const createChannelInboundEnvelopeBuilder = (() => (input: { body: string }) =>
(options.formatAgentEnvelope ?? (({ body }: { body: string }) => body))({
body: input.body,
})) as NonNullable<MatrixMonitorHandlerParams["createChannelInboundEnvelopeBuilder"]>;
const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn();
const runPrepared =
options.runPrepared ??
@@ -184,7 +220,16 @@ export function createMatrixHandlerTestHarness(
: (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) {
return await runPrepared(turn);
const preparedTurn =
"route" in turn
? ({
...turn,
routeSessionKey: turn.route.sessionKey,
storePath: "/tmp/matrix-sessions.json",
recordInboundSession,
} as PreparedInboundReply<unknown>)
: turn;
return await runPrepared(preparedTurn);
}
throw new Error("matrix test helper only supports prepared turn dispatch");
},
@@ -233,47 +278,19 @@ export function createMatrixHandlerTestHarness(
buildMentionRegexes: () => options.mentionRegexes ?? [],
},
session: {
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
readSessionUpdatedAt: options.readSessionUpdatedAt ?? (() => undefined),
recordInboundSession,
},
reply: {
resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})),
formatAgentEnvelope:
options.formatAgentEnvelope ?? (({ body }: { body: string }) => body),
finalizeInboundContext,
createReplyDispatcherWithTyping:
options.createReplyDispatcherWithTyping ??
(() => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
markRunComplete: () => {},
})),
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
dispatchReplyFromConfig,
withReplyDispatcher:
options.withReplyDispatcher ??
(async <T>(params: {
dispatcher: {
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const { dispatcher, run: runLocal, onSettled } = params;
try {
return await runLocal();
} finally {
settleReplyDispatcher: async ({
dispatcher,
onSettled,
}: Parameters<
MatrixMonitorHandlerParams["core"]["channel"]["reply"]["settleReplyDispatcher"]
>[0]) => {
dispatcher.markComplete?.();
try {
await dispatcher.waitForIdle?.();
} finally {
await onSettled?.();
}
}
}),
},
},
inbound: {
run,
@@ -332,11 +349,16 @@ export function createMatrixHandlerTestHarness(
getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"),
needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false,
resolveLiveUserAllowlist: options.resolveLiveUserAllowlist,
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
createChannelInboundEnvelopeBuilder,
finalizeInboundContext,
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
dispatchInboundMessageWithBufferedDispatcher,
historyLimit: options.historyLimit ?? 0,
});
return {
dispatchReplyFromConfig,
dispatchInboundMessage,
enqueueSystemEvent,
finalizeInboundContext,
handler,
@@ -11,7 +11,6 @@ import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import {
createMatrixHandlerTestHarness,
createMatrixReactionEvent,
@@ -527,6 +526,10 @@ describe("matrix monitor handler pairing account scope", () => {
getMemberDisplayName: async () => "sender",
dropPreStartupMessages: true,
needsRoomAliasesForConfig: false,
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
});
await handler(
@@ -580,12 +583,12 @@ describe("matrix monitor handler pairing account scope", () => {
});
it("does not enqueue delivered text messages into system events", async () => {
const dispatchReplyFromConfig = vi.fn(async () => ({
const dispatchInboundMessage = vi.fn(async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}));
const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({
dispatchReplyFromConfig,
dispatchInboundMessage,
isDirectMessage: true,
getMemberDisplayName: async () => "sender",
});
@@ -599,7 +602,7 @@ describe("matrix monitor handler pairing account scope", () => {
}),
);
expect(dispatchReplyFromConfig).toHaveBeenCalled();
expect(dispatchInboundMessage).toHaveBeenCalled();
expect(enqueueSystemEvent).not.toHaveBeenCalled();
});
@@ -1275,7 +1278,7 @@ describe("matrix monitor handler pairing account scope", () => {
resolveNotice = resolve;
});
const sendNotice = vi.fn(() => noticeSent);
const dispatchReplyFromConfig = vi.fn(async () => ({
const dispatchInboundMessage = vi.fn(async () => ({
counts: { block: 0, final: 0, tool: 0 },
queuedFinal: false,
}));
@@ -1289,7 +1292,7 @@ describe("matrix monitor handler pairing account scope", () => {
});
const { handler } = createMatrixHandlerTestHarness({
dispatchReplyFromConfig,
dispatchInboundMessage,
isDirectMessage: true,
resolveStorePath: () => storePath,
client: {
@@ -1308,12 +1311,12 @@ describe("matrix monitor handler pairing account scope", () => {
await vi.waitFor(() => {
expect(sendNotice).toHaveBeenCalledTimes(1);
});
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(dispatchInboundMessage).not.toHaveBeenCalled();
resolveNotice?.("$notice");
await handled;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -1613,7 +1616,7 @@ describe("matrix monitor handler pairing account scope", () => {
altAliases: ["#alt:example.org"],
}),
getMemberDisplayName: async () => "sender",
dispatchReplyFromConfig: async () => ({
dispatchInboundMessage: async () => ({
queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 },
}),
@@ -1755,121 +1758,13 @@ describe("matrix monitor handler pairing account scope", () => {
it("does not enqueue system events for delivered text replies", async () => {
const enqueueSystemEvent = vi.fn();
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore: async () => [] as string[],
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
buildPairingReply: () => "pairing",
},
commands: {
shouldHandleTextCommands: () => false,
},
text: {
hasControlCommand: () => false,
resolveMarkdownTableMode: () => "preserve",
},
routing: {
resolveAgentRoute: () => ({
agentId: "ops",
channel: "matrix",
accountId: "ops",
sessionKey: "agent:ops:main",
mainSessionKey: "agent:ops:main",
matchedBy: "binding.account",
}),
},
mentions: {
buildMentionRegexes: () => [],
},
session: {
resolveStorePath: () => "/tmp/session-store",
readSessionUpdatedAt: () => undefined,
recordInboundSession: vi.fn(async () => {}),
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: ({ body }: { body: string }) => body,
finalizeInboundContext: (ctx: unknown) => ctx,
createReplyDispatcherWithTyping: () => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
markRunComplete: () => {},
}),
resolveHumanDelayConfig: () => undefined,
dispatchReplyFromConfig: async () => ({
const { handler } = createMatrixHandlerTestHarness({
enqueueSystemEvent,
isDirectMessage: false,
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
withReplyDispatcher: async <T>({
dispatcher,
run,
onSettled,
}: {
dispatcher: {
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
try {
return await run();
} finally {
dispatcher.markComplete?.();
try {
await dispatcher.waitForIdle?.();
} finally {
await onSettled?.();
}
}
},
},
reactions: {
shouldAckReaction: () => false,
},
},
system: {
enqueueSystemEvent,
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {
error: () => {},
} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
streaming: "off",
previewToolProgressEnabled: false,
blockStreamingEnabled: false,
dmEnabled: true,
dmPolicy: "open",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
historyLimit: 0,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => false,
},
dropPreStartupMessages: true,
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
needsRoomAliasesForConfig: false,
});
await handler(
@@ -2177,7 +2072,7 @@ describe("matrix monitor handler pairing account scope", () => {
describe("matrix monitor handler live allowlist reload", () => {
type MatrixHandler = ReturnType<typeof createMatrixHandlerTestHarness>["handler"];
const createDispatchReplyFromConfig = () =>
const createDispatchInboundMessage = () =>
vi.fn(async () => ({
queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 },
@@ -2222,7 +2117,7 @@ describe("matrix monitor handler live allowlist reload", () => {
).length;
it("accepts a DM sender added to live dm.allowFrom", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = {
channels: {
matrix: {
@@ -2236,7 +2131,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: [],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
dispatchInboundMessage,
});
await sendLiveAllowlistMessage(handler, {
@@ -2244,7 +2139,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(dispatchInboundMessage).not.toHaveBeenCalled();
cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"];
await sendLiveAllowlistMessage(handler, {
@@ -2253,11 +2148,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("blocks a DM sender removed from live dm.allowFrom", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = {
channels: {
matrix: {
@@ -2271,7 +2166,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
dispatchReplyFromConfig,
dispatchInboundMessage,
});
await sendLiveAllowlistMessage(handler, {
@@ -2279,7 +2174,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
@@ -2288,11 +2183,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("blocks a DM sender after live wildcard removal", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = {
channels: {
matrix: {
@@ -2306,7 +2201,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: ["*"],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
dispatchInboundMessage,
});
await sendLiveAllowlistMessage(handler, {
@@ -2314,7 +2209,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
@@ -2323,11 +2218,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("uses account-scoped live dm.allowFrom overrides", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = {
channels: {
matrix: {
@@ -2347,7 +2242,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
dispatchReplyFromConfig,
dispatchInboundMessage,
});
await sendLiveAllowlistMessage(handler, {
@@ -2355,7 +2250,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.accounts.ops.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
@@ -2364,11 +2259,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("keeps startup-resolved display names only while the raw input remains configured", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = {
channels: {
matrix: {
@@ -2383,7 +2278,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }],
dispatchReplyFromConfig,
dispatchInboundMessage,
});
await sendLiveAllowlistMessage(handler, {
@@ -2391,7 +2286,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, {
@@ -2400,11 +2295,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("accepts a DM sender added as a live-resolved display name", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const resolveLiveUserAllowlist = vi.fn(
async (params: { entries?: ReadonlyArray<string | number> }) => {
const entries = (params.entries ?? []).map(String);
@@ -2425,7 +2320,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: [],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
dispatchInboundMessage,
resolveLiveUserAllowlist,
});
@@ -2434,7 +2329,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(dispatchInboundMessage).not.toHaveBeenCalled();
cfg.channels.matrix.dm.allowFrom = ["Alice"];
await sendLiveAllowlistMessage(handler, {
@@ -2449,11 +2344,11 @@ describe("matrix monitor handler live allowlist reload", () => {
);
expect(liveAllowlistRequest.accountId).toBe("ops");
expect(liveAllowlistRequest.entries).toEqual(["Alice"]);
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("refreshes cached live display-name allowlists when name matching is disabled", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
);
@@ -2471,7 +2366,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: [],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
dispatchInboundMessage,
resolveLiveUserAllowlist,
});
@@ -2480,7 +2375,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dangerouslyAllowNameMatching = false;
await sendLiveAllowlistMessage(handler, {
@@ -2492,11 +2387,11 @@ describe("matrix monitor handler live allowlist reload", () => {
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
2,
);
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("refreshes cached live display-name allowlists when name matching is enabled", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
);
@@ -2514,7 +2409,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true,
allowFrom: [],
allowFromResolvedEntries: [],
dispatchReplyFromConfig,
dispatchInboundMessage,
resolveLiveUserAllowlist,
});
@@ -2523,7 +2418,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org",
body: "hello",
});
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(dispatchInboundMessage).not.toHaveBeenCalled();
cfg.channels.matrix.dangerouslyAllowNameMatching = true;
await sendLiveAllowlistMessage(handler, {
@@ -2535,11 +2430,11 @@ describe("matrix monitor handler live allowlist reload", () => {
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
2,
);
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = {
channels: {
matrix: {
@@ -2557,7 +2452,7 @@ describe("matrix monitor handler live allowlist reload", () => {
{ input: "@alice:example.org", id: "@alice:example.org" },
{ input: "@bob:example.org", id: "@bob:example.org" },
],
dispatchReplyFromConfig,
dispatchInboundMessage,
});
await sendLiveAllowlistMessage(handler, {
@@ -2567,7 +2462,7 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "@room hello",
mentions: { room: true },
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"];
await sendLiveAllowlistMessage(handler, {
@@ -2578,7 +2473,7 @@ describe("matrix monitor handler live allowlist reload", () => {
mentions: { room: true },
});
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
});
@@ -2589,7 +2484,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
};
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
inboundDeduper,
dispatchReplyFromConfig: vi.fn(async () => ({
dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
})),
@@ -2631,7 +2526,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const recordInboundSession = vi.fn(async () => {
callOrder.push("record");
});
const dispatchReplyFromConfig = vi.fn(async () => {
const dispatchInboundMessage = vi.fn(async () => {
callOrder.push("dispatch");
return {
queuedFinal: true,
@@ -2641,7 +2536,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const { handler } = createMatrixHandlerTestHarness({
inboundDeduper,
recordInboundSession,
dispatchReplyFromConfig,
dispatchInboundMessage,
createReplyDispatcherWithTyping: () => ({
dispatcher: {
markComplete: () => {
@@ -2673,9 +2568,9 @@ describe("matrix monitor handler durable inbound dedupe", () => {
"claim",
"record",
"dispatch",
"run-complete",
"mark-complete",
"wait-for-idle",
"run-complete",
"dispatch-idle",
"commit",
]);
@@ -2742,7 +2637,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
recordInboundSession: vi.fn(async () => {
throw new Error("disk failed");
}),
dispatchReplyFromConfig: vi.fn(async () => ({
dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
})),
@@ -2776,7 +2671,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const { handler } = createMatrixHandlerTestHarness({
inboundDeduper,
runtime: runtime as never,
dispatchReplyFromConfig: vi.fn(async () => ({
dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
})),
@@ -2823,7 +2718,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const { handler } = createMatrixHandlerTestHarness({
inboundDeduper,
runtime: runtime as never,
dispatchReplyFromConfig: vi.fn(async () => ({
dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: false,
counts: {
final: 0,
@@ -2881,7 +2776,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
recordInboundSession: vi.fn(async () => {
callOrder.push("record");
}),
dispatchReplyFromConfig: vi.fn(async () => {
dispatchInboundMessage: vi.fn(async () => {
callOrder.push("dispatch");
return {
queuedFinal: false,
@@ -3031,22 +2926,13 @@ describe("matrix monitor handler draft streaming", () => {
markRunComplete: () => {},
};
},
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
capturedReplyOpts = args?.replyOptions;
notifyCaptured();
// Block until the test is done exercising callbacks.
await runGate;
return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
}) as never,
withReplyDispatcher: async <T>(params: {
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const result = await params.run();
await params.onSettled?.();
return result;
},
});
const dispatch = async () => {
@@ -4098,7 +3984,7 @@ describe("matrix monitor handler draft streaming", () => {
markDispatchIdle: () => {},
markRunComplete: () => {},
}),
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
capturedReplyOpts = args?.replyOptions;
// Simulate streaming then model error.
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
@@ -4107,15 +3993,6 @@ describe("matrix monitor handler draft streaming", () => {
});
throw new Error("model timeout");
}) as never,
withReplyDispatcher: async <T>(params: {
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const result = await params.run();
await params.onSettled?.();
return result;
},
});
// Handler should not throw (outer catch absorbs it).
@@ -4156,7 +4033,7 @@ describe("matrix monitor handler draft streaming", () => {
markDispatchIdle: () => {},
markRunComplete: () => {},
}),
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
capturedReplyOpts = args?.replyOptions;
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
await vi.waitFor(() => {
@@ -4164,15 +4041,6 @@ describe("matrix monitor handler draft streaming", () => {
});
throw new Error("model timeout");
}) as never,
withReplyDispatcher: async <T>(params: {
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const result = await params.run();
await params.onSettled?.();
return result;
},
});
await handler(
@@ -4448,7 +4316,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({
streaming: "off",
dispatchReplyFromConfig: vi.fn(
dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4469,7 +4337,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({
streaming: "partial",
dispatchReplyFromConfig: vi.fn(
dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4490,7 +4358,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({
streaming: "quiet",
dispatchReplyFromConfig: vi.fn(
dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4512,7 +4380,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({
streaming: "partial",
blockStreamingEnabled: true,
dispatchReplyFromConfig: vi.fn(
dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4534,7 +4402,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({
streaming: "off",
blockStreamingEnabled: true,
dispatchReplyFromConfig: vi.fn(
dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
+41 -56
View File
@@ -1,28 +1,27 @@
// Matrix plugin module implements handler behavior.
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
buildChannelInboundEventContext,
createChannelInboundEnvelopeBuilder,
hasFinalInboundReplyDispatch,
resolveInboundMentionDecision,
toInboundMediaFacts,
type ChannelBotLoopProtectionFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-inbound";
import {
createPreviewMessageReceipt,
defineFinalizableLivePreviewAdapter,
deliverWithFinalizableLivePreviewAdapter,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound";
import {
type AgentPlanStep,
buildChannelProgressDraftLineForEntry,
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
createChannelProgressDraftGate,
createPreviewMessageReceipt,
defineFinalizableLivePreviewAdapter,
deliverWithFinalizableLivePreviewAdapter,
formatChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLines,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound";
import {
evaluateSupplementalContextVisibility,
@@ -41,10 +40,13 @@ import {
buildTtsSupplementMediaPayload,
getReplyPayloadTtsSupplement,
} from "openclaw/plugin-sdk/reply-payload";
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
import {
dispatchInboundMessageWithBufferedDispatcher,
type GetReplyOptions,
} from "openclaw/plugin-sdk/reply-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type {
@@ -228,6 +230,11 @@ type MatrixMonitorHandlerParams = {
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
needsRoomAliasesForConfig: boolean;
resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist;
resolveStorePath?: typeof resolveStorePath;
createChannelInboundEnvelopeBuilder?: typeof createChannelInboundEnvelopeBuilder;
finalizeInboundContext?: (ctx: Record<string, unknown>) => unknown;
resolveHumanDelayConfig?: typeof resolveHumanDelayConfig;
dispatchInboundMessageWithBufferedDispatcher?: typeof dispatchInboundMessageWithBufferedDispatcher;
};
function resolveMatrixMentionPrecheckText(params: {
@@ -472,6 +479,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
getMemberDisplayName,
needsRoomAliasesForConfig,
resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist,
resolveStorePath: resolveStorePathImpl = resolveStorePath,
createChannelInboundEnvelopeBuilder:
createChannelInboundEnvelopeBuilderImpl = createChannelInboundEnvelopeBuilder,
finalizeInboundContext,
resolveHumanDelayConfig: resolveHumanDelayConfigImpl = resolveHumanDelayConfig,
dispatchInboundMessageWithBufferedDispatcher:
dispatchInboundMessageWithBufferedDispatcherImpl = dispatchInboundMessageWithBufferedDispatcher,
} = params;
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg,
@@ -1536,14 +1550,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const roomName = roomInfo?.name;
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
const storePath = resolveStorePathImpl(cfg.session?.store, {
agentId: _route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: _route.sessionKey,
});
const buildEnvelope = createChannelInboundEnvelopeBuilderImpl({ cfg, route: _route });
const sharedDmNoticeSessionKey = threadTarget
? _route.mainSessionKey || _route.sessionKey
: _route.sessionKey;
@@ -1560,12 +1570,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
logVerboseMessage,
})
: null;
const body = core.channel.reply.formatAgentEnvelope({
const body = buildEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: eventTs ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
@@ -1579,8 +1587,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
);
const ctxPayload = buildChannelInboundEventContext({
channel: "matrix",
finalize: core.channel.reply.finalizeInboundContext,
contextVisibility: contextVisibilityMode,
finalize: finalizeInboundContext,
supplemental: {
quote: replyContext
? {
@@ -2098,10 +2106,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
resetPreviewToolProgress();
};
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({
const dispatcherOptions = {
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, _route.agentId),
humanDelay: resolveHumanDelayConfigImpl(cfg, _route.agentId),
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
@@ -2134,9 +2141,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const payloadReplyToId = normalizeOptionalString(payload.replyToId);
const payloadReplyMismatch =
replyToMode !== "off" &&
!threadTarget &&
payloadReplyToId !== currentDraftReplyToId;
replyToMode !== "off" && !threadTarget && payloadReplyToId !== currentDraftReplyToId;
let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
const canPotentiallyFinalizeDraft =
Boolean(payload.text?.trim()) &&
@@ -2379,7 +2384,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
});
};
const pinnedMainDmOwner = isDirectMessage
? await (async () => {
const livePinnedCfg = core.config.current() as CoreConfig;
@@ -2422,12 +2427,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
raw: event,
}),
resolveTurn: () => ({
cfg,
channel: "matrix",
accountId: _route.accountId,
routeSessionKey: _route.sessionKey,
storePath,
route: { agentId: _route.agentId, sessionKey: _route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
botLoopProtection,
record: {
updateLastRoute: isDirectMessage
@@ -2464,14 +2468,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
});
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
}),
runDispatch: async () => {
if (
sharedDmContextNotice &&
@@ -2489,19 +2485,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
}
return await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: async () => {
try {
return await core.channel.reply.dispatchReplyFromConfig({
return await dispatchInboundMessageWithBufferedDispatcherImpl({
ctx: ctxPayload,
cfg,
dispatcher,
dispatcherOptions: {
...dispatcherOptions,
onSettled: () => progressDraftGate.cancel(),
},
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
// Keep block streaming enabled when explicitly requested, even
// with draft previews on. The draft remains the live preview
@@ -2540,12 +2531,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onModelSelected,
},
});
} finally {
progressDraftGate.cancel();
markRunComplete();
}
},
});
},
}),
},
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixTestRuntime } from "../test-runtime.js";
import type { CoreConfig } from "../types.js";
import { withAuthorizedMatrixReadTarget } from "./read-policy.js";
import type { MatrixClient } from "./sdk.js";
@@ -32,6 +33,10 @@ function createClient(
}
describe("Matrix read policy", () => {
beforeEach(() => {
installMatrixTestRuntime();
});
it("allows configured rooms and rejects other rooms before the read", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
const cfg = {
@@ -1,4 +1,5 @@
// Matrix helper module supports formatting behavior.
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import {
markdownToMatrixHtml,
@@ -187,7 +188,7 @@ export function resolveMatrixVoiceDecision(opts: {
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
// Matrix currently shares the core voice compatibility policy.
// Keep this wrapper as the seam if Matrix policy diverges later.
return getCore().media.isVoiceCompatibleAudio({
return isVoiceMessageCompatibleAudio({
contentType: opts.contentType,
fileName: opts.fileName,
});
@@ -80,9 +80,10 @@ class FakeWebSocket {
const mockState = vi.hoisted(() => ({
abortController: undefined as AbortController | undefined,
createReplyDispatcherWithTyping: vi.fn(),
createMattermostClient: vi.fn(),
createMattermostDraftStream: vi.fn(),
dispatchReplyFromConfig: vi.fn(),
dispatchInboundMessage: vi.fn(),
enqueueSystemEvent: vi.fn(),
fetchMattermostMe: vi.fn(),
registerMattermostMonitorSlashCommands: vi.fn(),
@@ -96,6 +97,22 @@ const mockState = vi.hoisted(() => ({
updateMattermostPost: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
createReplyDispatcherWithTyping: (...args: unknown[]) =>
mockState.createReplyDispatcherWithTyping(...args),
dispatchInboundMessage: async (params: Parameters<typeof actual.dispatchInboundMessage>[0]) => {
try {
return await mockState.dispatchInboundMessage(params);
} finally {
await params.onSettled?.();
}
},
};
});
vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return {
@@ -193,16 +210,43 @@ function createRuntimeCore(
type ReplyDispatcherOptions = {
deliver: (payload: ReplyPayload, info: { kind: "tool" | "block" | "final" }) => Promise<void>;
};
mockState.createReplyDispatcherWithTyping.mockImplementation(
(options: ReplyDispatcherOptions) => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
options,
}),
);
type RecordInboundSessionInput = {
storePath: string;
sessionKey: string;
ctx: unknown;
createIfMissing?: boolean;
groupResolution?: unknown;
onRecordError?: (error: unknown) => void;
updateLastRoute?: {
accountId?: string;
channel?: string;
mainDmOwnerPin?: {
onSkip?: () => void;
ownerRecipient?: string;
senderRecipient?: string;
};
sessionKey?: string;
to?: string;
};
};
const recordInboundSession = vi.fn(async (_params: RecordInboundSessionInput) => {});
const dispatchPreparedForTest = vi.fn(
async (turn: {
storePath: string;
routeSessionKey: string;
route: { agentId: string; sessionKey: string };
ctxPayload: { SessionKey?: string };
recordInboundSession: (params: unknown) => Promise<void>;
record?: {
groupResolution?: unknown;
createIfMissing?: boolean;
updateLastRoute?: unknown;
updateLastRoute?: RecordInboundSessionInput["updateLastRoute"];
onRecordError?: (err: unknown) => void;
};
runDispatch: () => Promise<{
@@ -210,9 +254,9 @@ function createRuntimeCore(
counts: { tool: number; block: number; final: number };
}>;
}) => {
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
await recordInboundSession({
storePath: "/tmp/openclaw-test-sessions.json",
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
@@ -224,7 +268,7 @@ function createRuntimeCore(
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
routeSessionKey: turn.route.sessionKey,
dispatchResult,
};
},
@@ -304,25 +348,7 @@ function createRuntimeCore(
buildPairingReply: () => "pairing required",
},
reply: {
createReplyDispatcherWithTyping: vi.fn((options: ReplyDispatcherOptions) => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
options,
})),
dispatchReplyFromConfig: mockState.dispatchReplyFromConfig,
finalizeInboundContext: (context: unknown) => context,
formatInboundEnvelope: (params: { channel: string; from: string; body: string }) =>
`${params.channel} ${params.from}\n${params.body}`,
resolveHumanDelayConfig: () => ({}),
withReplyDispatcher: async (params: { run: () => unknown; onSettled?: () => void }) => {
try {
return await params.run();
} finally {
params.onSettled?.();
}
},
settleReplyDispatcher: vi.fn(async ({ onSettled }) => onSettled?.()),
},
routing: {
resolveAgentRoute: () => ({
@@ -335,26 +361,7 @@ function createRuntimeCore(
},
session: {
resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
recordInboundSession: vi.fn(
async (_params: {
createIfMissing?: unknown;
groupResolution?: unknown;
onRecordError?: unknown;
sessionKey?: string;
storePath?: string;
updateLastRoute?: {
accountId?: string;
channel?: string;
mainDmOwnerPin?: {
onSkip?: unknown;
ownerRecipient?: string;
senderRecipient?: string;
};
sessionKey?: string;
to?: string;
};
}) => {},
),
recordInboundSession,
updateLastRoute: vi.fn(async () => {}),
},
inbound: {
@@ -457,7 +464,7 @@ describe("mattermost inbound user posts", () => {
mockState.resolveMattermostMedia.mockResolvedValue([]);
mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
mockState.sendMessageMattermost.mockResolvedValue({});
mockState.dispatchReplyFromConfig.mockImplementation(async () => {
mockState.dispatchInboundMessage.mockImplementation(async () => {
mockState.abortController?.abort();
});
});
@@ -503,8 +510,8 @@ describe("mattermost inbound user posts", () => {
await monitor;
expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello from mattermost");
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular");
@@ -599,8 +606,8 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("@openclaw");
expect(ctx?.MessageSid).toBe("post-bare-mention");
expect(ctx?.OriginatingChannel).toBe("mattermost");
@@ -638,7 +645,7 @@ describe("mattermost inbound user posts", () => {
},
};
mockState.runtimeCore = createRuntimeCore(progressConfig);
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onToolStart?.({
toolCallId: "read-1",
name: "read",
@@ -707,7 +714,7 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
expect(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(draftStream.clear).toHaveBeenCalledTimes(1);
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
@@ -779,8 +786,8 @@ describe("mattermost inbound user posts", () => {
await monitor;
expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig);
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello /status");
expect(ctx?.CommandAuthorized).toBe(false);
// Inline non-control text must not be tagged as an explicit text-slash command turn —
@@ -861,8 +868,8 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("/reset");
expect(ctx?.CommandBody).toBe("/reset");
expect(ctx?.CommandAuthorized).toBe(true);
@@ -913,8 +920,8 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello with websocket kind");
expect(ctx?.ChatType).toBe("channel");
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
@@ -976,7 +983,7 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled();
});
@@ -1041,7 +1048,7 @@ describe("mattermost inbound user posts", () => {
user_id: "user-1",
},
});
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
await socket.emitMessage({
event: "posted",
@@ -1066,8 +1073,8 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("abort");
expect(ctx?.CommandAuthorized).toBe(true);
});
@@ -1266,9 +1273,9 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDraftStream).not.toHaveBeenCalled();
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
expect(replyOptions?.disableBlockStreaming).toBe(false);
expect(replyOptions?.preserveProgressCallbackStartOrder).toBeUndefined();
});
@@ -1354,7 +1361,7 @@ describe("mattermost inbound user posts", () => {
let finalToolDraft = "";
let secondPartialArrivedBeforeBoundarySettled = false;
let finalDeliveryWaitedForBoundary = false;
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onAssistantMessageStart?.();
params.replyOptions?.onPartialReply?.({ text: "A much longer first block" });
const firstToolStart = params.replyOptions?.onToolStart?.({
@@ -1427,8 +1434,7 @@ describe("mattermost inbound user posts", () => {
toolBeforeFinalBoundaryCount = forceNewMessage.mock.calls.length;
finalToolDraft = String(draftUpdate.mock.calls.at(-1)?.[0] ?? "");
const dispatcherOptions =
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
?.options;
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
const finalDelivery = dispatcherOptions?.deliver(
{ text: "Final without a partial" },
{ kind: "final" },
@@ -1462,14 +1468,14 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const draftStreamOptions = mockState.createMattermostDraftStream.mock.calls.at(0)?.[0] as
| { chunkText?: (text: string) => string[] }
| undefined;
chunkMarkdownTextWithMode.mockClear();
expect(draftStreamOptions?.chunkText?.("first\n\nsecond")).toEqual(["first\n\nsecond"]);
expect(chunkMarkdownTextWithMode).toHaveBeenCalledWith("first\n\nsecond", 1234, "newline");
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
expect(replyOptions?.disableBlockStreaming).toBe(true);
expect(replyOptions?.preserveProgressCallbackStartOrder).toBe(true);
expect(sameToolUpdateBoundaryCount).toBe(1);
@@ -1540,14 +1546,13 @@ describe("mattermost inbound user posts", () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onAssistantMessageStart?.();
await params.replyOptions?.onPartialReply?.({ text: "First block" });
await params.replyOptions?.onAssistantMessageStart?.();
await params.replyOptions?.onPartialReply?.({ text: "Second block" });
const dispatcherOptions =
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
?.options;
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
await dispatcherOptions?.deliver(
{ text: "[bot] First block\n\nSecond block" },
{ kind: "final" },
@@ -1621,13 +1626,12 @@ describe("mattermost inbound user posts", () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onAssistantMessageStart?.();
await params.replyOptions?.onPartialReply?.({ text: "Only block" });
await params.replyOptions?.onAssistantMessageStart?.();
const dispatcherOptions =
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
?.options;
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
await dispatcherOptions?.deliver({ text: "Only block" }, { kind: "final" });
abortController.abort();
});
+28 -47
View File
@@ -1,10 +1,17 @@
import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound";
// Mattermost plugin module implements monitor behavior.
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound";
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
import {
buildChannelProgressDraftLineForEntry,
createChannelProgressDraftCompositor,
} from "openclaw/plugin-sdk/channel-outbound";
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
import {
createReplyDispatcherWithTyping,
dispatchInboundMessage,
finalizeInboundContext,
} from "openclaw/plugin-sdk/reply-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -430,7 +437,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const to =
kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`;
const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
const ctxPayload = finalizeInboundContext({
Body: bodyText,
BodyForAgent: bodyText,
RawBody: bodyText,
@@ -497,12 +504,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
await deliverMattermostReplyPayload({
core,
@@ -529,23 +535,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onReplyStart: typingCallbacks?.onReplyStart,
});
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
}),
});
},
log: (msg) => runtime.log?.(msg),
@@ -632,7 +632,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
params.kind === "direct"
? `Mattermost DM from ${params.senderName}`
: `Mattermost message in ${params.roomLabel} from ${params.senderName}`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
const ctxPayload = finalizeInboundContext({
Body: params.commandText,
BodyForAgent: params.commandText,
RawBody: params.commandText,
@@ -707,8 +707,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
isDirect: params.kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
@@ -751,23 +750,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onReplyStart: typingCallbacks?.onReplyStart,
});
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
}),
});
return capturedTexts.join("\n\n").trim();
@@ -1296,7 +1289,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
const body = core.channel.reply.formatInboundEnvelope({
const body = formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
@@ -1312,7 +1305,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatInboundEnvelope({
formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: entry.timestamp,
@@ -1335,7 +1328,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
limit: historyLimit,
})
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
BodyForAgent: bodyForAgent,
InboundHistory: inboundHistory,
@@ -1389,10 +1382,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
})
: null;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const previewLine = truncateUtf16Safe(bodyText, 200).replace(/\n/g, "\\n");
logVerboseMessage(
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
@@ -1591,11 +1580,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({
createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
typingCallbacks,
deliver: async (payloadEntry: ReplyPayload, info) => {
if (info.kind === "final") {
@@ -1715,12 +1704,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
raw: post,
}),
resolveTurn: () => ({
cfg,
channel: "mattermost",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute:
kind === "direct"
@@ -1772,23 +1760,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions: {
...replyOptions,
allowProgressCallbacksWhenSourceDeliverySuppressed:
draftToolProgressEnabled ? true : undefined,
preserveProgressCallbackStartOrder: draftPreviewEnabled
allowProgressCallbacksWhenSourceDeliverySuppressed: draftToolProgressEnabled
? true
: undefined,
preserveProgressCallbackStartOrder: draftPreviewEnabled ? true : undefined,
onObservedReplyDelivery: draftToolProgressEnabled
? () => draftStream.clear()
: undefined,
@@ -1898,7 +1880,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
},
}),
}),
}),
},
});
} finally {
@@ -6,10 +6,16 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
createReplyDispatcherWithTyping,
dispatchInboundMessage,
finalizeInboundContext,
} from "openclaw/plugin-sdk/reply-runtime";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
@@ -830,7 +836,7 @@ async function handleSlashCommandAsync(params: {
}
// Build inbound context — the command text is the body
const ctxPayload = core.channel.reply.finalizeInboundContext({
const ctxPayload = finalizeInboundContext({
Body: commandText,
BodyForAgent: commandText,
RawBody: commandText,
@@ -886,14 +892,13 @@ async function handleSlashCommandAsync(params: {
},
},
});
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
const humanDelay = resolveHumanDelayConfig(cfg, route.agentId);
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
@@ -921,23 +926,17 @@ async function handleSlashCommandAsync(params: {
onReplyStart: typingCallbacks?.onReplyStart,
});
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
}),
});
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -6,7 +6,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
import { asRecord } from "./dreaming-shared.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
function resolveDreamingPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
return asRecord(entry?.config) ?? {};
}
@@ -49,7 +49,7 @@ function formatPhaseGuide(): string {
}
function formatStatus(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
const pluginConfig = resolveDreamingPluginConfig(cfg);
const dreaming = resolveMemoryDreamingConfig({
pluginConfig,
cfg,
@@ -5,8 +5,10 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { resolveMemoryCorePluginConfig } from "openclaw/plugin-sdk/memory-core-host-status";
import {
resolveMemoryDreamingPluginConfig,
resolveSessionTranscriptsDirForAgent,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
@@ -236,7 +238,7 @@ function createHarness(
},
},
};
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
const pluginConfig = resolveMemoryDreamingPluginConfig(resolvedConfig) ?? {};
const beforeAgentReply = async (
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
@@ -434,7 +436,7 @@ describe("memory-core dreaming phases", () => {
await runDreamingSweepPhases({
workspaceDir,
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
logger,
subagent,
nowMs,
@@ -501,7 +503,7 @@ describe("memory-core dreaming phases", () => {
runDreamingSweepPhases({
workspaceDir,
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
logger,
subagent,
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
@@ -737,7 +739,7 @@ describe("memory-core dreaming phases", () => {
await runDreamingSweepPhases({
workspaceDir,
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
logger,
subagent,
nowMs,
+5 -5
View File
@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Memory Core plugin module implements dreaming behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { resolveMemoryDreamingPluginConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import {
DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS as DEFAULT_MEMORY_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS,
DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS as DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS,
@@ -13,7 +14,6 @@ import {
MANAGED_MEMORY_DREAMING_CRON_NAME as MANAGED_DREAMING_CRON_NAME,
MANAGED_MEMORY_DREAMING_CRON_TAG as MANAGED_DREAMING_CRON_TAG,
MEMORY_DREAMING_SYSTEM_EVENT_TEXT as DREAMING_SYSTEM_EVENT_TEXT,
resolveMemoryCorePluginConfig,
resolveMemoryDeepDreamingConfig,
resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-core-host-status";
@@ -550,7 +550,7 @@ async function runShortTermDreamingPromotionIfTriggered(params: {
let totalCandidates = 0;
let totalApplied = 0;
let failedWorkspaces = 0;
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
const pluginConfig = params.cfg ? resolveMemoryDreamingPluginConfig(params.cfg) : undefined;
const detachNarratives = params.trigger === "cron";
const [
{ writeDeepDreamingReport },
@@ -793,10 +793,10 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
const pluginConfig =
params.reason === "startup"
? (resolveMemoryCorePluginConfig(startupCfg) ??
resolveMemoryCorePluginConfig(api.config) ??
? (resolveMemoryDreamingPluginConfig(startupCfg) ??
resolveMemoryDreamingPluginConfig(api.config) ??
api.pluginConfig)
: resolveMemoryCorePluginConfig(startupCfg);
: resolveMemoryDreamingPluginConfig(startupCfg);
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg: startupCfg,
+2 -2
View File
@@ -10,6 +10,7 @@ import {
readFiniteNumberParam,
readPositiveIntegerParam,
readStringParam,
resolveMemoryDreamingPluginConfig,
type MemoryCorpusSearchResult,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
@@ -18,7 +19,6 @@ import type {
MemorySearchRuntimeDebug,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig,
resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
@@ -551,7 +551,7 @@ export function createMemorySearchTool(options: {
mode: citationsMode,
sessionKey: options.agentSessionKey,
});
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
const pluginConfig = resolveMemoryDreamingPluginConfig(cfg);
const dreamingEnabled = resolveMemoryDreamingConfig({
pluginConfig,
cfg,
+2 -2
View File
@@ -6,7 +6,7 @@ import {
TRUSTED_CLIENT_TOKEN,
generateSecMsGecToken,
} from "node-edge-tts/dist/drm.js";
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import {
assertOkOrThrowProviderError,
readProviderJsonResponse,
@@ -288,7 +288,7 @@ export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin {
audioBuffer,
outputFormat: format,
fileExtension,
voiceCompatible: isVoiceCompatibleAudio({ fileName: outputPath }),
voiceCompatible: isVoiceMessageCompatibleAudio({ fileName: outputPath }),
};
};
+22 -5
View File
@@ -24,12 +24,22 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { chunkMarkdownTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
import { describe, it } from "vitest";
import { describe, it, vi } from "vitest";
import type { OpenClawConfig, ReplyPayload } from "../runtime-api.js";
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
import { setMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
};
});
/** Options msteams passes into core createReplyDispatcherWithTyping (capture seam). */
type CapturedDispatcherOptions = {
onReplyStart?: () => Promise<void> | void;
@@ -230,11 +240,18 @@ const MSTEAMS_TRACE_CASES: readonly MSTeamsTraceCase[] = [
function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) {
let captured: CapturedDispatcherOptions | undefined;
setMSTeamsRuntime(
createTraceRuntimeStub(recorder, (options) => {
setMSTeamsRuntime(createTraceRuntimeStub(recorder, () => undefined));
createReplyDispatcherWithTypingMock.mockImplementation((options: CapturedDispatcherOptions) => {
captured = options;
}),
);
return {
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {
options.typingCallbacks?.onIdle?.();
},
markRunComplete: () => {},
};
});
const stream = createRecordingStream(recorder, traceCase.streamWriteFault);
const context = createRecordingTurnContext({
recorder,
+6 -15
View File
@@ -1,7 +1,6 @@
// Msteams plugin module implements feedback invoke behavior.
import path from "node:path";
import { recordChannelFeedbackEvent } from "openclaw/plugin-sdk/channel-inbound";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatUnknownError } from "./errors.js";
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
@@ -131,19 +130,12 @@ export async function runMSTeamsFeedbackInvokeHandler(
hasComment: Boolean(userComment),
});
// Write feedback event to session transcript
try {
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
await recordChannelFeedbackEvent({
cfg: deps.cfg,
agentId: route.agentId,
});
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
const transcriptFile = path.join(storePath, `${safeKey}.jsonl`);
await appendRegularFile({
filePath: transcriptFile,
content: `${JSON.stringify(feedbackEvent)}\n`,
rejectSymlinkParents: true,
}).catch(() => {
// Best effort — transcript dir may not exist yet
sessionKey: route.sessionKey,
event: feedbackEvent,
});
} catch {
// Best effort
@@ -181,12 +173,11 @@ export async function runMSTeamsFeedbackInvokeHandler(
runFeedbackReflection({
cfg: deps.cfg,
app: deps.app,
appId: deps.appId,
conversationRef,
sessionKey: route.sessionKey,
agentId: route.agentId,
conversationId,
feedbackMessageId: messageId,
conversationKind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
userComment,
log: deps.log,
}).catch((err: unknown) => {
@@ -1,119 +0,0 @@
// Msteams plugin module implements feedback reflection prompt behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
/** Max chars of the thumbed-down response to include in the reflection prompt. */
const MAX_RESPONSE_CHARS = 500;
type ParsedReflectionResponse = {
learning: string;
followUp: boolean;
userMessage?: string;
};
export function buildReflectionPrompt(params: {
thumbedDownResponse?: string;
userComment?: string;
}): string {
const parts: string[] = ["A user indicated your previous response wasn't helpful."];
if (params.thumbedDownResponse) {
const truncated =
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...`
: params.thumbedDownResponse;
parts.push(`\nYour response was:\n> ${truncated}`);
}
if (params.userComment) {
parts.push(`\nUser's comment: "${params.userComment}"`);
}
parts.push(
"\nBriefly reflect: what could you improve? Consider tone, length, " +
"accuracy, relevance, and specificity. Reply with a single JSON object " +
'only, no markdown or prose, using this exact shape:\n{"learning":"...",' +
'"followUp":false,"userMessage":""}\n' +
"- learning: a short internal adjustment note (1-2 sentences) for your " +
"future behavior in this conversation.\n" +
"- followUp: true only if the user needs a direct follow-up message.\n" +
"- userMessage: only the exact user-facing message to send; empty string " +
"when followUp is false.",
);
return parts.join("\n");
}
function parseBooleanLike(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "true" || normalized === "yes") {
return true;
}
if (normalized === "false" || normalized === "no") {
return false;
}
}
return undefined;
}
function parseStructuredReflectionValue(value: unknown): ParsedReflectionResponse | null {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const candidate = value as {
learning?: unknown;
followUp?: unknown;
userMessage?: unknown;
};
const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : undefined;
if (!learning) {
return null;
}
return {
learning,
followUp: parseBooleanLike(candidate.followUp) ?? false,
userMessage:
typeof candidate.userMessage === "string" && candidate.userMessage.trim()
? candidate.userMessage.trim()
: undefined,
};
}
export function parseReflectionResponse(text: string): ParsedReflectionResponse | null {
const trimmed = text.trim();
if (!trimmed) {
return null;
}
const candidates = [
trimmed,
...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []),
];
for (const candidateText of candidates) {
const candidate = candidateText.trim();
if (!candidate) {
continue;
}
try {
const parsed = parseStructuredReflectionValue(JSON.parse(candidate));
if (parsed) {
return parsed;
}
} catch {
// Fall through to the next parse strategy.
}
}
// Safe fallback: keep the internal learning, but never auto-message the user.
return {
learning: trimmed,
followUp: false,
};
}
@@ -1,15 +1,6 @@
// Msteams plugin module implements feedback reflection store behavior.
import crypto from "node:crypto";
import { getMSTeamsRuntime } from "./runtime.js";
/** Default cooldown between reflections per session (5 minutes). */
export const DEFAULT_COOLDOWN_MS = 300_000;
/** Tracks last reflection time per session to enforce cooldown. */
const lastReflectionBySession = new Map<string, number>();
/** Maximum cooldown entries before pruning expired ones. */
const MAX_COOLDOWN_ENTRIES = 500;
const LEARNINGS_NAMESPACE = "feedback-learnings";
const MAX_LEARNING_ENTRIES = 10_000;
@@ -23,59 +14,22 @@ function learningStoreKey(storePath: string, sessionKey: string): string {
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
}
function openLearningStore() {
return getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
namespace: LEARNINGS_NAMESPACE,
maxEntries: MAX_LEARNING_ENTRIES,
});
}
/** Prune expired cooldown entries to prevent unbounded memory growth. */
function pruneExpiredCooldowns(cooldownMs: number): void {
if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) {
return;
}
const now = Date.now();
for (const [key, time] of lastReflectionBySession) {
if (now - time >= cooldownMs) {
lastReflectionBySession.delete(key);
}
}
}
/** Check if a reflection is allowed (cooldown not active). */
export function isReflectionAllowed(sessionKey: string, cooldownMs?: number): boolean {
const cooldown = cooldownMs ?? DEFAULT_COOLDOWN_MS;
const lastTime = lastReflectionBySession.get(sessionKey);
if (lastTime == null) {
return true;
}
return Date.now() - lastTime >= cooldown;
}
/** Record that a reflection was run for a session. */
export function recordReflectionTime(sessionKey: string, cooldownMs?: number): void {
lastReflectionBySession.set(sessionKey, Date.now());
pruneExpiredCooldowns(cooldownMs ?? DEFAULT_COOLDOWN_MS);
}
/** Store a learning derived from feedback reflection. */
export async function storeSessionLearning(params: {
storePath: string;
sessionKey: string;
learning: string;
}): Promise<void> {
const store = openLearningStore();
const key = learningStoreKey(params.storePath, params.sessionKey);
const existing = await store.lookup(key);
let learnings = existing?.learnings ?? [];
learnings.push(params.learning);
if (learnings.length > 10) {
learnings = learnings.slice(-10);
}
await store.register(key, {
sessionKey: params.sessionKey,
learnings,
updatedAt: Date.now(),
const store = getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
namespace: LEARNINGS_NAMESPACE,
maxEntries: MAX_LEARNING_ENTRIES,
});
const key = learningStoreKey(params.storePath, params.sessionKey);
if (!store.update) {
throw new Error("plugin state atomic update is unavailable");
}
await store.update(key, (existing) => ({
sessionKey: params.sessionKey,
learnings: [...(existing?.learnings ?? []), params.learning].slice(-10),
updatedAt: Date.now(),
}));
}
@@ -1,173 +0,0 @@
// Msteams tests cover feedback reflection plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
import { isReflectionAllowed, recordReflectionTime } from "./feedback-reflection-store.js";
import { buildFeedbackEvent } from "./feedback-reflection.js";
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
// on the ES2024 String.prototype.isWellFormed() runtime API.
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
describe("buildFeedbackEvent", () => {
it("builds a well-formed custom event", () => {
const event = buildFeedbackEvent({
messageId: "msg-123",
value: "negative",
comment: "too verbose",
sessionKey: "msteams:user1",
agentId: "default",
conversationId: "19:abc",
});
expect(event.type).toBe("custom");
expect(event.event).toBe("feedback");
expect(event.value).toBe("negative");
expect(event.comment).toBe("too verbose");
expect(event.messageId).toBe("msg-123");
expect(event.ts).toBeGreaterThan(0);
});
it("omits comment when not provided", () => {
const event = buildFeedbackEvent({
messageId: "msg-123",
value: "positive",
sessionKey: "msteams:user1",
agentId: "default",
conversationId: "19:abc",
});
expect(event.comment).toBeUndefined();
expect(event.value).toBe("positive");
});
});
describe("buildReflectionPrompt", () => {
it("includes the thumbed-down response", () => {
const prompt = buildReflectionPrompt({
thumbedDownResponse: "Here is a long explanation...",
});
expect(prompt).toContain("previous response wasn't helpful");
expect(prompt).toContain("Here is a long explanation...");
expect(prompt).toContain("reflect");
});
it("truncates long responses", () => {
const longResponse = "x".repeat(600);
const prompt = buildReflectionPrompt({
thumbedDownResponse: longResponse,
});
expect(prompt).toContain("...");
expect(prompt.length).toBeLessThan(longResponse.length + 500);
});
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(499)}...`);
expect(prompt).not.toContain("\ud83e");
expect(prompt).not.toContain("\udd9e");
});
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
});
it("includes user comment when provided", () => {
const prompt = buildReflectionPrompt({
thumbedDownResponse: "Some response",
userComment: "Too wordy",
});
expect(prompt).toContain('User\'s comment: "Too wordy"');
});
it("works without optional params", () => {
const prompt = buildReflectionPrompt({});
expect(prompt).toContain("previous response wasn't helpful");
expect(prompt).toContain('"followUp":false');
});
});
describe("parseReflectionResponse", () => {
it("parses strict JSON output", () => {
expect(
parseReflectionResponse(
'{"learning":"Be more direct next time.","followUp":true,"userMessage":"Sorry about that. I will keep it tighter."}',
),
).toEqual({
learning: "Be more direct next time.",
followUp: true,
userMessage: "Sorry about that. I will keep it tighter.",
});
});
it("parses JSON inside markdown fences", () => {
expect(
parseReflectionResponse(
'```json\n{"learning":"Ask a clarifying question first.","followUp":false,"userMessage":""}\n```',
),
).toEqual({
learning: "Ask a clarifying question first.",
followUp: false,
userMessage: undefined,
});
});
it("falls back to internal-only learning when parsing fails", () => {
expect(parseReflectionResponse("Be more concise.\nFollow up: yes.")).toEqual({
learning: "Be more concise.\nFollow up: yes.",
followUp: false,
});
});
});
describe("reflection cooldown", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("allows first reflection", () => {
expect(isReflectionAllowed("session-first")).toBe(true);
});
it("blocks reflection within cooldown", () => {
recordReflectionTime("session-blocked");
expect(isReflectionAllowed("session-blocked", 60_000)).toBe(false);
});
it("allows reflection after cooldown expires", () => {
vi.spyOn(Date, "now").mockReturnValue(0);
recordReflectionTime("session-expired");
vi.spyOn(Date, "now").mockReturnValue(2);
expect(isReflectionAllowed("session-expired", 1)).toBe(true);
});
it("tracks sessions independently", () => {
recordReflectionTime("session-tracked-1");
expect(isReflectionAllowed("session-tracked-1", 60_000)).toBe(false);
expect(isReflectionAllowed("session-tracked-2", 60_000)).toBe(true);
});
it("keeps longer custom cooldown entries during pruning", () => {
vi.spyOn(Date, "now").mockReturnValue(0);
recordReflectionTime("prune-target", 600_000);
vi.spyOn(Date, "now").mockReturnValue(301_000);
for (let index = 0; index <= 500; index += 1) {
recordReflectionTime(`prune-session-${index}`, 600_000);
}
expect(isReflectionAllowed("prune-target", 600_000)).toBe(false);
});
});
+43 -160
View File
@@ -1,22 +1,16 @@
// Msteams plugin module implements feedback reflection behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
dispatchReplyFromConfigWithSettledDispatcher,
type OpenClawConfig,
} from "../runtime-api.js";
DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS,
runChannelFeedbackReflection,
} from "openclaw/plugin-sdk/channel-inbound";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { formatUnknownError } from "./errors.js";
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
import {
DEFAULT_COOLDOWN_MS,
isReflectionAllowed,
recordReflectionTime,
storeSessionLearning,
} from "./feedback-reflection-store.js";
import { storeSessionLearning } from "./feedback-reflection-store.js";
import { buildConversationReference } from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
import type { MSTeamsApp } from "./sdk.js";
@@ -30,7 +24,6 @@ type FeedbackEvent = {
sessionKey: string;
agentId: string;
conversationId: string;
reflectionLearning?: string;
};
export function buildFeedbackEvent(params: {
@@ -57,173 +50,65 @@ export function buildFeedbackEvent(params: {
type RunFeedbackReflectionParams = {
cfg: OpenClawConfig;
app: MSTeamsApp;
appId: string;
conversationRef: StoredConversationReference;
sessionKey: string;
agentId: string;
conversationId: string;
feedbackMessageId: string;
conversationKind: "direct" | "group" | "channel";
thumbedDownResponse?: string;
userComment?: string;
log: MSTeamsMonitorLogger;
};
function buildReflectionContext(params: {
cfg: OpenClawConfig;
conversationId: string;
sessionKey: string;
reflectionPrompt: string;
}) {
const core = getMSTeamsRuntime();
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams",
from: "system",
body: params.reflectionPrompt,
envelope: envelopeOptions,
});
return {
ctxPayload: core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: params.reflectionPrompt,
RawBody: params.reflectionPrompt,
CommandBody: params.reflectionPrompt,
From: `msteams:system:${params.conversationId}`,
To: `conversation:${params.conversationId}`,
SessionKey: params.sessionKey,
ChatType: "direct" as const,
SenderName: "system",
SenderId: "system",
Provider: "msteams" as const,
Surface: "msteams" as const,
Timestamp: Date.now(),
WasMentioned: true,
CommandAuthorized: false,
OriginatingChannel: "msteams" as const,
OriginatingTo: `conversation:${params.conversationId}`,
}),
};
}
function createReflectionCaptureDispatcher(params: {
cfg: OpenClawConfig;
agentId: string;
log: MSTeamsMonitorLogger;
}) {
const core = getMSTeamsRuntime();
let response = "";
const noopTypingCallbacks = {
onReplyStart: async () => {},
onIdle: () => {},
onCleanup: () => {},
};
const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({
deliver: async (payload) => {
if (payload.text) {
response += (response ? "\n" : "") + payload.text;
}
},
typingCallbacks: noopTypingCallbacks,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
onError: (err) => {
params.log.debug?.("reflection reply error", { error: formatUnknownError(err) });
},
});
return {
dispatcher,
replyOptions,
readResponse: () => response,
};
}
async function sendReflectionFollowUp(params: {
cfg: OpenClawConfig;
app: MSTeamsApp;
conversationRef: StoredConversationReference;
userMessage: string;
}): Promise<void> {
const baseRef = buildConversationReference(params.conversationRef);
await sendMSTeamsActivityWithReference(
params.app,
baseRef,
{ type: "message", text: params.userMessage },
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(params.cfg.channels?.msteams) },
);
}
/**
* Run a background reflection after negative feedback.
* This is designed to be called fire-and-forget (don't await in the invoke handler).
*/
export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> {
const { cfg, log, sessionKey } = params;
const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS;
if (!isReflectionAllowed(sessionKey, cooldownMs)) {
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
return;
}
const reflectionPrompt = buildReflectionPrompt({
const cooldownMs =
cfg.channels?.msteams?.feedbackReflectionCooldownMs ??
DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS;
let reflection;
try {
reflection = await runChannelFeedbackReflection({
cfg,
channel: "msteams",
channelLabel: "Teams",
agentId: params.agentId,
sessionKey,
conversationId: params.conversationId,
conversationKind: params.conversationKind,
thumbedDownResponse: params.thumbedDownResponse,
userComment: params.userComment,
});
const runtime = getMSTeamsRuntime();
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
agentId: params.agentId,
});
const { ctxPayload } = buildReflectionContext({
cfg,
conversationId: params.conversationId,
sessionKey: params.sessionKey,
reflectionPrompt,
});
const capture = createReflectionCaptureDispatcher({
cfg,
agentId: params.agentId,
log,
});
try {
await dispatchReplyFromConfigWithSettledDispatcher({
ctxPayload,
cfg,
dispatcher: capture.dispatcher,
onSettled: () => {},
replyOptions: capture.replyOptions,
cooldownMs,
onRecordError: (err) =>
log.debug?.("reflection session record failed", { error: formatUnknownError(err) }),
onDispatchError: (err) =>
log.debug?.("reflection reply error", { error: formatUnknownError(err) }),
});
} catch (err) {
log.error("reflection dispatch failed", { error: formatUnknownError(err) });
return;
}
const reflectionResponse = capture.readResponse().trim();
if (!reflectionResponse) {
if (reflection.status === "cooldown") {
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
return;
}
if (reflection.status === "empty") {
log.debug?.("reflection produced no output");
return;
}
const parsedReflection = parseReflectionResponse(reflectionResponse);
if (!parsedReflection) {
log.debug?.("reflection produced no structured output");
return;
}
recordReflectionTime(sessionKey, cooldownMs);
log.info("reflection complete", {
sessionKey,
responseLength: reflectionResponse.length,
followUp: parsedReflection.followUp,
responseLength: reflection.responseLength,
followUp: reflection.followUp,
});
try {
await storeSessionLearning({
storePath,
sessionKey: params.sessionKey,
learning: parsedReflection.learning,
storePath: reflection.storePath,
sessionKey,
learning: reflection.learning,
});
} catch (err) {
log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) });
@@ -233,12 +118,10 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
params.conversationRef.conversation?.conversationType,
);
const shouldNotify =
conversationType === "personal" &&
parsedReflection.followUp &&
Boolean(parsedReflection.userMessage);
conversationType === "personal" && reflection.followUp && Boolean(reflection.userMessage);
if (!shouldNotify) {
if (parsedReflection.followUp && conversationType !== "personal") {
if (reflection.followUp && conversationType !== "personal") {
log.debug?.("skipping reflection follow-up outside direct message", {
sessionKey,
conversationType,
@@ -248,12 +131,12 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
}
try {
await sendReflectionFollowUp({
cfg,
app: params.app,
conversationRef: params.conversationRef,
userMessage: parsedReflection.userMessage!,
});
await sendMSTeamsActivityWithReference(
params.app,
buildConversationReference(params.conversationRef),
{ type: "message", text: reflection.userMessage! },
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(cfg.channels?.msteams) },
);
log.info("sent reflection follow-up", { sessionKey });
} catch (err) {
log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) });
@@ -1,7 +1,4 @@
// Msteams tests cover monitor handler.feedback authz plugin behavior.
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js";
@@ -13,6 +10,14 @@ import type { MSTeamsTurnContext } from "./sdk-types.js";
const feedbackReflectionMockState = vi.hoisted(() => ({
runFeedbackReflection: vi.fn(),
}));
const channelInboundMockState = vi.hoisted(() => ({
recordChannelFeedbackEvent: vi.fn(async () => true),
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>()),
recordChannelFeedbackEvent: channelInboundMockState.recordChannelFeedbackEvent,
}));
vi.mock("./monitor-handler/message-handler.js", () => ({
createMSTeamsMessageHandler: () => async () => {},
@@ -57,7 +62,7 @@ function createRuntimeStub(readAllowFromStore: ReturnType<typeof vi.fn>): Plugin
}),
},
session: {
resolveStorePath: (storePath?: string) => storePath ?? tmpdir(),
resolveStorePath: (storePath?: string) => storePath ?? "/tmp",
},
},
} as unknown as PluginRuntime;
@@ -126,41 +131,21 @@ function createFeedbackInvokeContext(params: {
} as unknown as MSTeamsTurnContext;
}
async function expectFileMissing(filePath: string) {
let error: unknown;
try {
await access(filePath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
}
async function withFeedbackHandler(params: {
cfg: OpenClawConfig;
context: Parameters<typeof createFeedbackInvokeContext>[0];
assertResult: (args: { tmpDir: string }) => Promise<void>;
assertResult: () => Promise<void>;
}) {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
try {
const deps = createDeps({
cfg: {
...params.cfg,
session: { store: tmpDir },
},
});
const deps = createDeps({ cfg: params.cfg });
await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
await params.assertResult({ tmpDir });
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
await params.assertResult();
}
describe("msteams feedback invoke authz", () => {
beforeEach(() => {
feedbackReflectionMockState.runFeedbackReflection.mockReset();
feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined);
channelInboundMockState.recordChannelFeedbackEvent.mockClear();
});
it("records feedback for an allowlisted DM sender", async () => {
@@ -181,34 +166,22 @@ describe("msteams feedback invoke authz", () => {
senderName: "Owner",
comment: "allowed feedback",
},
assertResult: async ({ tmpDir }) => {
const transcript = await readFile(
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
"utf-8",
);
const event = JSON.parse(transcript.trim()) as Record<string, unknown>;
expect(Object.keys(event).toSorted()).toEqual([
"agentId",
"comment",
"conversationId",
"event",
"messageId",
"sessionKey",
"ts",
"type",
"value",
]);
expect(typeof event.ts).toBe("number");
expect({ ...event, ts: 0 }).toEqual({
assertResult: async () => {
expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith({
cfg: expect.any(Object),
agentId: "default",
sessionKey: "msteams:direct:owner-aad",
event: {
type: "custom",
event: "feedback",
ts: 0,
ts: expect.any(Number),
messageId: "bot-msg-1",
value: "positive",
comment: "allowed feedback",
sessionKey: "msteams:direct:owner-aad",
agentId: "default",
conversationId: "a:personal-chat",
},
});
},
});
@@ -239,35 +212,14 @@ describe("msteams feedback invoke authz", () => {
senderName: "Owner",
comment: "allowed dm feedback",
},
assertResult: async ({ tmpDir }) => {
const transcript = await readFile(
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
"utf-8",
);
const event = JSON.parse(transcript.trim()) as Record<string, unknown>;
expect(Object.keys(event).toSorted()).toEqual([
"agentId",
"comment",
"conversationId",
"event",
"messageId",
"sessionKey",
"ts",
"type",
"value",
]);
expect(typeof event.ts).toBe("number");
expect({ ...event, ts: 0 }).toEqual({
type: "custom",
event: "feedback",
ts: 0,
messageId: "bot-msg-1",
value: "positive",
comment: "allowed dm feedback",
sessionKey: "msteams:direct:owner-aad",
assertResult: async () => {
expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "default",
conversationId: "a:personal-chat",
});
sessionKey: "msteams:direct:owner-aad",
event: expect.objectContaining({ comment: "allowed dm feedback" }),
}),
);
},
});
});
@@ -290,19 +242,16 @@ describe("msteams feedback invoke authz", () => {
senderName: "Attacker",
comment: "blocked feedback",
},
assertResult: async ({ tmpDir }) => {
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl"));
assertResult: async () => {
expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
},
});
});
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
try {
const deps = createDeps({
cfg: {
session: { store: tmpDir },
channels: {
msteams: {
groupPolicy: "allowlist",
@@ -327,10 +276,7 @@ describe("msteams feedback invoke authz", () => {
deps,
);
await expectFileMissing(path.join(tmpDir, "msteams_group_19_group_thread_tacv2.jsonl"));
expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});
@@ -28,6 +28,8 @@ type MSTeamsTestRuntimeOptions = {
};
export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void {
const recordInboundSession = options.recordInboundSession ?? vi.fn(async () => undefined);
const resolveStorePath = options.resolveStorePath ?? (() => "/tmp/msteams-sessions.json");
const runPrepared = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
await turn.recordInboundSession({
storePath: turn.storePath,
@@ -63,7 +65,16 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
: (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) {
return await runPrepared(turn);
const preparedTurn =
"route" in turn
? ({
...turn,
routeSessionKey: turn.route.sessionKey,
storePath: resolveStorePath(),
recordInboundSession,
} as PreparedInboundReply<unknown>)
: turn;
return await runPrepared(preparedTurn);
}
throw new Error("msteams test runtime only supports prepared turn dispatch");
});
@@ -124,8 +135,8 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
resolveHumanDelayConfig: () => undefined,
},
session: {
recordInboundSession: options.recordInboundSession ?? vi.fn(async () => undefined),
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
recordInboundSession,
resolveStorePath,
},
inbound: {
run: run as unknown as PluginRuntime["channel"]["inbound"]["run"],
@@ -2,9 +2,9 @@
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import {
buildChannelInboundEventContext,
createChannelInboundEnvelopeBuilder,
logInboundDrop,
resolveInboundMentionDecision,
resolveInboundSessionEnvelopeContext,
resolveInboundSupplementalSenderAllowed,
} from "openclaw/plugin-sdk/channel-inbound";
import {
@@ -788,17 +788,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
quoteSenderName ??= quoteInfo?.sender;
const envelopeFrom = isDirectMessage ? senderName : conversationType;
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg,
agentId: route.agentId,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
const buildEnvelope = createChannelInboundEnvelopeBuilder({ cfg, route });
const body = buildEnvelope({
channel: "Teams",
from: envelopeFrom,
timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: agentBody,
});
let combinedBody = body;
@@ -811,12 +805,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
buildEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
previousTimestamp: null,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
envelope: envelopeOptions,
}),
});
}
@@ -858,7 +852,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined;
const ctxPayload = buildChannelInboundEventContext({
channel: "msteams",
finalize: core.channel.reply.finalizeInboundContext,
contextVisibility: contextVisibilityMode,
supplemental: {
quote: quoteInfo
@@ -975,12 +968,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
raw: activity,
}),
resolveTurn: () => ({
cfg,
channel: "msteams",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
logVerboseMessage(
@@ -14,6 +14,14 @@ vi.mock("../runtime-api.js", () => ({
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
};
});
vi.mock("./runtime.js", () => ({
getMSTeamsRuntime: getMSTeamsRuntimeMock,
}));
+4 -2
View File
@@ -1,3 +1,4 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
// Msteams plugin module implements reply dispatcher behavior.
import {
buildChannelProgressDraftLine,
@@ -8,6 +9,7 @@ import {
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
} from "openclaw/plugin-sdk/channel-outbound";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
createChannelMessageReplyPipeline,
@@ -295,9 +297,9 @@ export function createMSTeamsReplyDispatcher(params: {
dispatcher,
replyOptions,
markDispatchIdle: baseMarkDispatchIdle,
} = core.channel.reply.createReplyDispatcherWithTyping({
} = createReplyDispatcherWithTyping({
...replyPipeline,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
onReplyStart: async () => {
await streamController.onReplyStart();
// Always start the typing keepalive loop when typing is enabled and
+29 -35
View File
@@ -1,10 +1,13 @@
import {
buildChannelInboundEventContext,
resolveChannelInboundRouteEnvelope,
} from "openclaw/plugin-sdk/channel-inbound";
// Nextcloud Talk plugin module implements inbound behavior.
import {
channelIngressRoutes,
resolveStableChannelMessageIngress,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import {
normalizeOptionalString,
normalizeStringEntries,
@@ -304,7 +307,7 @@ export async function handleNextcloudTalkInbound(params: {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
return;
}
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
@@ -312,14 +315,10 @@ export async function handleNextcloudTalkInbound(params: {
kind: isGroup ? "group" : "direct",
id: isGroup ? roomToken : senderId,
},
runtime: core.channel,
sessionStore: (config.session as Record<string, unknown> | undefined)?.store as
| string
| undefined,
});
const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
const { storePath, body } = buildEnvelope({
const body = buildEnvelope({
channel: "Nextcloud Talk",
from: fromLabel,
timestamp: message.timestamp,
@@ -329,42 +328,37 @@ export async function handleNextcloudTalkInbound(params: {
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
To: `nextcloud-talk:${roomToken}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
const ctxPayload = buildChannelInboundEventContext({
channel: CHANNEL_ID,
accountId: route.accountId,
messageId: message.messageId,
timestamp: message.timestamp,
from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
sender: { id: senderId, name: senderName || undefined },
conversation: { kind: isGroup ? "group" : "direct", id: roomToken, label: fromLabel },
route: {
agentId: route.agentId,
accountId: route.accountId,
routeSessionKey: route.sessionKey,
},
reply: { to: `nextcloud-talk:${roomToken}`, originatingTo: `nextcloud-talk:${roomToken}` },
message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody },
access: {
commands: { authorized: commandAuthorized },
mentions: { canDetectMention: isGroup, wasMentioned: isGroup && wasMentioned },
},
extra: {
GroupSubject: isGroup ? roomName || roomToken : undefined,
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
WasMentioned: isGroup ? wasMentioned : undefined,
MessageSid: message.messageId,
Timestamp: message.timestamp,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `nextcloud-talk:${roomToken}`,
CommandAuthorized: commandAuthorized,
},
});
await core.channel.inbound.dispatchReply({
await core.channel.inbound.dispatch({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: (payload) =>
payload.text === undefined
+26 -14
View File
@@ -1,3 +1,4 @@
import type { dispatchInboundDirectDm as DispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound";
// Nostr tests cover channel.inbound plugin behavior.
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -7,6 +8,7 @@ import { setNostrRuntime } from "./runtime.js";
import { buildResolvedNostrAccount } from "./test-fixtures.js";
const mocks = vi.hoisted(() => ({
dispatchInboundDirectDm: vi.fn(),
normalizePubkey: vi.fn((value: string) =>
value
.trim()
@@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({
startNostrBus: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>()),
dispatchInboundDirectDm: mocks.dispatchInboundDirectDm,
}));
vi.mock("./nostr-bus.js", () => ({
DEFAULT_RELAYS: ["wss://relay.example.com"],
startNostrBus: mocks.startNostrBus,
@@ -127,6 +133,7 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
describe("nostr inbound gateway path", () => {
afterEach(() => {
mocks.dispatchInboundDirectDm.mockReset();
mocks.normalizePubkey.mockClear();
mocks.startNostrBus.mockReset();
});
@@ -159,15 +166,19 @@ describe("nostr inbound gateway path", () => {
});
it("routes allowed DMs through the standard reply pipeline", async () => {
const { harness, cleanup } = await startGatewayHarness({
mocks.dispatchInboundDirectDm.mockImplementationOnce(
async (params: Parameters<typeof DispatchInboundDirectDm>[0]) => {
await params.deliver({ text: "|a|b|" });
},
);
const { cleanup } = await startGatewayHarness({
account: buildResolvedNostrAccount({
publicKey: "bot-pubkey",
config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
}),
cfg: {
session: { store: { type: "jsonl" } },
commands: { useAccessGroups: true },
} as never,
},
});
const options = mockCallArg(mocks.startNostrBus) as {
@@ -185,17 +196,18 @@ describe("nostr inbound gateway path", () => {
createdAt: 1_710_000_000,
});
expect(harness.recordInboundSession).toHaveBeenCalledTimes(1);
expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const ctx = (
mockCallArg(harness.dispatchReplyWithBufferedBlockDispatcher) as {
ctx?: Record<string, unknown>;
}
).ctx;
expect(ctx?.BodyForAgent).toBe("hello from nostr");
expect(ctx?.SenderId).toBe("sender-pubkey");
expect(ctx?.MessageSid).toBe("event-123");
expect(ctx?.CommandAuthorized).toBe(true);
expect(mocks.dispatchInboundDirectDm).toHaveBeenCalledWith(
expect.objectContaining({
channel: "nostr",
accountId: "default",
peer: { kind: "direct", id: "sender-pubkey" },
senderId: "sender-pubkey",
rawBody: "hello from nostr",
messageId: "event-123",
timestamp: 1_710_000_000_000,
commandAuthorized: true,
}),
);
expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
await cleanup.stop();
+2 -4
View File
@@ -163,11 +163,9 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
return;
}
const { dispatchInboundDirectDmWithRuntime } =
await import("./inbound-direct-dm-runtime.js");
await dispatchInboundDirectDmWithRuntime({
const { dispatchInboundDirectDm } = await import("./inbound-direct-dm-runtime.js");
await dispatchInboundDirectDm({
cfg: ctx.cfg,
runtime,
channel: "nostr",
channelLabel: "Nostr",
accountId: account.accountId,
@@ -1,2 +1,2 @@
// Nostr plugin module implements inbound direct dm runtime behavior.
export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/channel-inbound";
export { dispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound";
+10 -91
View File
@@ -18,7 +18,7 @@ import { qaChannelPlugin, setQaChannelRuntime } from "../api.js";
import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
import type { ChannelMessageActionName } from "./runtime-api.js";
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0];
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatch"]>[0];
afterEach(() => {
resetPluginRuntimeStateForTest();
@@ -66,7 +66,6 @@ function createMockQaRuntime(params?: {
onDispatch?: (ctx: Record<string, unknown>) => void;
toolStarts?: Array<{ name?: string; phase?: string; args?: Record<string, unknown> }>;
}): PluginRuntime {
const sessionUpdatedAt = new Map<string, number>();
return createPluginRuntimeMock({
channel: {
mentions: {
@@ -77,104 +76,24 @@ function createMockQaRuntime(params?: {
return patterns.some((pattern) => pattern.test(text));
},
},
routing: {
resolveAgentRoute({
accountId,
peer,
}: {
accountId?: string | null;
peer?: { kind?: string; id?: string } | null;
}) {
return {
agentId: "qa-agent",
channel: "qa-channel",
accountId: accountId ?? "default",
sessionKey: `qa-agent:${peer?.kind ?? "direct"}:${peer?.id ?? "default"}`,
mainSessionKey: "qa-agent:main",
lastRoutePolicy: "session",
matchedBy: "default",
};
},
},
session: {
resolveStorePath(_store: string | undefined, { agentId }: { agentId: string }) {
return agentId;
},
readSessionUpdatedAt({ sessionKey }: { sessionKey: string }) {
return sessionUpdatedAt.get(sessionKey);
},
recordInboundSession({ sessionKey }: { sessionKey: string }) {
sessionUpdatedAt.set(sessionKey, Date.now());
},
},
reply: {
resolveEnvelopeFormatOptions() {
return {};
},
formatAgentEnvelope({ body }: { body: string }) {
return body;
},
finalizeInboundContext(ctx: Record<string, unknown>) {
return ctx as typeof ctx & { CommandAuthorized: boolean };
},
async dispatchReplyWithBufferedBlockDispatcher({
ctx,
dispatcherOptions,
replyOptions,
}: {
ctx: { BodyForAgent?: string; Body?: string };
dispatcherOptions: {
deliver: (payload: { text: string }, info: { kind: string }) => Promise<void>;
};
replyOptions?: {
onToolStart?: (payload: {
name?: string;
phase?: string;
args?: Record<string, unknown>;
}) => Promise<void> | void;
};
}) {
inbound: {
async dispatch(turn: QaDispatchTurn) {
for (const toolStart of params?.toolStarts ?? []) {
await replyOptions?.onToolStart?.(toolStart);
await turn.replyOptions?.onToolStart?.(toolStart);
}
params?.onDispatch?.(ctx as Record<string, unknown>);
await dispatcherOptions.deliver(
params?.onDispatch?.(turn.ctxPayload as Record<string, unknown>);
await turn.delivery.deliver(
{
text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`,
text: `qa-echo: ${turn.ctxPayload.BodyForAgent ?? turn.ctxPayload.Body ?? ""}`,
},
{ kind: "final" },
);
},
},
inbound: {
async dispatchReply(turn: QaDispatchTurn) {
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey:
typeof turn.ctxPayload.SessionKey === "string"
? turn.ctxPayload.SessionKey
: turn.routeSessionKey,
ctx: turn.ctxPayload,
onRecordError: turn.record?.onRecordError ?? (() => undefined),
});
return {
admission: turn.admission ?? { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult: await turn.dispatchReplyWithBufferedBlockDispatcher({
ctx: turn.ctxPayload,
cfg: turn.cfg,
dispatcherOptions: {
...turn.dispatcherOptions,
deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
await turn.delivery.deliver(...args);
},
onError: turn.delivery.onError,
},
replyOptions: turn.replyOptions,
replyResolver: turn.replyResolver,
}),
routeSessionKey: turn.route.sessionKey,
dispatchResult: undefined,
};
},
},
@@ -505,7 +424,7 @@ describe("qa-channel plugin", () => {
expect(ctx.ChatType).toBe("group");
expect(ctx.From).toBe("group:qa-room");
expect(ctx.To).toBe("group:qa-room");
expect(ctx.SessionKey).toBe("qa-agent:group:group:qa-room");
expect(ctx.SessionKey).toBe("agent:main:qa-channel:group:group:qa-room");
expect(ctx.SenderId).toBe("alice");
expect(ctx.GroupSubject).toBe("QA Room");
expect("conversation" in outbound).toBe(true);
+10 -10
View File
@@ -59,7 +59,7 @@ function createQaInboundParams(
}
function firstRunAssembledParams(runtime: ReturnType<typeof createPluginRuntimeMock>) {
const call = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0];
const call = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0];
if (!call) {
throw new Error("expected assembled turn call");
}
@@ -262,7 +262,7 @@ describe("handleQaInbound", () => {
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
const assembled = firstRunAssembledParams(runtime);
expect(assembled.replyPipeline).toEqual({});
expect(assembled.ctxPayload.WasMentioned).toBe(true);
@@ -280,7 +280,7 @@ describe("handleQaInbound", () => {
}),
);
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
});
it("allows direct messages from configured senders", async () => {
@@ -295,7 +295,7 @@ describe("handleQaInbound", () => {
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload?.CommandAuthorized).toBe(true);
expect(ctxPayload?.SenderId).toBe("alice");
@@ -318,14 +318,14 @@ describe("handleQaInbound", () => {
expect(assembled.ctxPayload).toMatchObject({
CommandAuthorized: true,
CommandSource: "native",
CommandTargetSessionKey: assembled.routeSessionKey,
CommandTargetSessionKey: assembled.route.sessionKey,
CommandTurn: {
body: "/stop",
source: "native",
},
});
expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice");
expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.routeSessionKey);
expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.route.sessionKey);
});
it("skips malformed inline attachment base64 without dropping the message", async () => {
@@ -347,7 +347,7 @@ describe("handleQaInbound", () => {
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload.MediaPath).toBeUndefined();
expect(ctxPayload.MediaPaths).toBeUndefined();
@@ -380,7 +380,7 @@ describe("handleQaInbound", () => {
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload.MediaPath).toBeUndefined();
expect(ctxPayload.MediaPaths).toBeUndefined();
@@ -410,7 +410,7 @@ describe("handleQaInbound", () => {
}),
);
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
});
it("skips configured group messages that miss mention activation", async () => {
@@ -438,6 +438,6 @@ describe("handleQaInbound", () => {
}),
);
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
});
});
+48 -48
View File
@@ -1,9 +1,12 @@
import {
buildChannelInboundEventContext,
resolveChannelInboundRouteEnvelope,
} from "openclaw/plugin-sdk/channel-inbound";
// Qa Channel plugin module implements inbound behavior.
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import {
buildAgentMediaPayload,
saveMediaBuffer,
@@ -219,7 +222,7 @@ export async function handleQaInbound(params: {
target,
toolCalls,
});
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
accountId: params.account.accountId,
@@ -232,8 +235,6 @@ export async function handleQaInbound(params: {
: "channel",
id: target,
},
runtime: runtime.channel,
sessionStore: params.config.session?.store,
});
const isGroup = inbound.conversation.kind !== "direct";
const wasMentioned = isGroup
@@ -286,7 +287,7 @@ export async function handleQaInbound(params: {
if (access.ingress.admission !== "dispatch") {
return;
}
const { storePath, body } = buildEnvelope({
const body = buildEnvelope({
channel: params.channelLabel,
from: inbound.senderName || inbound.senderId,
timestamp: inbound.timestamp,
@@ -304,65 +305,64 @@ export async function handleQaInbound(params: {
: undefined;
const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text;
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: inbound.text,
RawBody: inbound.text,
CommandBody: commandBody,
From: target,
To: target,
SessionKey: commandTargets?.sessionKey ?? route.sessionKey,
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
AccountId: route.accountId ?? params.account.accountId,
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group",
WasMentioned: wasMentioned,
ConversationLabel:
const sessionKey = commandTargets?.sessionKey ?? route.sessionKey;
const ctxPayload = buildChannelInboundEventContext({
channel: params.channelId,
accountId: route.accountId ?? params.account.accountId,
messageId: inbound.id,
messageIdFull: inbound.id,
timestamp: inbound.timestamp,
from: target,
sender: { id: inbound.senderId, name: inbound.senderName },
conversation: {
kind: inbound.conversation.kind === "direct" ? "direct" : "group",
id: inbound.conversation.id,
label:
inbound.threadTitle ||
inbound.conversation.title ||
inbound.senderName ||
inbound.conversation.id,
threadId: inbound.threadId,
nativeChannelId: inbound.conversation.id,
},
route: {
agentId: route.agentId,
accountId: route.accountId,
routeSessionKey: sessionKey,
dispatchSessionKey: sessionKey,
},
reply: {
to: target,
originatingTo: target,
replyToId: inbound.replyToId,
messageThreadId: inbound.threadId,
threadParentId: inbound.threadId ? inbound.conversation.id : undefined,
},
message: { body, bodyForAgent: inbound.text, rawBody: inbound.text, commandBody },
access: {
commands: { authorized: true },
mentions: { canDetectMention: isGroup, wasMentioned: Boolean(wasMentioned) },
},
command: nativeCommand
? { kind: "native", name: nativeCommand.name, body: commandBody, authorized: true }
: undefined,
extra: {
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
GroupSubject: isGroup
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
: undefined,
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
NativeChannelId: inbound.conversation.id,
MessageThreadId: inbound.threadId,
ThreadLabel: inbound.threadTitle,
ThreadParentId: inbound.threadId ? inbound.conversation.id : undefined,
SenderName: inbound.senderName,
SenderId: inbound.senderId,
Provider: params.channelId,
Surface: params.channelId,
MessageSid: inbound.id,
MessageSidFull: inbound.id,
ReplyToId: inbound.replyToId,
Timestamp: inbound.timestamp,
OriginatingChannel: params.channelId,
OriginatingTo: target,
CommandAuthorized: true,
CommandSource: nativeCommand ? "native" : undefined,
CommandTurn: nativeCommand
? {
kind: "native",
source: "native",
authorized: true,
body: commandBody,
}
: undefined,
...mediaPayload,
},
});
await runtime.channel.inbound.dispatchReply({
await runtime.channel.inbound.dispatch({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
accountId: params.account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload, info) => {
const text =
@@ -1,5 +1,6 @@
// Qqbot plugin module implements inbound context behavior.
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { EngineAdapters } from "../adapter/index.js";
import type { QQBotGroupCommandLevel } from "../config/group.js";
import type { GroupActivationMode } from "../group/activation.js";
@@ -69,7 +70,7 @@ export interface InboundContext {
export interface InboundPipelineDeps {
account: GatewayAccount;
cfg: unknown;
cfg: OpenClawConfig;
log?: EngineLogger;
runtime: GatewayPluginRuntime;
startTyping: (event: QueuedMessage) => Promise<{
@@ -421,10 +421,6 @@ export async function dispatchOutbound(
});
}
const cfgWithSession = cfg as { session?: { store?: unknown } };
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
agentId: routeAgentId,
});
const dispatchPromise = runtime.channel.inbound.run({
channel: "qqbot",
accountId: inbound.route.accountId,
@@ -438,12 +434,11 @@ export async function dispatchOutbound(
raw: inbound,
}),
resolveTurn: () => ({
cfg: openClawCfg,
channel: "qqbot",
accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey,
storePath,
route: { agentId: routeAgentId, sessionKey: inbound.route.sessionKey },
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
record: {
onRecordError: (err: unknown) => {
log?.error(
@@ -773,7 +768,6 @@ async function buildCtxPayload(
const commandSource = resolveCommandSource(inbound, runtime, cfg);
const hasImageMedia = inbound.localMediaPaths.length > 0 || inbound.remoteMediaUrls.length > 0;
return buildChannelInboundEventContext({
finalize: runtime.channel.reply.finalizeInboundContext,
channel: "qqbot",
accountId: inbound.route.accountId,
messageId: event.messageId,
@@ -80,13 +80,13 @@ function buildAllowAccess(): QQBotInboundAccess {
}
function buildDeps(
cfg: unknown,
cfg: StubCfg,
runtime: GatewayPluginRuntime,
account: GatewayAccount,
): InboundPipelineDeps {
return {
account,
cfg,
cfg: cfg as InboundPipelineDeps["cfg"],
runtime,
startTyping: vi.fn(),
adapters: {
@@ -15,6 +15,11 @@
* sees directly.
*/
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
type EnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import {
buildMergedMessageContext,
@@ -103,13 +108,13 @@ export function buildAgentBody(input: BuildAgentBodyInput): string {
return base;
}
const envelopeOpts = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
const envelopeOpts = resolveEnvelopeFormatOptions(deps.cfg);
return deps.adapters.history.buildPendingHistoryContext({
historyMap: deps.groupHistories,
historyKey: event.groupOpenid,
limit: groupInfo.historyLimit,
currentMessage: base,
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, deps, envelopeOpts),
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, envelopeOpts),
});
}
@@ -139,19 +144,15 @@ function formatSenderLabelFrom(name: string | undefined, id: string): string {
return name.includes(id) ? name : `${name} (${id})`;
}
function formatHistoryEntry(
entry: HistoryEntry,
deps: InboundPipelineDeps,
envelopeOpts: unknown,
): string {
function formatHistoryEntry(entry: HistoryEntry, envelopeOpts: unknown): string {
const attachmentDesc = formatAttachmentTags(entry.attachments);
const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body;
return deps.runtime.channel.reply.formatInboundEnvelope({
return formatInboundEnvelope({
channel: "qqbot",
from: entry.sender,
timestamp: entry.timestamp,
body: bodyWithAttachments,
chatType: "group",
envelope: envelopeOpts,
envelope: envelopeOpts as EnvelopeFormatOptions,
});
}
@@ -7,6 +7,11 @@
* dispatcher needs. No decisions / gating.
*/
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ProcessedAttachments } from "../inbound-attachments.js";
import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js";
@@ -25,16 +30,16 @@ interface BuildBodyInput {
/** Format the inbound envelope (Web UI body). */
export function buildBody(input: BuildBodyInput): string {
const { event, deps, userContent, isGroupChat, imageUrls } = input;
const envelopeOptions = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
return deps.runtime.channel.reply.formatInboundEnvelope({
const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg as OpenClawConfig);
return formatInboundEnvelope({
channel: "qqbot",
from: event.senderName ?? event.senderId,
timestamp: new Date(event.timestamp).getTime(),
body: userContent,
...(imageUrls.length > 0 ? { imageUrls } : {}),
chatType: isGroupChat ? "group" : "direct",
sender: { id: event.senderId, name: event.senderName },
envelope: envelopeOptions,
...(imageUrls.length > 0 ? { imageUrls } : {}),
});
}
+1 -9
View File
@@ -87,20 +87,12 @@ export async function dispatchRaftWake(params: {
bodyForAgent: input.textForAgent,
},
});
const storePath = channelRuntime.session.resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
return {
cfg: ctx.cfg,
channel: RAFT_CHANNEL_ID,
accountId: ctx.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: channelRuntime.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
// Raft's bridge only transports wake hints. The agent owns CLI delivery
// after it reads the pending Raft messages, so OpenClaw must not emit a
// duplicate synthetic reply through the channel dispatcher.
+3 -5
View File
@@ -1,5 +1,5 @@
import {
dispatchInboundDirectDmWithRuntime,
dispatchInboundDirectDm,
recordChannelBotPairLoopAndCheckSuppression,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
@@ -235,9 +235,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
});
return;
}
await dispatchInboundDirectDmWithRuntime({
await dispatchInboundDirectDm({
cfg: ctx.cfg,
runtime,
channel: "reef",
channelLabel: "Reef",
accountId: "default",
@@ -293,9 +292,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
async (notice) => {
let resendText = "";
let dispatchFailure: Error | undefined;
await dispatchInboundDirectDmWithRuntime({
await dispatchInboundDirectDm({
cfg: ctx.cfg,
runtime,
channel: "reef",
channelLabel: "Reef",
accountId: "default",
@@ -79,6 +79,38 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
};
});
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
return {
...actual,
runChannelInboundEvent: (params: RunParams) => {
const resolveTurn = params.adapter.resolveTurn;
return actual.runChannelInboundEvent({
...params,
adapter: {
...params.adapter,
resolveTurn: async (input, eventClass, preflight) => {
const resolved = await resolveTurn(input, eventClass, preflight);
if (!("route" in resolved) || !("runDispatch" in resolved)) {
return resolved;
}
const { route, ...turn } = resolved;
return {
...turn,
routeSessionKey: route.sessionKey,
storePath: "/tmp/openclaw/signal-sessions.json",
recordInboundSession: recordInboundSessionMock,
};
},
},
});
},
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
@@ -33,7 +33,6 @@ import {
resolveChannelGroupRequireMention,
} from "openclaw/plugin-sdk/channel-policy";
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
createInternalHookEvent,
@@ -536,12 +535,11 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
raw: entry,
}),
resolveTurn: () => ({
cfg: deps.cfg,
channel: "signal",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession,
record: {
updateLastRoute: !entry.isGroup
? {
+3 -3
View File
@@ -1,5 +1,5 @@
// Slack-private authored text placement after block compilation.
import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import type { LegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export type SlackAuthoredTextPlacement = "none" | "blocks" | "outside-blocks";
@@ -10,7 +10,7 @@ function normalizeComparableSlackText(text: string): string {
function isSlackAuthoredTextRepresentedInInteractive(
text: string,
interactive?: InteractiveReply,
interactive?: LegacyInteractiveReply,
): boolean {
return isSlackAuthoredTextRepresentedInFragments(
text,
@@ -43,7 +43,7 @@ function isSlackAuthoredTextRepresentedInFragments(
/** Resolve placement from producer facts, before accessibility text changes the payload text. */
export function resolveSlackAuthoredTextPlacement(params: {
text?: string;
interactive?: InteractiveReply;
interactive?: LegacyInteractiveReply;
renderedInBlocks?: boolean;
renderedTextFragments?: readonly string[];
}): SlackAuthoredTextPlacement {
+4 -4
View File
@@ -2,12 +2,12 @@
import type { Block, KnownBlock } from "@slack/web-api";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
reduceInteractiveReply,
reduceLegacyInteractiveReply,
resolveMessagePresentationButtonAction,
resolveMessagePresentationOptionAction,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveReply,
LegacyInteractiveReply,
MessagePresentation,
MessagePresentationAction,
MessagePresentationButtonsBlock,
@@ -228,7 +228,7 @@ export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackB
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
*/
export function buildSlackInteractiveBlocks(
interactive?: InteractiveReply,
interactive?: LegacyInteractiveReply,
options: SlackBlockRenderOptions = {},
): SlackBlock[] {
const initialState = {
@@ -236,7 +236,7 @@ export function buildSlackInteractiveBlocks(
buttonIndex: options.buttonIndexOffset ?? 0,
selectIndex: options.selectIndexOffset ?? 0,
};
return reduceInteractiveReply(interactive, initialState, (state, block) => {
return reduceLegacyInteractiveReply(interactive, initialState, (state, block) => {
if (block.type === "text") {
const trimmed = block.text.trim();
if (!trimmed) {
+18 -17
View File
@@ -2,7 +2,7 @@
//
// Drives the real dispatch wiring (dispatchPreparedSlackMessage → deliverSlackPayload
// → native stream / draft preview / preview finalize / deliverReplies → sendMessageSlack)
// with the core agent turn mocked at the dispatchReplyWithBufferedBlockDispatcher seam:
// with the core agent turn mocked at the channel-inbound dispatch seam:
// the scripted steps stand in for the reply dispatcher callbacks (typing, partials,
// tool progress, per-payload deliver). OUT events are the Slack Web API calls observed
// at a recording WebClient stand-in. Native streaming runs through the REAL
@@ -90,33 +90,35 @@ const traceState = vi.hoisted(
// deliver/typing/replyOptions wiring (dedupe, thread plan, native stream ladder,
// draft preview, preview finalize, deliverReplies chunking, sendMessageSlack)
// stays the real production code.
vi.mock("./monitor/reply.runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./monitor/reply.runtime.js")>();
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: async (params: {
dispatcherOptions: unknown;
replyOptions?: unknown;
}) => {
dispatchChannelInboundTurn: async (params: DispatchParams) => {
traceState.turn = {
options: params.dispatcherOptions as CapturedDispatcherOptions,
options: {
...params.dispatcherOptions,
deliver: params.delivery.deliver,
onError: params.delivery.onError,
} as CapturedDispatcherOptions,
replyOptions: (params.replyOptions ?? {}) as CapturedReplyOptions,
};
traceState.turnStarted?.resolve();
if (!traceState.turnOutcome) {
throw new Error("trace turn outcome gate not initialized");
}
return await traceState.turnOutcome.promise;
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: params.ctxPayload,
routeSessionKey: params.route.sessionKey,
dispatchResult: await traceState.turnOutcome.promise,
};
},
};
});
// Session-store recording is not wire behavior; keep the turn hermetic.
vi.mock("./monitor/conversation.runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./monitor/conversation.runtime.js")>();
return { ...actual, recordInboundSession: async () => {} };
});
// send.ts/actions.ts build their own WebClient from tokens; route every client
// resolution to the scenario's recording client so all wire calls are captured.
vi.mock("./client.js", async (importOriginal) => {
@@ -138,8 +140,7 @@ vi.mock("./client.js", async (importOriginal) => {
import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js";
afterAll(() => {
vi.doUnmock("./monitor/reply.runtime.js");
vi.doUnmock("./monitor/conversation.runtime.js");
vi.doUnmock("openclaw/plugin-sdk/channel-inbound");
vi.doUnmock("./client.js");
vi.resetModules();
});
@@ -5,7 +5,7 @@ import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import {
normalizeInteractiveReply,
normalizeLegacyInteractiveReply,
normalizeMessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
@@ -88,7 +88,7 @@ export async function handleSlackMessageAction(params: {
});
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
const presentation = normalizeMessagePresentation(actionParams.presentation);
const interactive = normalizeInteractiveReply(actionParams.interactive);
const interactive = normalizeLegacyInteractiveReply(actionParams.interactive);
const hasStructuredContent = Boolean(presentation || interactive?.blocks.length);
const resolution = resolveSlackReplyBlockResolution(
{
+6 -14
View File
@@ -295,23 +295,16 @@ vi.mock("./monitor/config.runtime.js", async () => {
};
});
vi.mock("./monitor/reply.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./monitor/reply.runtime.js")>(
"./monitor/reply.runtime.js",
);
type BufferedDispatchParams = Parameters<
typeof actual.dispatchReplyWithBufferedBlockDispatcher
>[0];
type ReplyResolver = NonNullable<BufferedDispatchParams["replyResolver"]>;
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
type ReplyResolver = NonNullable<DispatchParams["replyResolver"]>;
const replyResolver: ReplyResolver = (...args) =>
slackTestState.replyMock(...args) as ReturnType<ReplyResolver>;
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: (params: BufferedDispatchParams) =>
actual.dispatchReplyWithBufferedBlockDispatcher({
...params,
replyResolver,
}),
dispatchChannelInboundTurn: (params: DispatchParams) =>
actual.dispatchChannelInboundTurn({ ...params, replyResolver }),
};
});
@@ -349,7 +342,6 @@ vi.mock("./monitor/conversation.runtime.js", async () => {
...actual,
readChannelAllowFromStore: (...args: unknown[]) =>
slackTestState.readAllowFromStoreMock(...args),
recordInboundSession: vi.fn().mockResolvedValue(undefined),
upsertChannelPairingRequest: (...args: unknown[]) =>
slackTestState.upsertPairingRequestMock(...args),
};
@@ -2,7 +2,6 @@
export {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
recordInboundSession,
resolveConversationLabel,
resolvePluginConversationBindingApproval,
upsertChannelPairingRequest,
@@ -1,4 +1,5 @@
// Slack tests cover dispatch.preview fallback plugin behavior.
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const FINAL_REPLY_TEXT = "final answer";
@@ -13,7 +14,6 @@ const finalizeSlackPreviewEditMock = vi.fn(async () => {});
const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim());
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const recordInboundSessionMock = vi.fn(async () => undefined);
const recordSlackThreadParticipationMock = vi.fn();
const updateLastRouteMock = vi.fn(async () => {});
const appendSlackStreamMock = vi.fn(async () => {});
@@ -518,10 +518,6 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
removeAckReactionAfterReply: () => {},
}));
vi.mock("../conversation.runtime.js", () => ({
recordInboundSession: recordInboundSessionMock,
}));
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
return {
@@ -1018,109 +1014,13 @@ vi.mock("../replies.js", () => ({
resolveSlackThreadTs: () => mockedReplyThreadTs,
}));
vi.mock("../reply.runtime.js", () => ({
createReplyDispatcherWithTyping: (params: {
transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null;
beforeDeliver?: (
payload: TestReplyPayload,
info: { kind: TestReplyDispatchKind },
) => Promise<TestReplyPayload | null> | TestReplyPayload | null;
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>;
}) => ({
dispatcher: {
deliver: async (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => {
const transformed = params.transformReplyPayload
? params.transformReplyPayload(payload)
: payload;
if (!transformed) {
return;
}
const deliverPayload = params.beforeDeliver
? await params.beforeDeliver(transformed, info)
: transformed;
if (!deliverPayload) {
return;
}
mockedQueuedDispatchCounts[info.kind] += 1;
await params.deliver(deliverPayload, info);
},
},
replyOptions: {},
markDispatchIdle: () => {},
}),
dispatchReplyWithBufferedBlockDispatcher: async (params: {
dispatcherOptions: {
transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null;
beforeDeliver?: (
payload: TestReplyPayload,
info: { kind: TestReplyDispatchKind },
) => Promise<TestReplyPayload | null> | TestReplyPayload | null;
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>;
};
replyOptions?: {
disableBlockStreaming?: boolean;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
suppressTyping?: boolean;
suppressDefaultToolProgressMessages?: boolean;
onItemEvent?: (payload: {
kind?: string;
itemId?: string;
toolCallId?: string;
progressText?: string;
summary?: string;
title?: string;
name?: string;
phase?: string;
status?: string;
meta?: string;
}) => Promise<void> | void;
onCommandOutput?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
status?: string;
exitCode?: number | null;
}) => Promise<void> | void;
onToolStart?: (payload: {
itemId?: string;
toolCallId?: string;
name: string;
phase?: string;
args?: Record<string, unknown>;
detailMode?: "explain" | "raw";
}) => Promise<void> | void;
onPatchSummary?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
summary?: string;
}) => Promise<void> | void;
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
steps?: Array<{
step: string;
status: "pending" | "in_progress" | "completed";
}>;
}) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onReasoningStream?: (payload?: {
text?: string;
isReasoningSnapshot?: boolean;
}) => Promise<void> | void;
onPartialReply?: (payload: { text: string }) => Promise<void> | void;
onQueuedFollowupAdmitted?: () => Promise<void> | void;
};
}) => {
capturedReplyOptions = params.replyOptions;
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
return {
...actual,
dispatchChannelInboundTurn: async (params: DispatchParams) => {
capturedReplyOptions = params.replyOptions as typeof capturedReplyOptions;
if (mockedReplyOptionEvents.length > 0) {
for (const entry of mockedReplyOptionEvents) {
if (entry.kind === "item") {
@@ -1206,13 +1106,14 @@ vi.mock("../reply.runtime.js", () => ({
await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText });
continue;
}
const transformed = params.dispatcherOptions.transformReplyPayload
? params.dispatcherOptions.transformReplyPayload(entry.payload)
: entry.payload;
const payload = entry.payload as ReplyPayload;
const transformed = params.dispatcherOptions?.transformReplyPayload
? params.dispatcherOptions.transformReplyPayload(payload)
: payload;
if (!transformed) {
continue;
}
const deliverPayload = params.dispatcherOptions.beforeDeliver
const deliverPayload = params.dispatcherOptions?.beforeDeliver
? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind })
: transformed;
if (!deliverPayload) {
@@ -1220,7 +1121,7 @@ vi.mock("../reply.runtime.js", () => ({
}
mockedQueuedDispatchCounts[entry.kind] += 1;
try {
await params.dispatcherOptions.deliver(deliverPayload, { kind: entry.kind });
await params.delivery.deliver(deliverPayload, { kind: entry.kind });
} catch (error) {
if (!mockedDispatcherCapturesDeliveryErrors) {
throw error;
@@ -1229,150 +1130,18 @@ vi.mock("../reply.runtime.js", () => ({
}
}
return {
admission: { kind: "dispatch" } as const,
dispatched: true as const,
ctxPayload: params.ctxPayload,
routeSessionKey: params.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { ...mockedQueuedDispatchCounts },
},
};
},
dispatchInboundMessage: async (params: {
replyOptions?: {
disableBlockStreaming?: boolean;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
suppressTyping?: boolean;
suppressDefaultToolProgressMessages?: boolean;
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onReasoningStream?: (payload?: {
text?: string;
isReasoningSnapshot?: boolean;
}) => Promise<void> | void;
onItemEvent?: (payload: {
kind?: string;
itemId?: string;
progressText?: string;
summary?: string;
title?: string;
name?: string;
phase?: string;
status?: string;
meta?: string;
}) => Promise<void> | void;
onToolStart?: (payload: {
itemId?: string;
toolCallId?: string;
name: string;
phase?: string;
args?: Record<string, unknown>;
detailMode?: "explain" | "raw";
}) => Promise<void> | void;
onPatchSummary?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
summary?: string;
}) => Promise<void> | void;
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
steps?: Array<{
step: string;
status: "pending" | "in_progress" | "completed";
}>;
}) => Promise<void> | void;
onPartialReply?: (payload: { text: string }) => Promise<void> | void;
onQueuedFollowupAdmitted?: () => Promise<void> | void;
};
dispatcher: {
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>;
};
}) => {
capturedReplyOptions = params.replyOptions;
if (mockedReplyOptionEvents.length > 0) {
for (const entry of mockedReplyOptionEvents) {
if (entry.kind === "item") {
await params.replyOptions?.onItemEvent?.({
kind: entry.itemKind,
itemId: entry.itemId,
progressText: entry.progressText,
summary: entry.summary,
title: entry.title,
name: entry.name,
phase: entry.phase,
status: entry.status,
meta: entry.meta,
});
} else if (entry.kind === "tool_start") {
await params.replyOptions?.onToolStart?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
name: entry.name,
phase: entry.phase,
args: entry.args,
detailMode: entry.detailMode,
});
} else if (entry.kind === "patch") {
await params.replyOptions?.onPatchSummary?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
phase: entry.phase,
title: entry.title,
name: entry.name,
added: entry.added,
modified: entry.modified,
deleted: entry.deleted,
summary: entry.summary,
});
} else if (entry.kind === "plan") {
await params.replyOptions?.onPlanUpdate?.({
phase: entry.phase,
explanation: entry.explanation,
steps: entry.steps,
});
} else if (entry.kind === "concurrent_items") {
await Promise.all(
entry.progressTexts.map((progressText) =>
Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })),
),
);
} else if (entry.kind === "partial") {
await params.replyOptions?.onPartialReply?.({ text: entry.text });
} else if (entry.kind === "assistant_start") {
await params.replyOptions?.onAssistantMessageStart?.();
} else if (entry.kind === "reasoning") {
await params.replyOptions?.onReasoningStream?.({
text: entry.text,
isReasoningSnapshot: entry.isReasoningSnapshot,
});
} else {
await params.replyOptions?.onReasoningEnd?.();
}
}
} else {
for (const progressText of mockedProgressEvents) {
await params.replyOptions?.onItemEvent?.({ progressText });
}
}
for (const entry of mockedDispatchSequence) {
if (entry.kind === "queued_followup") {
await params.replyOptions?.onQueuedFollowupAdmitted?.();
continue;
}
if (entry.kind === "item") {
await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText });
continue;
}
await params.dispatcher.deliver(entry.payload, { kind: entry.kind });
}
return {
queuedFinal: false,
counts: { ...mockedQueuedDispatchCounts },
};
},
}));
vi.mock("./preview-finalize.js", () => ({
finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock,
@@ -1392,7 +1161,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
normalizeSlackOutboundTextMock.mockClear();
postMessageMock.mockClear();
chatUpdateMock.mockClear();
recordInboundSessionMock.mockReset();
recordSlackThreadParticipationMock.mockReset();
updateLastRouteMock.mockReset();
appendSlackStreamMock.mockReset();
@@ -1490,168 +1258,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS });
});
it("passes accepted Slack bot messages through the shared bot loop guard", async () => {
const base = {
cfg: {
channels: {
defaults: {
botLoopProtection: {
maxEventsPerWindow: 1,
windowSeconds: 60,
cooldownSeconds: 60,
},
},
},
},
accountConfig: { allowBots: true },
message: {
channel: "C_LOOP_SLACK",
bot_id: "B_OTHER",
user: undefined,
},
};
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
...base,
message: {
...base.message,
ts: "900.001",
event_ts: "900.001",
},
}),
);
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
...base,
message: {
...base.message,
ts: "900.002",
event_ts: "900.002",
},
}),
);
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
});
it("restores Slack status reactions when bot loop protection drops a turn", async () => {
const base = {
cfg: {
messages: {
statusReactions: { enabled: true },
},
channels: {
defaults: {
botLoopProtection: {
maxEventsPerWindow: 1,
windowSeconds: 60,
cooldownSeconds: 60,
},
},
},
},
accountConfig: { allowBots: true },
message: {
channel: "C_LOOP_SLACK_STATUS",
bot_id: "B_OTHER",
user: undefined,
},
};
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
...base,
message: {
...base.message,
ts: "910.001",
event_ts: "910.001",
},
}),
);
for (const value of Object.values(statusReactionControllerMock)) {
value.mockClear();
}
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
...base,
ackReactionMessageTs: "910.002",
ackReactionPromise: Promise.resolve(true),
message: {
...base.message,
ts: "910.002",
event_ts: "910.002",
},
}),
);
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expect(statusReactionControllerMock.setQueued).toHaveBeenCalledTimes(1);
expect(statusReactionControllerMock.restoreInitial).toHaveBeenCalledTimes(1);
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
});
it("layers Slack channel bot loop overrides over account settings field-by-field", async () => {
const base = {
cfg: {
channels: {
defaults: {
botLoopProtection: {
maxEventsPerWindow: 20,
windowSeconds: 1,
cooldownSeconds: 60,
},
},
},
},
accountConfig: {
allowBots: true,
botLoopProtection: {
windowSeconds: 120,
cooldownSeconds: 240,
},
},
channelConfig: {
botLoopProtection: {
maxEventsPerWindow: 1,
},
},
message: {
channel: "C_LOOP_SLACK_LAYERED",
bot_id: "B_OTHER_LAYERED",
user: undefined,
},
};
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
...base,
message: {
...base.message,
ts: "900.001",
event_ts: "900.001",
},
}),
);
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
...base,
message: {
...base.message,
ts: "961.001",
event_ts: "961.001",
},
}),
);
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
});
it("updates non-main DM last-route metadata on the prepared direct session", async () => {
mockedPinnedMainDmOwner = "U2";
await dispatchPreparedSlackMessage(
@@ -9,7 +9,7 @@ import {
type StatusReactionAdapter,
} from "openclaw/plugin-sdk/channel-feedback";
import {
dispatchChannelInboundReply,
dispatchChannelInboundTurn,
type InboundReplyRecordOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import {
@@ -89,7 +89,6 @@ import { resolveSlackThreadTargets } from "../../threading.js";
import type { SlackMessageEvent } from "../../types.js";
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
import { recordInboundSession } from "../conversation.runtime.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js";
import {
createSlackReplyDeliveryPlan,
@@ -98,7 +97,6 @@ import {
resolveDeliveredSlackReplyThreadTs,
resolveSlackThreadTs,
} from "../replies.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../reply.runtime.js";
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
import { resolveSlackTimestampMs } from "./timestamp.js";
import type { PreparedSlackMessage } from "./types.js";
@@ -2074,16 +2072,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
let queuedFinal = false;
let counts: Partial<Record<ReplyDispatchKind, number>> = {};
try {
const turnResult = await dispatchChannelInboundReply({
const turnResult = await dispatchChannelInboundTurn({
cfg,
channel: "slack",
accountId: route.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath: prepared.turn.storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload: prepared.ctxPayload,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
dispatcherOptions: {
...replyPipeline,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
@@ -1,2 +0,0 @@
// Slack plugin module implements reply behavior.
export { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
+2 -2
View File
@@ -8,7 +8,7 @@ import {
} from "openclaw/plugin-sdk/channel-send-result";
import {
normalizeMessagePresentation,
resolveInteractiveTextFallback,
resolveLegacyInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
@@ -299,7 +299,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
const payload = {
...ctx.payload,
text:
resolveInteractiveTextFallback({
resolveLegacyInteractiveTextFallback({
text: ctx.payload.text,
interactive: ctx.payload.interactive,
}) ?? "",
+4 -2
View File
@@ -46,7 +46,9 @@ function createRuntime() {
messageSid: string;
accountSid: string;
}) => unknown;
resolveTurn: (ingested: unknown) => Promise<{ routeSessionKey: string }>;
resolveTurn: (
ingested: unknown,
) => Promise<{ route: { agentId: string; sessionKey: string } }>;
};
}) => void
>();
@@ -172,6 +174,6 @@ describe("dispatchSmsInboundEvent", () => {
}),
}),
);
expect(turn.routeSessionKey).toBe("agent:main:sms:direct:+15551234567");
expect(turn.route.sessionKey).toBe("agent:main:sms:direct:+15551234567");
});
});
+1 -12
View File
@@ -170,23 +170,12 @@ export async function dispatchSmsInboundEvent(params: {
To: params.msg.to,
},
});
const storePath = params.channelRuntime.session.resolveStorePath(
params.cfg.session?.store,
{
agentId: route.agentId,
},
);
return {
cfg: params.cfg,
channel: CHANNEL_ID,
accountId: params.account.accountId,
agentId: route.agentId,
routeSessionKey: sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey },
ctxPayload,
recordInboundSession: params.channelRuntime.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: () => ({
to: from,
@@ -14,7 +14,7 @@ export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () =
);
export const dispatchReplyWithBufferedBlockDispatcher: Mock<
() => Promise<{ counts: Record<string, number> }>
(_params: unknown) => Promise<{ counts: Record<string, number> }>
> = vi.fn().mockResolvedValue({ counts: {} });
export const finalizeInboundContextMock: Mock<
(ctx: Record<string, unknown>) => Record<string, unknown>
@@ -152,7 +152,7 @@ vi.mock("./runtime.js", () => ({
kind: "message",
canStartAgentTurn: true,
});
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
ctx: resolved.ctxPayload,
cfg: mockRuntimeConfig,
dispatcherOptions: {
@@ -166,7 +166,7 @@ vi.mock("./runtime.js", () => ({
dispatched: true,
dispatchResult,
ctxPayload: resolved.ctxPayload,
routeSessionKey: resolved.routeSessionKey,
routeSessionKey: resolved.route.sessionKey,
};
}),
buildContext: buildChannelInboundEventContextMock,
@@ -125,20 +125,15 @@ export async function dispatchSynologyChatInboundEvent(params: {
CommandAuthorized: params.msg.commandAuthorized,
},
});
const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, {
agentId: resolved.route.agentId,
});
return {
cfg: currentCfg,
channel: CHANNEL_ID,
accountId: params.account.accountId,
route: {
agentId: resolved.route.agentId,
routeSessionKey: resolved.route.sessionKey,
storePath,
sessionKey: resolved.route.sessionKey,
},
ctxPayload: msgCtx,
recordInboundSession: resolved.rt.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: () => ({
to: sendUserId,
@@ -95,12 +95,14 @@ export async function runTelegramDispatchTurn(params: {
raw: context,
}),
resolveTurn: () => ({
cfg: params.cfg,
channel: "telegram",
accountId: context.route.accountId,
routeSessionKey: context.route.sessionKey,
storePath: context.turn.storePath,
route: {
agentId: context.route.agentId,
sessionKey: context.route.sessionKey,
},
ctxPayload: context.ctxPayload,
recordInboundSession: context.turn.recordInboundSession,
record: context.turn.record,
runDispatch: () =>
params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
@@ -165,6 +165,43 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
type TestTurn = {
storePath: string;
recordInboundSession: Parameters<
typeof actual.runPreparedInboundReply
>[0]["recordInboundSession"];
};
return {
...actual,
runChannelInboundEvent: (params: RunParams) => {
const resolveTurn = params.adapter.resolveTurn;
return actual.runChannelInboundEvent({
...params,
adapter: {
...params.adapter,
resolveTurn: async (input, eventClass, preflight) => {
const resolved = await resolveTurn(input, eventClass, preflight);
if (!("route" in resolved) || !("runDispatch" in resolved)) {
return resolved;
}
const { route, ...turn } = resolved;
const testTurn = (params.raw as { turn: TestTurn }).turn;
return {
...turn,
routeSessionKey: route.sessionKey,
storePath: testTurn.storePath,
recordInboundSession: testTurn.recordInboundSession,
};
},
},
});
},
};
});
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
+6 -6
View File
@@ -1,12 +1,12 @@
// Telegram plugin module implements button types behavior.
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import { reduceLegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import {
isMessagePresentationInteractiveBlock,
normalizeMessagePresentation,
normalizeInteractiveReply,
normalizeLegacyInteractiveReply,
resolveMessagePresentationButtonAction,
type InteractiveReply,
type LegacyInteractiveReply,
type MessagePresentation,
type MessagePresentationButton,
} from "openclaw/plugin-sdk/interactive-runtime";
@@ -100,9 +100,9 @@ function chunkInteractiveButtons(
* @deprecated Use buildTelegramPresentationButtons with MessagePresentation.
*/
function buildTelegramInteractiveButtons(
interactive?: InteractiveReply,
interactive?: LegacyInteractiveReply,
): TelegramInlineButtons | undefined {
const rows = reduceInteractiveReply(
const rows = reduceLegacyInteractiveReply(
interactive,
[] as TelegramInlineButton[][],
(state, block) => {
@@ -159,7 +159,7 @@ export function resolveTelegramInlineButtons(params: {
}): TelegramInlineButtons | undefined {
return (
params.buttons ??
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) ??
buildTelegramInteractiveButtons(normalizeLegacyInteractiveReply(params.interactive)) ??
buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation))
);
}
@@ -1,12 +1,12 @@
// Telegram plugin module implements interactive fallback behavior.
import {
adaptMessagePresentationForChannel,
interactiveReplyToPresentation,
legacyInteractiveReplyToPresentation,
isMessagePresentationInteractiveBlock,
normalizeMessagePresentation,
normalizeInteractiveReply,
normalizeLegacyInteractiveReply,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
resolveLegacyInteractiveTextFallback,
type MessagePresentation,
type MessagePresentationInteractiveBlock,
} from "openclaw/plugin-sdk/interactive-runtime";
@@ -122,7 +122,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
capabilities: TELEGRAM_PRESENTATION_CAPABILITIES,
});
const interactive = normalizeInteractiveReply(payload.interactive);
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
const existingButtons = resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
interactive,
@@ -141,7 +141,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
presentation: { ...presentation, blocks: fallbackBlocks },
});
const currentText =
resolveInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
resolveLegacyInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
const hasFallback =
fallbackText.length > 0 &&
(currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`));
@@ -168,8 +168,8 @@ export function resolveTelegramInteractiveTextFallback(params: {
interactive?: unknown;
presentation?: unknown;
}): string | undefined {
const interactive = normalizeInteractiveReply(params.interactive);
const text = resolveInteractiveTextFallback({
const interactive = normalizeLegacyInteractiveReply(params.interactive);
const text = resolveLegacyInteractiveTextFallback({
text: params.text ?? undefined,
interactive,
});
@@ -189,7 +189,7 @@ export function resolveTelegramInteractiveTextFallback(params: {
if (!interactive) {
return text;
}
const interactivePresentation = interactiveReplyToPresentation(interactive);
const interactivePresentation = legacyInteractiveReplyToPresentation(interactive);
if (!interactivePresentation) {
return text;
}
+2 -2
View File
@@ -1,5 +1,5 @@
// Telegram plugin module implements voice behavior.
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
function resolveTelegramVoiceDecision(opts: {
wantsVoice: boolean;
@@ -9,7 +9,7 @@ function resolveTelegramVoiceDecision(opts: {
if (!opts.wantsVoice) {
return { useVoice: false };
}
if (isVoiceCompatibleAudio(opts)) {
if (isVoiceMessageCompatibleAudio(opts)) {
return { useVoice: true };
}
const contentType = opts.contentType ?? "unknown";
+6 -13
View File
@@ -1,4 +1,5 @@
// Tlon plugin entrypoint registers its OpenClaw integration.
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
@@ -502,7 +503,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
bodyWithAttachments = mediaLines + "\n" + messageText;
}
const body = core.channel.reply.formatAgentEnvelope({
const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
channel: "Tlon",
from: fromLabel,
timestamp,
@@ -559,10 +560,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
cfg,
route.agentId,
).responsePrefix;
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const humanDelay = resolveHumanDelayConfig(cfg, route.agentId);
const deliveryTarget = isGroup ? groupChannel : senderShip;
const prepareReplyPayload = (payload: ReplyPayload): ReplyPayload => {
@@ -596,17 +594,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
};
await core.channel.inbound.dispatchReply({
await core.channel.inbound.dispatch({
channel: "tlon",
accountId: route.accountId,
cfg,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: prepareReplyPayload,
durable: deliveryTarget
+3 -11
View File
@@ -5,6 +5,7 @@
* resolves agent routes, and handles replies.
*/
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
@@ -75,11 +76,10 @@ async function processTwitchMessage(params: {
});
const senderId = message.userId ?? message.username;
const fromLabel = message.displayName ?? message.username;
const body = core.channel.reply.formatAgentEnvelope({
const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
channel: "Twitch",
from: fromLabel,
timestamp: input.timestamp,
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
body: input.rawText,
});
const ctxPayload = core.channel.inbound.buildContext({
@@ -113,9 +113,6 @@ async function processTwitchMessage(params: {
commandBody: input.textForCommands,
},
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "twitch",
@@ -125,13 +122,8 @@ async function processTwitchMessage(params: {
cfg,
channel: "twitch",
accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
durable: () => ({
to: `twitch:channel:${message.channel}`,
@@ -5,7 +5,6 @@ import {
type AckReactionHandle,
} from "openclaw/plugin-sdk/channel-feedback";
import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
createInternalHookEvent,
deriveInboundMessageHookContext,
@@ -544,12 +543,11 @@ export async function processMessage(params: {
};
},
resolveTurn: () => ({
cfg: params.cfg,
channel: "whatsapp",
accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey,
storePath,
route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey },
ctxPayload,
recordInboundSession,
record: {
onRecordError: (err) => {
params.replyLogger.warn(
-1
View File
@@ -53,7 +53,6 @@ export {
type ReplyPayload,
resolveClientIp,
resolveDefaultGroupPolicy,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveOpenProviderRuntimeGroupPolicy,
resolveWebhookPath,
resolveWebhookTargetWithAuthOrRejectSync,
@@ -106,14 +106,6 @@ function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean):
describe("Zalo polling media replies", () => {
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const recordInboundSessionMock = vi.fn(async () => undefined);
const resolveAgentRouteMock = vi.fn(() => ({
agentId: "main",
channel: "zalo",
accountId: "acct-zalo-polling-media",
sessionKey: "agent:main:zalo:direct:dm-chat-1",
mainSessionKey: "agent:main:main",
matchedBy: "default",
}));
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
beforeAll(async () => {
@@ -143,10 +135,6 @@ describe("Zalo polling media replies", () => {
);
setLifecycleRuntimeCore(
{
routing: {
resolveAgentRoute:
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
reply: {
finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
@@ -21,14 +21,6 @@ describe("Zalo reply-once lifecycle", () => {
const recordInboundSessionMock = vi.fn(
async (_input: { sessionKey?: string; ctx?: Record<string, unknown> }) => undefined,
);
const resolveAgentRouteMock = vi.fn(() => ({
agentId: "main",
channel: "zalo",
accountId: "acct-zalo-lifecycle",
sessionKey: "agent:main:zalo:direct:dm-chat-1",
mainSessionKey: "agent:main:main",
matchedBy: "default",
}));
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
beforeAll(async () => {
@@ -38,10 +30,6 @@ describe("Zalo reply-once lifecycle", () => {
beforeEach(async () => {
await resetLifecycleTestState();
setLifecycleRuntimeCore({
routing: {
resolveAgentRoute:
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
reply: {
finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
@@ -60,10 +48,17 @@ describe("Zalo reply-once lifecycle", () => {
});
function createReplyOnceMonitorSetup() {
return createLifecycleMonitorSetup({
const setup = createLifecycleMonitorSetup({
accountId: "acct-zalo-lifecycle",
dmPolicy: "open",
});
return {
...setup,
config: {
...setup.config,
session: { dmScope: "per-channel-peer" as const },
},
};
}
function requireRecordInboundSessionArgs() {
+9 -14
View File
@@ -1,15 +1,17 @@
// Zalo plugin module implements monitor behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound";
import {
formatInboundMediaUnavailableText,
resolveChannelInboundRouteEnvelope,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
type OutboundReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import { sleepWithAbort, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
@@ -568,7 +570,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
const agentBody = agentBodyOverride ?? rawBody;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config,
channel: "zalo",
accountId: account.accountId,
@@ -576,8 +578,6 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
kind: isGroup ? ("group" as const) : ("direct" as const),
id: chatId,
},
runtime: core.channel,
sessionStore: config.session?.store,
});
if (
@@ -591,7 +591,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const timestamp = resolveZaloTimestampMs(date);
const { storePath, body } = buildEnvelope({
const body = buildEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp,
@@ -673,17 +673,12 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
},
};
await core.channel.inbound.dispatchReply({
await core.channel.inbound.dispatch({
cfg: config,
channel: "zalo",
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: (payload) =>
prepareZaloDurableReplyPayload({
-1
View File
@@ -63,7 +63,6 @@ export {
isNumericTargetId,
sendPayloadWithChunkedTextAndMedia,
} from "./runtime-support.js";
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./runtime-support.js";
export { waitForAbortSignal } from "./runtime-support.js";
export {
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,

Some files were not shown because too many files have changed in this diff Show More