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 7e2ff4dedb7b220a133cb419ee91e67585b9b9f2799706f336cdea973be80bfb plugin-sdk-api-baseline.json
660dbaa276792415bf7eb87c19240b19decd400b7e58745e87fe0f1f1cc33353 plugin-sdk-api-baseline.jsonl 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.idleHours`
- `threadBindings.maxAgeHours` - `threadBindings.maxAgeHours`
- `threadBindings.spawnSessions`: gates both subagent and ACP thread spawns. - `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` - `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. 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.
+28 -26
View File
@@ -174,7 +174,7 @@ describe("handleClickClackInbound", () => {
correlationId: "fakeco.case_1", correlationId: "fakeco.case_1",
}); });
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled(); expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0]; const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0];
expect(completionRequest?.agentId).toBe("service-bot"); expect(completionRequest?.agentId).toBe("service-bot");
@@ -270,9 +270,9 @@ describe("handleClickClackInbound", () => {
message: createMessage(), message: createMessage(),
}); });
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchReply).toHaveBeenCalledTimes(1); expect(dispatchTurn).toHaveBeenCalledTimes(1);
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true); expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
}); });
it("propagates account toolsAllow into agent reply dispatch", async () => { it("propagates account toolsAllow into agent reply dispatch", async () => {
@@ -297,9 +297,9 @@ describe("handleClickClackInbound", () => {
message: createMessage(), message: createMessage(),
}); });
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchReply).toHaveBeenCalledTimes(1); expect(dispatchTurn).toHaveBeenCalledTimes(1);
const dispatchParams = dispatchReply.mock.calls[0]?.[0] as const dispatchParams = dispatchTurn.mock.calls[0]?.[0] as
| (Record<string, unknown> & { | (Record<string, unknown> & {
toolsAllow?: unknown; toolsAllow?: unknown;
}) })
@@ -335,12 +335,12 @@ describe("handleClickClackInbound", () => {
}), }),
}); });
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchReply).toHaveBeenCalledTimes(2); expect(dispatchTurn).toHaveBeenCalledTimes(2);
const withoutOptIn = dispatchReply.mock.calls[0]?.[0] as { const withoutOptIn = dispatchTurn.mock.calls[0]?.[0] as {
replyOptions?: { runId?: unknown; onItemEvent?: unknown; onModelSelected?: unknown }; replyOptions?: { runId?: unknown; onItemEvent?: unknown; onModelSelected?: unknown };
}; };
const withOptIn = dispatchReply.mock.calls[1]?.[0] as { const withOptIn = dispatchTurn.mock.calls[1]?.[0] as {
replyOptions?: { replyOptions?: {
onItemEvent?: unknown; onItemEvent?: unknown;
onModelSelected?: unknown; onModelSelected?: unknown;
@@ -377,7 +377,7 @@ describe("handleClickClackInbound", () => {
correlationId: "fakeco.case_2", 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}`); expect(dispatchParams?.replyOptions?.runId).toBe(`clickclack:${VALID_MESSAGE_ID}`);
await dispatchParams?.delivery.deliver({ text: "correlated reply" }, {} as never); 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") { if (typeof delivery?.durable !== "function") {
throw new Error("expected ClickClack media durable delivery resolver"); throw new Error("expected ClickClack media durable delivery resolver");
} }
@@ -437,7 +437,7 @@ describe("handleClickClackInbound", () => {
message: createMessage({ id: "msg_invalid" }), 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, undefined,
); );
}); });
@@ -461,15 +461,15 @@ describe("handleClickClackInbound", () => {
}), }),
config: cfg, config: cfg,
message: createMessage({ message: createMessage({
channel_id: undefined, channel_id: "",
direct_conversation_id: "dcn_1", direct_conversation_id: "dcn_1",
}), }),
}); });
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchReply).toHaveBeenCalledTimes(1); expect(dispatchTurn).toHaveBeenCalledTimes(1);
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct"); expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true); expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
}); });
it("preserves session policy when an account overrides the routed agent", async () => { 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); const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchReply.mock.calls[0]?.[0].routeSessionKey).toBe( expect(dispatchTurn.mock.calls[0]?.[0].route.sessionKey).toBe(
"agent:service-bot:clickclack:direct:alice", "agent:service-bot:clickclack:direct:alice",
); );
expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({ expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({
@@ -546,10 +546,12 @@ describe("handleClickClackInbound", () => {
}), }),
}); });
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchReply.mock.calls[0]?.[0]).toMatchObject({ expect(dispatchTurn.mock.calls[0]?.[0]).toMatchObject({
agentId: "service-bot", route: {
routeSessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner", agentId: "service-bot",
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(); expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
}); });
+47 -46
View File
@@ -1,3 +1,4 @@
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound"; import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
/** /**
* Converts authorized ClickClack messages into OpenClaw agent/model replies and * Converts authorized ClickClack messages into OpenClaw agent/model replies and
@@ -150,6 +151,10 @@ export async function handleClickClackInbound(params: {
if (!access.shouldDispatch) { if (!access.shouldDispatch) {
return; return;
} }
const conversationId = message.channel_id || message.direct_conversation_id;
if (!conversationId) {
return;
}
const isDirect = Boolean(message.direct_conversation_id); const isDirect = Boolean(message.direct_conversation_id);
const target = buildClickClackTarget( const target = buildClickClackTarget(
isDirect isDirect
@@ -200,52 +205,53 @@ export async function handleClickClackInbound(params: {
}); });
} }
const senderName = message.author?.display_name || message.author_id; 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 // Preserve both normalized channel fields and ClickClack-native ids so reply
// routing, session recovery, and command authorization see the same message. // 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", channel: "ClickClack",
from: senderName, from: senderName,
timestamp: new Date(message.created_at), timestamp: new Date(message.created_at),
previousTimestamp,
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
body: message.body, body: message.body,
}); });
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, { const ctxPayload = runtime.channel.inbound.buildContext({
agentId: route.agentId, channel: CHANNEL_ID,
}); accountId: route.accountId ?? params.account.accountId,
const ctxPayload = runtime.channel.reply.finalizeInboundContext({ messageId: message.id,
Body: body, messageIdFull: message.id,
BodyForAgent: message.body, timestamp: new Date(message.created_at).getTime(),
RawBody: message.body, from: target,
CommandBody: message.body, sender: { id: message.author_id, name: senderName },
From: target, conversation: {
To: target, kind: isDirect ? "direct" : "group",
SessionKey: route.sessionKey, id: conversationId,
AccountId: route.accountId ?? params.account.accountId, label: isDirect ? senderName : message.channel_id,
ChatType: isDirect ? "direct" : "group", threadId: message.parent_message_id ? message.thread_root_id : undefined,
WasMentioned: isDirect ? undefined : true, nativeChannelId: conversationId,
ConversationLabel: isDirect ? senderName : message.channel_id, },
GroupChannel: message.channel_id, route: {
NativeChannelId: message.channel_id || message.direct_conversation_id, agentId: route.agentId,
MessageThreadId: message.parent_message_id ? message.thread_root_id : undefined, accountId: route.accountId,
ThreadParentId: message.parent_message_id ? message.thread_root_id : undefined, routeSessionKey: route.sessionKey,
SenderName: senderName, },
SenderId: message.author_id, reply: {
Provider: CHANNEL_ID, to: target,
Surface: CHANNEL_ID, originatingTo: target,
MessageSid: message.id, replyToId: message.id,
MessageSidFull: message.id, messageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
ReplyToId: message.id, threadParentId: message.parent_message_id ? message.thread_root_id : undefined,
Timestamp: message.created_at, },
OriginatingChannel: CHANNEL_ID, message: { body, bodyForAgent: message.body, rawBody: message.body, commandBody: message.body },
OriginatingTo: target, access: {
CommandAuthorized: access.commandAuthorized, commands: { authorized: access.commandAuthorized },
mentions: {
canDetectMention: !isDirect,
wasMentioned: !isDirect,
},
},
extra: { GroupChannel: message.channel_id },
}); });
const runId = resolveClickClackAgentRunId(message.id); const runId = resolveClickClackAgentRunId(message.id);
const activityReplyOptions = activity const activityReplyOptions = activity
@@ -266,17 +272,12 @@ export async function handleClickClackInbound(params: {
allowProgressCallbacksWhenSourceDeliverySuppressed: true, allowProgressCallbacksWhenSourceDeliverySuppressed: true,
} }
: undefined; : undefined;
const dispatchPromise = runtime.channel.inbound.dispatchReply({ const dispatchPromise = runtime.channel.inbound.dispatch({
cfg: params.config as OpenClawConfig, cfg: params.config as OpenClawConfig,
channel: CHANNEL_ID, channel: CHANNEL_ID,
accountId: params.account.accountId, accountId: params.account.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
toolsAllow: params.account.toolsAllow, toolsAllow: params.account.toolsAllow,
// Provenance stamping shares the agentActivity opt-in: with the flag off // Provenance stamping shares the agentActivity opt-in: with the flag off
// the extension's wire payloads stay byte-identical to pre-activity // 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"], delegatedExecutionPluginIds: ["voice-call"],
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
deliveryDefaults: { deliveryDefaults: {
sourceVisibleReplies: "message_tool", visibleReplies: "message_tool",
}, },
authBootstrap: "harness", authBootstrap: "harness",
authBinding: { authBinding: {
+2 -2
View File
@@ -121,7 +121,7 @@ describe("codex plugin", () => {
expect(agentHarnessRegistration.id).toBe("codex"); expect(agentHarnessRegistration.id).toBe("codex");
expect(agentHarnessRegistration.label).toBe("Codex agent harness"); expect(agentHarnessRegistration.label).toBe("Codex agent harness");
expect(agentHarnessRegistration.deliveryDefaults).toEqual({ expect(agentHarnessRegistration.deliveryDefaults).toEqual({
sourceVisibleReplies: "message_tool", visibleReplies: "message_tool",
}); });
expect(typeof agentHarnessRegistration.dispose).toBe("function"); expect(typeof agentHarnessRegistration.dispose).toBe("function");
expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function"); expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function");
@@ -419,7 +419,7 @@ describe("codex plugin", () => {
bindingStore: testCodexAppServerBindingStore, bindingStore: testCodexAppServerBindingStore,
}); });
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool"); expect(harness.deliveryDefaults?.visibleReplies).toBe("message_tool");
expect( expect(
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" }) harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
.supported, .supported,
@@ -153,9 +153,7 @@ export function createCodexAttemptLifecycleController(
startedAt: attemptStartedAt, startedAt: attemptStartedAt,
endedAt: Date.now(), endedAt: Date.now(),
...data, ...data,
...((params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd) ...(params.deferTerminalLifecycle ? { phase: "finishing" } : {}),
? { phase: "finishing" }
: {}),
}, },
}); });
state.lifecycleTerminalEmitted = true; state.lifecycleTerminalEmitted = true;
@@ -51,25 +51,15 @@ setupRunAttemptTestHooks();
describe("runCodexAppServerAttempt hooks and model diagnostics", () => { describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
it.each([ it.each([
{ label: "completed", status: "completed" as const, error: undefined, legacy: false }, { label: "completed", status: "completed" as const, error: undefined },
{ label: "failed", status: "failed" as const, error: "codex exploded", legacy: false }, { label: "failed", status: "failed" as const, error: "codex exploded" },
{ ])("defers $label lifecycle terminal ownership", async ({ status, error }) => {
label: "completed legacy alias",
status: "completed" as const,
error: undefined,
legacy: true,
},
])("defers $label lifecycle terminal ownership", async ({ status, error, legacy }) => {
const onRunAgentEvent = vi.fn(); const onRunAgentEvent = vi.fn();
const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`); const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`);
const workspaceDir = path.join(tempDir, `workspace-${status}`); const workspaceDir = path.join(tempDir, `workspace-${status}`);
const harness = createStartedThreadHarness(); const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir); const params = createParams(sessionFile, workspaceDir);
if (legacy) { params.deferTerminalLifecycle = true;
params.deferTerminalLifecycleEnd = true;
} else {
params.deferTerminalLifecycle = true;
}
params.onAgentEvent = onRunAgentEvent; params.onAgentEvent = onRunAgentEvent;
const run = runCodexAppServerAttempt(params); const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start"); 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 type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { import {
adaptMessagePresentationForChannel, adaptMessagePresentationForChannel,
normalizeInteractiveReply, normalizeLegacyInteractiveReply,
normalizeMessagePresentation, normalizeMessagePresentation,
renderMessagePresentationFallbackText, renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
@@ -153,7 +153,7 @@ export async function handleDiscordMessageAction(
? undefined ? undefined
: (params.components ?? : (params.components ??
presentationComponents ?? presentationComponents ??
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive))); buildDiscordInteractiveComponents(normalizeLegacyInteractiveReply(params.interactive)));
const hasComponents = const hasComponents =
Boolean(rawComponents) && Boolean(rawComponents) &&
(typeof rawComponents === "function" || typeof rawComponents === "object"); (typeof rawComponents === "function" || typeof rawComponents === "object");
@@ -178,11 +178,9 @@ export async function dispatchDiscordComponentEvent(params: {
const { const {
createReplyReferencePlanner, createReplyReferencePlanner,
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext, finalizeInboundContext,
resolveChunkMode, resolveChunkMode,
resolveTextChunkLimit, resolveTextChunkLimit,
recordInboundSession,
} = await (async () => { } = await (async () => {
const conversationRuntime = await loadConversationRuntime(); const conversationRuntime = await loadConversationRuntime();
return { return {
@@ -273,12 +271,8 @@ export async function dispatchDiscordComponentEvent(params: {
cfg: ctx.cfg, cfg: ctx.cfg,
channel: "discord", channel: "discord",
accountId, accountId,
agentId, route: { agentId, sessionKey },
routeSessionKey: sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
record: { record: {
updateLastRoute: interactionCtx.isDirectMessage 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", () => ({ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSession(...args), recordInboundSession: (...args: unknown[]) => recordInboundSession(...args),
resolvePinnedMainDmOwnerFromAllowlist: (params: { resolvePinnedMainDmOwnerFromAllowlist: (params: {
@@ -8,7 +8,7 @@ import {
shouldAckReaction as shouldAckReactionGate, shouldAckReaction as shouldAckReactionGate,
} from "openclaw/plugin-sdk/channel-feedback"; } from "openclaw/plugin-sdk/channel-feedback";
import { import {
dispatchChannelInboundReply, dispatchChannelInboundTurn,
hasFinalInboundReplyDispatch, hasFinalInboundReplyDispatch,
} from "openclaw/plugin-sdk/channel-inbound"; } from "openclaw/plugin-sdk/channel-inbound";
import { import {
@@ -24,8 +24,6 @@ import {
resolveChannelStreamingBlockEnabled, resolveChannelStreamingBlockEnabled,
resolveTranscriptBackedChannelFinalText, resolveTranscriptBackedChannelFinalText,
} from "openclaw/plugin-sdk/channel-outbound"; } 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 { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
@@ -36,7 +34,13 @@ import {
resolveSendableOutboundReplyParts, resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload"; } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; 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 { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
@@ -57,15 +61,11 @@ import {
import { buildDiscordMessageProcessContext } from "./message-handler.context.js"; import { buildDiscordMessageProcessContext } from "./message-handler.context.js";
import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js"; import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { import { completeDiscordSessionConflict } from "./message-handler.retry.js";
completeDiscordSessionConflict,
withDiscordSessionRetry,
} from "./message-handler.retry.js";
import { deliverDiscordReply, formatDiscordReplyDeliveryFailure } from "./reply-delivery.js"; import { deliverDiscordReply, formatDiscordReplyDeliveryFailure } from "./reply-delivery.js";
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js"; import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js"; import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
const loadReplyRuntime = createLazyRuntimeModule(() => import("openclaw/plugin-sdk/reply-runtime"));
const TARGETED_ONLY_ALLOWED_MENTIONS = { const TARGETED_ONLY_ALLOWED_MENTIONS = {
parse: ["users", "roles"], parse: ["users", "roles"],
} as APIAllowedMentions; } as APIAllowedMentions;
@@ -185,7 +185,6 @@ async function processDiscordMessageInner(
if (boundThreadId && typeof threadBindings.touchThread === "function") { if (boundThreadId && typeof threadBindings.touchThread === "function") {
threadBindings.touchThread({ threadId: boundThreadId }); threadBindings.touchThread({ threadId: boundThreadId });
} }
const { dispatchReplyWithBufferedBlockDispatcher: dispatchReply } = await loadReplyRuntime();
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({ const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
cfg, cfg,
ctx: { ctx: {
@@ -991,7 +990,11 @@ async function processDiscordMessageInner(
); );
}; };
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig); 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 dispatchError = false;
let dispatchAborted = false; let dispatchAborted = false;
const deliverPendingToolWarningFinalIfNeeded = async () => { const deliverPendingToolWarningFinalIfNeeded = async () => {
@@ -1015,17 +1018,18 @@ async function processDiscordMessageInner(
dispatchAborted = true; dispatchAborted = true;
return; return;
} }
const preparedResult = await dispatchChannelInboundReply({ const preparedResult = await dispatchChannelInboundTurn({
cfg, cfg,
channel: "discord", channel: "discord",
accountId: route.accountId, accountId: route.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: persistedSessionKey },
routeSessionKey: persistedSessionKey,
storePath: turn.storePath,
ctxPayload, ctxPayload,
recordInboundSession,
afterRecord: queueInitialAckReactionAfterRecord, afterRecord: queueInitialAckReactionAfterRecord,
dispatchReplyWithBufferedBlockDispatcher: withDiscordSessionRetry(dispatchReply, abortSignal), sessionInitRetry: {
delaysMs: [250, 1_000, 2_500],
signal: abortSignal,
sleep: sleepWithAbort,
},
dispatcherOptions: { dispatcherOptions: {
...replyPipeline, ...replyPipeline,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId), 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"; import { DiscordRetryableInboundError } from "./inbound-dedupe.js";
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /^reply session initialization conflicted for \S+$/u; 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 = const DISCORD_SESSION_CONFLICT_FAILURE_TEXT =
"⚠️ Couldn't process this message because the session stayed busy. Please try again in a moment."; "⚠️ 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 = ( type TerminalFailureDelivery = (
payload: { text: string; isError: true }, payload: { text: string; isError: true },
info: { kind: "final" }, info: { kind: "final" },
@@ -19,63 +15,12 @@ function isReplySessionInitConflictError(error: unknown): boolean {
return REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(message); 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( export async function completeDiscordSessionConflict(
error: unknown, error: unknown,
deliver: TerminalFailureDelivery, deliver: TerminalFailureDelivery,
onDeliveryError: DeliveryErrorHandler, onDeliveryError: DeliveryErrorHandler,
): Promise<boolean> { ): Promise<boolean> {
if (!(error instanceof DiscordReplySessionConflictExhaustedError)) { if (!isReplySessionInitConflictError(error)) {
return false; return false;
} }
try { try {
@@ -87,7 +32,10 @@ export async function completeDiscordSessionConflict(
} catch (deliveryError) { } catch (deliveryError) {
// Keep the conflict retryable when its visible terminal notice cannot land. // Keep the conflict retryable when its visible terminal notice cannot land.
onDeliveryError(deliveryError, { kind: "final" }); 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. // Discord plugin module implements shared interactive behavior.
import { import {
reduceInteractiveReply, reduceLegacyInteractiveReply,
resolveMessagePresentationButtonAction, resolveMessagePresentationButtonAction,
resolveMessagePresentationOptionAction, resolveMessagePresentationOptionAction,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
import type { import type {
InteractiveButtonStyle, InteractiveButtonStyle,
InteractiveReply, LegacyInteractiveReply,
MessagePresentation, MessagePresentation,
MessagePresentationButton, MessagePresentationButton,
MessagePresentationOption, MessagePresentationOption,
@@ -141,9 +141,9 @@ function appendDiscordButtonBlocks(
* @deprecated Use buildDiscordPresentationComponents with MessagePresentation. * @deprecated Use buildDiscordPresentationComponents with MessagePresentation.
*/ */
export function buildDiscordInteractiveComponents( export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply, interactive?: LegacyInteractiveReply,
): DiscordComponentMessageSpec | undefined { ): DiscordComponentMessageSpec | undefined {
const blocks = reduceInteractiveReply( const blocks = reduceLegacyInteractiveReply(
interactive, interactive,
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>, [] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
(state, block) => { (state, block) => {
+101 -92
View File
@@ -1,5 +1,4 @@
// Feishu tests cover bot.broadcast plugin behavior. // 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 { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
import { feishuGroupNameCache } from "./bot-group-name-state.js"; import { feishuGroupNameCache } from "./bot-group-name-state.js";
@@ -7,25 +6,71 @@ import type { FeishuMessageEvent } from "./bot.js";
import { handleFeishuMessage } from "./bot.js"; import { handleFeishuMessage } from "./bot.js";
import { setFeishuRuntime } from "./runtime.js"; import { setFeishuRuntime } from "./runtime.js";
const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } = const {
vi.hoisted(() => ({ builtInboundContextCalls,
mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({ mockCreateFeishuReplyDispatcher,
dispatcher: { mockCreateFeishuClient,
sendToolResult: vi.fn(), mockDispatchInboundMessage,
sendBlockReply: vi.fn(), mockRecordInboundSession,
sendFinalReply: vi.fn(), mockResolveAgentRoute,
waitForIdle: vi.fn(), mockResolveStorePath,
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), } = vi.hoisted(() => ({
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), builtInboundContextCalls: [] as Array<Record<string, unknown>>,
markComplete: vi.fn(), mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({
}, dispatcher: {
replyOptions: {}, sendToolResult: vi.fn(),
markDispatchIdle: vi.fn(), sendBlockReply: vi.fn(),
ensureNoVisibleReplyFallback: vi.fn(), sendFinalReply: vi.fn(),
})), waitForIdle: vi.fn(),
mockCreateFeishuClient: vi.fn(), getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
mockResolveAgentRoute: vi.fn(), getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
})); markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
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", () => ({ vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher, createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
@@ -48,43 +93,7 @@ function createRuntimeEnv() {
} }
describe("broadcast dispatch", () => { describe("broadcast dispatch", () => {
const finalizeInboundContextCalls: Array<Record<string, unknown>> = [];
const mockGetChatInfo = vi.fn(); 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 mockShouldComputeCommandAuthorized = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/inbound-clip.mp4", path: "/tmp/inbound-clip.mp4",
@@ -99,18 +108,10 @@ describe("broadcast dispatch", () => {
resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params), resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
}, },
session: { session: {
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"), resolveStorePath: mockResolveStorePath,
recordInboundSession: vi.fn().mockResolvedValue(undefined), recordInboundSession: mockRecordInboundSession,
},
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"],
}, },
reply: {},
commands: { commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false), resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
@@ -135,9 +136,13 @@ describe("broadcast dispatch", () => {
if (!("runDispatch" in turn)) { if (!("runDispatch" in turn)) {
throw new Error("feishu broadcast test runtime only supports prepared turns"); throw new Error("feishu broadcast test runtime only supports prepared turns");
} }
await turn.recordInboundSession({ const routeSessionKey = "route" in turn ? turn.route.sessionKey : turn.routeSessionKey;
storePath: turn.storePath, const storePath = "storePath" in turn ? turn.storePath : mockResolveStorePath();
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, const recordInboundSession =
"recordInboundSession" in turn ? turn.recordInboundSession : mockRecordInboundSession;
await recordInboundSession({
storePath,
sessionKey: turn.ctxPayload.SessionKey ?? routeSessionKey,
ctx: turn.ctxPayload, ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution, groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing, createIfMissing: turn.record?.createIfMissing,
@@ -148,7 +153,7 @@ describe("broadcast dispatch", () => {
admission: { kind: "dispatch" as const }, admission: { kind: "dispatch" as const },
dispatched: true, dispatched: true,
ctxPayload: turn.ctxPayload, ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey, routeSessionKey,
dispatchResult: await turn.runDispatch(), dispatchResult: await turn.runDispatch(),
}; };
}), }),
@@ -219,8 +224,12 @@ describe("broadcast dispatch", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockDispatchInboundMessage.mockReset().mockResolvedValue({
queuedFinal: false,
counts: { final: 1 },
});
feishuGroupNameCache.clear(); feishuGroupNameCache.clear();
finalizeInboundContextCalls.length = 0; builtInboundContextCalls.length = 0;
mockResolveAgentRoute.mockReturnValue({ mockResolveAgentRoute.mockReturnValue({
agentId: "main", agentId: "main",
channel: "feishu", channel: "feishu",
@@ -277,8 +286,8 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(), runtime: createRuntimeEnv(),
}); });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey); const sessionKeys = builtInboundContextCalls.map((call) => call.SessionKey);
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group"); expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group"); expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
const recordCalls = ( const recordCalls = (
@@ -320,7 +329,7 @@ describe("broadcast dispatch", () => {
]); ]);
expect(mockGetChatInfo).toHaveBeenCalledTimes(1); expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
expect( expect(
finalizeInboundContextCalls builtInboundContextCalls
.map((call) => ({ .map((call) => ({
sessionKey: call.SessionKey, sessionKey: call.SessionKey,
groupSubject: call.GroupSubject, groupSubject: call.GroupSubject,
@@ -347,7 +356,7 @@ describe("broadcast dispatch", () => {
}); });
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => { it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
mockDispatchReplyFromConfig mockDispatchInboundMessage
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
queuedFinal: false, queuedFinal: false,
@@ -389,7 +398,7 @@ describe("broadcast dispatch", () => {
}); });
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => { it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
mockDispatchReplyFromConfig mockDispatchInboundMessage
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
queuedFinal: true, queuedFinal: true,
@@ -430,7 +439,7 @@ describe("broadcast dispatch", () => {
}); });
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => { it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
mockDispatchReplyFromConfig mockDispatchInboundMessage
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
queuedFinal: false, queuedFinal: false,
@@ -484,7 +493,7 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(), runtime: createRuntimeEnv(),
}); });
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
expect(mockGetChatInfo).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled();
}); });
@@ -502,7 +511,7 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(), runtime: createRuntimeEnv(),
}); });
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
expect(mockGetChatInfo).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled();
}); });
@@ -539,14 +548,14 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(), runtime: createRuntimeEnv(),
}); });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1); expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
expect(finalizeInboundContextCalls).toHaveLength(1); expect(builtInboundContextCalls).toHaveLength(1);
expect(finalizeInboundContextCalls[0]?.SessionKey).toBe( expect(builtInboundContextCalls[0]?.SessionKey).toBe(
"agent:main:feishu:group:oc-broadcast-group", "agent:main:feishu:group:oc-broadcast-group",
); );
expect(finalizeInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team"); expect(builtInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
expect(finalizeInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team"); expect(builtInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
expect(mockGetChatInfo).toHaveBeenCalledTimes(1); expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
}); });
@@ -584,11 +593,11 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(), runtime: createRuntimeEnv(),
accountId: "account-A", accountId: "account-A",
}); });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
mockDispatchReplyFromConfig.mockClear(); mockDispatchInboundMessage.mockClear();
mockGetChatInfo.mockClear(); mockGetChatInfo.mockClear();
finalizeInboundContextCalls.length = 0; builtInboundContextCalls.length = 0;
await handleFeishuMessage({ await handleFeishuMessage({
cfg, cfg,
@@ -596,7 +605,7 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(), runtime: createRuntimeEnv(),
accountId: "account-B", accountId: "account-B",
}); });
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
expect(mockGetChatInfo).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled();
}); });
@@ -634,10 +643,10 @@ describe("broadcast dispatch", () => {
runtime: createRuntimeEnv(), runtime: createRuntimeEnv(),
}); });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
const sessionKey = const sessionKey =
typeof finalizeInboundContextCalls[0]?.SessionKey === "string" typeof builtInboundContextCalls[0]?.SessionKey === "string"
? finalizeInboundContextCalls[0].SessionKey ? builtInboundContextCalls[0].SessionKey
: ""; : "";
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group"); 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; let currentRuntimeConfig = {} as ClawdbotConfig;
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime { function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
return { const runtime = {
config: { config: {
current: vi.fn(() => currentRuntimeConfig), current: vi.fn(() => currentRuntimeConfig),
}, },
@@ -209,9 +209,14 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
kind: "message", kind: "message",
canStartAgentTurn: true, canStartAgentTurn: true,
}); });
await turn.recordInboundSession({ if (!("route" in turn) || !("runDispatch" in turn)) {
storePath: turn.storePath, throw new Error("expected a prepared channel turn plan");
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, }
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, ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution, groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing, createIfMissing: turn.record?.createIfMissing,
@@ -229,6 +234,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}), ...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}), ...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
} as unknown as PluginRuntime; } as unknown as PluginRuntime;
return runtime;
} }
const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) => const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
@@ -239,7 +245,6 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) => const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
mockResolveStorePath(params); mockResolveStorePath(params);
const resolveEnvelopeFormatOptionsMock = () => ({}); const resolveEnvelopeFormatOptionsMock = () => ({});
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const withReplyDispatcherMock = async ({ const withReplyDispatcherMock = async ({
run, run,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run(); }: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
@@ -299,6 +304,8 @@ const {
mockResolveFeishuReasoningPreviewEnabled, mockResolveFeishuReasoningPreviewEnabled,
mockTranscribeFirstAudio, mockTranscribeFirstAudio,
mockMaybeCreateDynamicAgent, mockMaybeCreateDynamicAgent,
mockBuildChannelInboundEventContext,
mockDispatchInboundMessage,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({ mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: createReplyDispatcher(), dispatcher: createReplyDispatcher(),
@@ -336,8 +343,49 @@ const {
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false), mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
mockTranscribeFirstAudio: vi.fn(), mockTranscribeFirstAudio: vi.fn(),
mockMaybeCreateDynamicAgent: 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", () => ({ vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher, createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
})); }));
@@ -966,42 +1014,11 @@ describe("handleFeishuMessage ACP routing", () => {
); );
expect(dispatcherOptions.allowReasoningPreview).toBe(true); 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", () => { describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({ const mockFinalizeInboundContext = mockBuildChannelInboundEventContext;
...ctx, const mockDispatchReplyFromConfig = mockDispatchInboundMessage;
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
}));
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockWithReplyDispatcher = vi.fn( const mockWithReplyDispatcher = vi.fn(
async ({ async ({
dispatcher, dispatcher,
@@ -1037,6 +1054,10 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockDispatchReplyFromConfig.mockReset().mockResolvedValue({
queuedFinal: false,
counts: { final: 1 },
});
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true); mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockGetMessageFeishu.mockReset().mockResolvedValue(null); mockGetMessageFeishu.mockReset().mockResolvedValue(null);
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]); mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
+29 -44
View File
@@ -1,7 +1,8 @@
// Feishu plugin module implements bot behavior.
import { import {
buildChannelInboundEventContext, buildChannelInboundEventContext,
formatAgentEnvelope,
formatInboundMediaUnavailableText, formatInboundMediaUnavailableText,
resolveEnvelopeFormatOptions,
toInboundMediaFacts, toInboundMediaFacts,
} from "openclaw/plugin-sdk/channel-inbound"; } from "openclaw/plugin-sdk/channel-inbound";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/channel-outbound"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/channel-outbound";
@@ -17,6 +18,7 @@ import {
createChannelHistoryWindow, createChannelHistoryWindow,
type HistoryEntry, type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history"; } from "openclaw/plugin-sdk/reply-history";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { import {
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
@@ -24,6 +26,7 @@ import {
warnMissingProviderGroupPolicyFallbackOnce, warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy"; } from "openclaw/plugin-sdk/runtime-group-policy";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; 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 { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js";
@@ -977,7 +980,7 @@ export async function handleFeishuMessage(params: {
(groupSession?.groupSessionScope === "group_topic" || (groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender"); groupSession?.groupSessionScope === "group_topic_sender");
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({ const messageBody = buildFeishuAgentBody({
ctx: agentFacingCtx, ctx: agentFacingCtx,
quotedContent, quotedContent,
@@ -990,7 +993,7 @@ export async function handleFeishuMessage(params: {
log(`feishu[${account.accountId}]: appending permission error notice to message body`); log(`feishu[${account.accountId}]: appending permission error notice to message body`);
} }
const body = core.channel.reply.formatAgentEnvelope({ const body = formatAgentEnvelope({
channel: "Feishu", channel: "Feishu",
from: envelopeFrom, from: envelopeFrom,
timestamp: new Date(), timestamp: new Date(),
@@ -1008,7 +1011,7 @@ export async function handleFeishuMessage(params: {
limit: historyLimit, limit: historyLimit,
currentMessage: combinedBody, currentMessage: combinedBody,
formatEntry: (entry) => formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({ formatAgentEnvelope({
channel: "Feishu", channel: "Feishu",
// Preserve speaker identity in group history as well. // Preserve speaker identity in group history as well.
from: `${ctx.chatId}:${entry.sender}`, from: `${ctx.chatId}:${entry.sender}`,
@@ -1116,7 +1119,7 @@ export async function handleFeishuMessage(params: {
return threadContext; 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({ const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
storePath, storePath,
sessionKey: agentSessionKey, sessionKey: agentSessionKey,
@@ -1182,7 +1185,7 @@ export async function handleFeishuMessage(params: {
: relevantMessages.slice(1); : relevantMessages.slice(1);
const historyParts = historyMessages.map((msg) => { const historyParts = historyMessages.map((msg) => {
const role = msg.senderType === "app" ? "assistant" : "user"; const role = msg.senderType === "app" ? "assistant" : "user";
return core.channel.reply.formatAgentEnvelope({ return formatAgentEnvelope({
channel: "Feishu", channel: "Feishu",
from: `${msg.senderId ?? "Unknown"} (${role})`, from: `${msg.senderId ?? "Unknown"} (${role})`,
timestamp: msg.createTime, timestamp: msg.createTime,
@@ -1216,7 +1219,6 @@ export async function handleFeishuMessage(params: {
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName); const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
return buildChannelInboundEventContext({ return buildChannelInboundEventContext({
channel: "feishu", channel: "feishu",
finalize: core.channel.reply.finalizeInboundContext,
supplemental: { supplemental: {
quote: quotedContent ? { id: ctx.parentId, body: quotedContent } : undefined, quote: quotedContent ? { id: ctx.parentId, body: quotedContent } : undefined,
thread: { thread: {
@@ -1388,7 +1390,7 @@ export async function handleFeishuMessage(params: {
} }
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId); const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, { const agentStorePath = resolveStorePath(cfg.session?.store, {
agentId, agentId,
}); });
const agentRecord = { const agentRecord = {
@@ -1456,12 +1458,11 @@ export async function handleFeishuMessage(params: {
raw: ctx, raw: ctx,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg,
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: agentSessionKey, route: { agentId, sessionKey: agentSessionKey },
storePath: agentStorePath,
ctxPayload: agentCtx, ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord, record: agentRecord,
onPreDispatchFailure: () => onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({ core.channel.reply.settleReplyDispatcher({
@@ -1469,16 +1470,12 @@ export async function handleFeishuMessage(params: {
onSettled: () => markDispatchIdle(), onSettled: () => markDispatchIdle(),
}), }),
runDispatch: () => runDispatch: () =>
core.channel.reply.withReplyDispatcher({ dispatchInboundMessage({
ctx: agentCtx,
cfg,
dispatcher, dispatcher,
onSettled: () => markDispatchIdle(), onSettled: () => markDispatchIdle(),
run: () => replyOptions,
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher,
replyOptions,
}),
}), }),
}), }),
}, },
@@ -1524,22 +1521,17 @@ export async function handleFeishuMessage(params: {
raw: ctx, raw: ctx,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg,
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: agentSessionKey, route: { agentId, sessionKey: agentSessionKey },
storePath: agentStorePath,
ctxPayload: agentCtx, ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord, record: agentRecord,
runDispatch: () => runDispatch: () =>
core.channel.reply.withReplyDispatcher({ dispatchInboundMessage({
ctx: agentCtx,
cfg,
dispatcher: noopDispatcher, dispatcher: noopDispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher: noopDispatcher,
}),
}), }),
}), }),
}, },
@@ -1592,7 +1584,7 @@ export async function handleFeishuMessage(params: {
); );
const identity = resolveAgentOutboundIdentity(effectiveCfg, route.agentId); const identity = resolveAgentOutboundIdentity(effectiveCfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, { const storePath = resolveStorePath(effectiveCfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,
}); });
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
@@ -1637,12 +1629,11 @@ export async function handleFeishuMessage(params: {
raw: ctx, raw: ctx,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg: effectiveCfg,
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, route: { agentId: route.agentId, sessionKey: route.sessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: { record: {
updateLastRoute: buildFeishuInboundLastRouteUpdate({ updateLastRoute: buildFeishuInboundLastRouteUpdate({
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
@@ -1666,18 +1657,12 @@ export async function handleFeishuMessage(params: {
onSettled: () => markDispatchIdle(), onSettled: () => markDispatchIdle(),
}), }),
runDispatch: () => runDispatch: () =>
core.channel.reply.withReplyDispatcher({ dispatchInboundMessage({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher, dispatcher,
onSettled: () => { onSettled: () => markDispatchIdle(),
markDispatchIdle(); replyOptions,
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
}), }),
}), }),
}, },
+6 -6
View File
@@ -30,10 +30,10 @@ import {
createRuntimeDirectoryLiveAdapter, createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime"; } from "openclaw/plugin-sdk/directory-runtime";
import { import {
interactiveReplyToPresentation, legacyInteractiveReplyToPresentation,
normalizeInteractiveReply, normalizeLegacyInteractiveReply,
normalizeMessagePresentation, normalizeMessagePresentation,
resolveInteractiveTextFallback, resolveLegacyInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
@@ -1074,10 +1074,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
const textCard = readNativeFeishuCardJson(text, { const textCard = readNativeFeishuCardJson(text, {
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx), responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
}); });
const interactive = normalizeInteractiveReply(ctx.params.interactive); const interactive = normalizeLegacyInteractiveReply(ctx.params.interactive);
const presentation = const presentation =
normalizeMessagePresentation(ctx.params.presentation) ?? normalizeMessagePresentation(ctx.params.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined); (interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
const mediaUrl = readFeishuMediaParam(ctx.params); const mediaUrl = readFeishuMediaParam(ctx.params);
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]); const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
if (textCard && !presentation) { if (textCard && !presentation) {
@@ -1088,7 +1088,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
presentation, presentation,
fallbackText: textCard fallbackText: textCard
? undefined ? undefined
: resolveInteractiveTextFallback({ text, interactive }), : resolveLegacyInteractiveTextFallback({ text, interactive }),
}) })
: undefined; : undefined;
const presentationCard = const presentationCard =
+4 -2
View File
@@ -1,5 +1,7 @@
// Feishu plugin module implements comment dispatcher behavior. // Feishu plugin module implements comment dispatcher behavior.
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js"; import { createFeishuClient } from "./client.js";
import { import {
@@ -56,10 +58,10 @@ export function createFeishuCommentReplyDispatcher(
}); });
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({ createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix, responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
onReplyStart: async () => { onReplyStart: async () => {
await typingReaction.start(); await typingReaction.start();
}, },
+43 -77
View File
@@ -1,5 +1,4 @@
// Feishu tests cover comment handler plugin behavior. // 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 { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
import { handleFeishuCommentEvent } from "./comment-handler.js"; import { handleFeishuCommentEvent } from "./comment-handler.js";
@@ -10,6 +9,7 @@ const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn()); const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() }))); const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn()); const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
const dispatchInboundMessageMock = vi.hoisted(() => vi.fn());
vi.mock("./monitor.comment.js", () => ({ vi.mock("./monitor.comment.js", () => ({
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock, resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
@@ -31,6 +31,11 @@ vi.mock("./drive.js", () => ({
deliverCommentThreadText: deliverCommentThreadTextMock, 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"> { async function raceWithNextMacrotask<T>(promise: Promise<T>): Promise<T | "pending"> {
return await Promise.race([ return await Promise.race([
promise, promise,
@@ -83,38 +88,19 @@ function createTestRuntime(overrides?: {
readAllowFromStore?: () => Promise<unknown[]>; readAllowFromStore?: () => Promise<unknown[]>;
upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>; upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>;
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>; resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
dispatchReplyFromConfig?: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"];
withReplyDispatcher?: PluginRuntime["channel"]["reply"]["withReplyDispatcher"];
}) { }) {
const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ctx); const recordInboundSession = vi.fn(async (_params: unknown) => {});
const dispatchReplyFromConfig = type PreparedCommentTurnPlan = {
overrides?.dispatchReplyFromConfig ?? route: { agentId: string; sessionKey: string };
vi.fn(async () => ({ ctxPayload: { SessionKey?: string };
queuedFinal: true, record?: Record<string, unknown> & { onRecordError?: (error: unknown) => void };
counts: { tool: 0, block: 0, final: 1 }, runDispatch: () => Promise<unknown>;
})); };
const withReplyDispatcher = const dispatchPreparedForTest = vi.fn(async (turn: PreparedCommentTurnPlan) => {
overrides?.withReplyDispatcher ?? const storePath = "/tmp/feishu-session-store.json";
vi.fn( await recordInboundSession({
async ({ storePath,
run, sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
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,
ctx: turn.ctxPayload, ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution, groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing, createIfMissing: turn.record?.createIfMissing,
@@ -126,7 +112,7 @@ function createTestRuntime(overrides?: {
admission: { kind: "dispatch" as const }, admission: { kind: "dispatch" as const },
dispatched: true, dispatched: true,
ctxPayload: turn.ctxPayload, ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey, routeSessionKey: turn.route.sessionKey,
dispatchResult, dispatchResult,
}; };
}); });
@@ -151,9 +137,11 @@ function createTestRuntime(overrides?: {
resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())), resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())),
}, },
reply: { reply: {
finalizeInboundContext, settleReplyDispatcher: vi.fn(async ({ dispatcher, onSettled }) => {
dispatchReplyFromConfig, dispatcher.markComplete();
withReplyDispatcher, await dispatcher.waitForIdle();
await onSettled?.();
}),
}, },
session: { session: {
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"), resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
@@ -176,7 +164,7 @@ function createTestRuntime(overrides?: {
if (!("runDispatch" in turn)) { if (!("runDispatch" in turn)) {
throw new Error("feishu comment test runtime only supports prepared turns"); 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"], }) as unknown as PluginRuntime["channel"]["inbound"]["run"],
}, },
pairing: { pairing: {
@@ -206,6 +194,10 @@ describe("handleFeishuCommentEvent", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
dispatchInboundMessageMock.mockResolvedValue({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
});
currentRuntimeConfig = buildConfig(); currentRuntimeConfig = buildConfig();
maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({ maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({
created: false, created: false,
@@ -270,20 +262,16 @@ describe("handleFeishuCommentEvent", () => {
); );
const runtime = (await import("./runtime.js")).getFeishuRuntime(); 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< const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType<
typeof vi.fn typeof vi.fn
>; >;
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(finalizeInboundContext).toHaveBeenCalledTimes(1); expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
const finalizedContext = mockCallArg(finalizeInboundContext, "finalizeInboundContext") as const finalizedContext = (
| Record<string, unknown> mockCallArg(dispatchInboundMessageMock, "dispatchInboundMessage") as {
| undefined; ctx?: Record<string, unknown>;
}
).ctx;
expect({ expect({
from: finalizedContext?.From, from: finalizedContext?.From,
to: finalizedContext?.To, to: finalizedContext?.To,
@@ -306,7 +294,6 @@ describe("handleFeishuCommentEvent", () => {
| { sessionKey?: string } | { sessionKey?: string }
| undefined; | undefined;
expect(recordArgs?.sessionKey).toBe("agent:main:feishu:direct:comment-doc:docx:doc_token_1"); 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 () => { it("allows comment senders matched by user_id allowlist entries", async () => {
@@ -332,10 +319,7 @@ describe("handleFeishuCommentEvent", () => {
} as never, } as never,
}); });
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled(); expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
}); });
@@ -376,10 +360,7 @@ describe("handleFeishuCommentEvent", () => {
| undefined; | undefined;
expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender"); expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender");
expect(dynamicAgentArgs?.accountId).toBe("default"); expect(dynamicAgentArgs?.accountId).toBe("default");
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
}); });
it("drops a comment denied by refreshed dynamic-agent policy", async () => { it("drops a comment denied by refreshed dynamic-agent policy", async () => {
@@ -410,12 +391,9 @@ describe("handleFeishuCommentEvent", () => {
} as never, } as never,
}); });
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled(); expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
expect(deliverCommentThreadTextMock).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 () => { it("issues a pairing challenge before dynamic comment-agent creation", async () => {
@@ -446,12 +424,9 @@ describe("handleFeishuCommentEvent", () => {
} as never, } as never,
}); });
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled(); expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
expect(deliverCommentThreadTextMock).toHaveBeenCalledTimes(1); 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 () => { it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
@@ -506,10 +481,7 @@ describe("handleFeishuCommentEvent", () => {
].join("\n"), ].join("\n"),
is_whole_comment: false, is_whole_comment: false,
}); });
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
typeof vi.fn
>;
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
}); });
it("passes whole-comment metadata to the comment reply dispatcher", async () => { 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 () => { it("always finalizes comment typing cleanup even when dispatch fails", async () => {
const dispatchReplyFromConfig = vi.fn(async () => { dispatchInboundMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
throw new Error("dispatch failed"); const runtime = createTestRuntime();
});
const runtime = createTestRuntime({ dispatchReplyFromConfig });
setFeishuRuntime(runtime); setFeishuRuntime(runtime);
const markRunComplete = vi.fn(); const markRunComplete = vi.fn();
const markDispatchIdle = vi.fn(); const markDispatchIdle = vi.fn();
@@ -669,10 +639,6 @@ describe("handleFeishuCommentEvent", () => {
}); });
expect(startTypingReaction).not.toHaveBeenCalled(); expect(startTypingReaction).not.toHaveBeenCalled();
const runtime = (await import("./runtime.js")).getFeishuRuntime(); expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
}); });
}); });
+38 -41
View File
@@ -1,5 +1,7 @@
// Feishu plugin module implements comment handler behavior. // Feishu plugin module implements comment handler behavior.
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; 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 type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js"; import { createFeishuClient } from "./client.js";
@@ -214,36 +216,36 @@ export async function handleFeishuCommentEvent(
fileToken: turn.fileToken, fileToken: turn.fileToken,
}); });
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`; const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
const ctxPayload = core.channel.reply.finalizeInboundContext({ const rawBody = turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt;
Body: bodyForAgent, const conversationLabel = turn.documentTitle
BodyForAgent: bodyForAgent, ? `Feishu comment · ${turn.documentTitle}`
RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, : "Feishu comment";
CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, const ctxPayload = buildChannelInboundEventContext({
From: `feishu:${turn.senderId}`, channel: "feishu",
To: commentTarget, accountId: route.accountId,
SessionKey: commentSessionKey, surface: "feishu-comment",
AccountId: route.accountId, messageId: turn.messageId,
ChatType: "direct", timestamp: parseTimestampMs(turn.timestamp),
ConversationLabel: turn.documentTitle from: `feishu:${turn.senderId}`,
? `Feishu comment · ${turn.documentTitle}` sender: { id: turn.senderId, name: turn.senderId },
: "Feishu comment", conversation: { kind: "direct", id: commentTarget, label: conversationLabel },
SenderName: turn.senderId, route: {
SenderId: turn.senderId, agentId: route.agentId,
Provider: "feishu", accountId: route.accountId,
Surface: "feishu-comment", routeSessionKey: commentSessionKey,
MessageSid: turn.messageId, dispatchSessionKey: commentSessionKey,
// For Feishu comment turns, MessageThreadId carries the inbound reply_id so },
// comment-aware tools can clean typing reaction before sending visible output. reply: {
MessageThreadId: turn.replyId, to: commentTarget,
Timestamp: parseTimestampMs(turn.timestamp), originatingTo: commentTarget,
WasMentioned: turn.isMentioned, // Comment-aware tools use the inbound reply id as the native thread id.
CommandAuthorized: false, messageThreadId: turn.replyId,
OriginatingChannel: "feishu", },
OriginatingTo: commentTarget, message: { body: bodyForAgent, bodyForAgent, rawBody, commandBody: rawBody },
}); access: {
commands: { authorized: false },
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, { mentions: { canDetectMention: true, wasMentioned: turn.isMentioned ?? false },
agentId: route.agentId, },
}); });
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } = const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
@@ -279,12 +281,11 @@ export async function handleFeishuCommentEvent(
raw: turn, raw: turn,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg: effectiveCfg,
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: commentSessionKey, route: { agentId: route.agentId, sessionKey: commentSessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: { record: {
onRecordError: (err) => { onRecordError: (err) => {
error( error(
@@ -303,15 +304,11 @@ export async function handleFeishuCommentEvent(
}); });
}, },
runDispatch: () => runDispatch: () =>
core.channel.reply.withReplyDispatcher({ dispatchInboundMessage({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher, dispatcher,
run: () => replyOptions,
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
}), }),
}), }),
}, },
+10 -10
View File
@@ -7,11 +7,11 @@ import {
} from "openclaw/plugin-sdk/channel-send-result"; } from "openclaw/plugin-sdk/channel-send-result";
import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime"; import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime";
import { import {
interactiveReplyToPresentation, legacyInteractiveReplyToPresentation,
normalizeInteractiveReply, normalizeLegacyInteractiveReply,
normalizeMessagePresentation, normalizeMessagePresentation,
renderMessagePresentationFallbackText, renderMessagePresentationFallbackText,
resolveInteractiveTextFallback, resolveLegacyInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
@@ -179,10 +179,10 @@ function buildFeishuPayloadCard(params: {
const rawText = params.text ?? params.payload.text; const rawText = params.text ?? params.payload.text;
const textCard = readNativeFeishuCardJson(rawText); const textCard = readNativeFeishuCardJson(rawText);
const interactive = normalizeInteractiveReply(params.payload.interactive); const interactive = normalizeLegacyInteractiveReply(params.payload.interactive);
const presentation = const presentation =
normalizeMessagePresentation(params.payload.presentation) ?? normalizeMessagePresentation(params.payload.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined); (interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
if (!presentation && !interactive) { if (!presentation && !interactive) {
if (!textCard) { if (!textCard) {
return undefined; return undefined;
@@ -193,7 +193,7 @@ function buildFeishuPayloadCard(params: {
const text = textCard const text = textCard
? undefined ? undefined
: resolveInteractiveTextFallback({ : resolveLegacyInteractiveTextFallback({
text: rawText, text: rawText,
interactive, interactive,
}); });
@@ -600,10 +600,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload); const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload);
const ttsSupplement = getReplyPayloadTtsSupplement(payload); const ttsSupplement = getReplyPayloadTtsSupplement(payload);
if (parseFeishuCommentTarget(ctx.to)) { if (parseFeishuCommentTarget(ctx.to)) {
const interactive = normalizeInteractiveReply(payload.interactive); const interactive = normalizeLegacyInteractiveReply(payload.interactive);
const normalizedPresentation = const normalizedPresentation =
normalizeMessagePresentation(payload.presentation) ?? normalizeMessagePresentation(payload.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined); (interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
// Document comments cannot render cards. Resolve the text path before // Document comments cannot render cards. Resolve the text path before
// validating card limits so unused native card data cannot block delivery. // validating card limits so unused native card data cannot block delivery.
const textCard = readNativeFeishuCardJson(payload.text); const textCard = readNativeFeishuCardJson(payload.text);
@@ -652,10 +652,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
if (ttsSupplement) { if (ttsSupplement) {
return await sendFeishuTtsSupplementPayload({ ctx, payload, supplement: ttsSupplement }); return await sendFeishuTtsSupplementPayload({ ctx, payload, supplement: ttsSupplement });
} }
const interactive = normalizeInteractiveReply(payload.interactive); const interactive = normalizeLegacyInteractiveReply(payload.interactive);
const presentation = const presentation =
normalizeMessagePresentation(payload.presentation) ?? normalizeMessagePresentation(payload.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined); (interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
const fallbackPayload = presentation const fallbackPayload = presentation
? { ? {
...payload, ...payload,
+224 -228
View File
@@ -1,5 +1,5 @@
// Feishu plugin module implements reply dispatcher behavior. // 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 { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound"; import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
import { import {
@@ -14,6 +14,7 @@ import {
resolveTextChunksWithFallback, resolveTextChunksWithFallback,
sendMediaWithLeadingCaption, sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload"; } from "openclaw/plugin-sdk/reply-payload";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking"; import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js"; import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js";
@@ -637,245 +638,240 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
return nextIdleSideEffects; return nextIdleSideEffects;
}; };
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: prefixContext.responsePrefix,
responsePrefix: prefixContext.responsePrefix, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, agentId),
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), silentReplyContext: {
silentReplyContext: { cfg,
cfg, sessionKey: params.sessionKey,
sessionKey: params.sessionKey, surface: "feishu",
surface: "feishu", conversationType: chatId.startsWith("oc_") ? "group" : "direct",
conversationType: chatId.startsWith("oc_") ? "group" : "direct", },
}, onSkip: (_payload, info) => {
onSkip: (_payload, info) => { if (info.kind === "final") {
if (info.kind === "final") { skippedFinalReason = info.reason;
skippedFinalReason = info.reason; }
} },
}, onReplyStart: async () => {
onReplyStart: async () => { if (!replyLifecycleStateInitialized) {
if (!replyLifecycleStateInitialized) { replyLifecycleStateInitialized = true;
replyLifecycleStateInitialized = true; deliveredFinalTexts.clear();
deliveredFinalTexts.clear(); sentIndependentBlockText = false;
sentIndependentBlockText = false; streamingClosedForReply = false;
streamingClosedForReply = false; streamingCloseErroredForReply = false;
streamingCloseErroredForReply = false; visibleReplySent = false;
visibleReplySent = false; skippedFinalReason = null;
skippedFinalReason = null; }
} if (streamingEnabled && renderMode === "card") {
if (streamingEnabled && renderMode === "card") { startStreaming();
startStreaming(); }
} await Promise.resolve(typingCallbacks?.onReplyStart?.());
await Promise.resolve(typingCallbacks?.onReplyStart?.()); },
}, deliver: async (payload: ReplyPayload, info) => {
deliver: async (payload: ReplyPayload, info) => { if (info?.kind === "final") {
if (info?.kind === "final") { skippedFinalReason = null;
skippedFinalReason = null; }
} const payloadText =
const payloadText = payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text;
payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text; const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText });
const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText }); const text =
const text = info?.kind === "final"
info?.kind === "final" ? mergeStreamingFinalText(
? mergeStreamingFinalText( streamText,
streamText, reply.text,
reply.text, payload.isError === true && hasStreamingFinalText,
payload.isError === true && hasStreamingFinalText, )
) : reply.text;
: reply.text; const hasText = reply.hasText;
const hasText = reply.hasText; const hasMedia = reply.hasMedia;
const hasMedia = reply.hasMedia; const ttsSupplement = getReplyPayloadTtsSupplement(payload);
const ttsSupplement = getReplyPayloadTtsSupplement(payload); const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true;
const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true; const hasVoiceMedia =
const hasVoiceMedia = hasMedia &&
hasMedia && reply.mediaUrls.some((mediaUrl) =>
reply.mediaUrls.some((mediaUrl) => shouldSuppressFeishuTextForVoiceMedia({
shouldSuppressFeishuTextForVoiceMedia({ mediaUrl,
mediaUrl, ...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}), ttsSupplement,
ttsSupplement, }),
}), );
); const finalTextExceedsStreamingLimit =
const finalTextExceedsStreamingLimit = info?.kind === "final" && hasText && text.length > textChunkLimit;
info?.kind === "final" && hasText && text.length > textChunkLimit; const useStaticCard =
const useStaticCard = hasText &&
hasText && (renderMode === "card" ||
(renderMode === "card" || (info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") ||
(info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") || (renderMode === "auto" && shouldUseCard(text)));
(renderMode === "auto" && shouldUseCard(text))); const useStreamingCard =
const useStreamingCard = hasText &&
hasText && streamingEnabled &&
streamingEnabled && !finalTextExceedsStreamingLimit &&
!finalTextExceedsStreamingLimit && (info?.kind === "final" || useStaticCard);
(info?.kind === "final" || useStaticCard); const finalTextWouldUseStreamingCard = info?.kind === "final" && hasText && streamingEnabled;
const finalTextWouldUseStreamingCard = const useCard = useStaticCard || useStreamingCard;
info?.kind === "final" && hasText && streamingEnabled; const skipTextForDuplicateFinal =
const useCard = useStaticCard || useStreamingCard; info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
const skipTextForDuplicateFinal = const skipTextForClosedStreamingFinal =
info?.kind === "final" && hasText && deliveredFinalTexts.has(text); info?.kind === "final" &&
const skipTextForClosedStreamingFinal = hasText &&
info?.kind === "final" && streamingClosedForReply &&
hasText && !streamingCloseErroredForReply &&
streamingClosedForReply && finalTextWouldUseStreamingCard;
!streamingCloseErroredForReply && const shouldDeliverText =
finalTextWouldUseStreamingCard; hasText && !hasVoiceMedia && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
const shouldDeliverText = const shouldDiscardStreamingPreview =
hasText && info?.kind === "final" &&
!hasVoiceMedia && (finalTextExceedsStreamingLimit ||
!skipTextForDuplicateFinal && (hasMedia &&
!skipTextForClosedStreamingFinal; ((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) ||
const shouldDiscardStreamingPreview = skipTextForDuplicateFinal)));
info?.kind === "final" &&
(finalTextExceedsStreamingLimit ||
(hasMedia &&
((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) ||
skipTextForDuplicateFinal)));
if (!shouldDeliverText && !hasMedia) { if (!shouldDeliverText && !hasMedia) {
return; return;
} }
if (shouldDiscardStreamingPreview) { if (shouldDiscardStreamingPreview) {
await discardStreamingPreview(); await discardStreamingPreview();
} }
if (shouldDeliverText) { if (shouldDeliverText) {
if (info?.kind === "block") { if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as // Drop internal block chunks unless we can safely consume them as
// streaming-card fallback content or send them as independent // streaming-card fallback content or send them as independent
// messages for true progressive delivery. // messages for true progressive delivery.
if (!useStreamingCard) { if (!useStreamingCard) {
if (coreBlockStreamingEnabled) { if (coreBlockStreamingEnabled) {
// Reuse normal text chunking, but notify mentions only on the first visible chunk. // Reuse normal text chunking, but notify mentions only on the first visible chunk.
const isFirstBlock = !sentIndependentBlockText; const isFirstBlock = !sentIndependentBlockText;
const firstChunkMentions = const firstChunkMentions =
isFirstBlock && mentionTargets?.length ? mentionTargets : undefined; isFirstBlock && mentionTargets?.length ? mentionTargets : undefined;
await sendChunkedTextReply({ await sendChunkedTextReply({
text, text,
useCard: false, useCard: false,
infoKind: "block", infoKind: "block",
firstChunkMentions, firstChunkMentions,
sendChunk: async ({ chunk, isFirst }) => { sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({ await sendMessageFeishu({
cfg, cfg,
to: sendTarget, to: sendTarget,
text: chunk, text: chunk,
replyToMessageId: sendReplyToMessageId, replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread, replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback, allowTopLevelReplyFallback,
accountId, accountId,
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}), ...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
}); });
}, },
}); });
sentIndependentBlockText = true; sentIndependentBlockText = true;
if (hasMedia) { if (hasMedia) {
await sendMediaReplies(payload); await sendMediaReplies(payload);
}
} }
return;
}
startStreaming();
if (streamingStartPromise) {
await streamingStartPromise;
}
}
if (info?.kind === "final" && useStreamingCard) {
startStreaming();
if (streamingStartPromise) {
await streamingStartPromise;
}
}
const shouldStreamText = info?.kind === "block" || info?.kind === "final";
if (streaming?.isActive() && shouldStreamText) {
if (info?.kind === "block") {
// Some runtimes emit block payloads without onPartial/final callbacks.
// Mirror block text into streamText so onIdle close still sends content.
queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
}
if (info?.kind === "final") {
// Final payloads can be cumulative snapshots or independent
// notices. Preserve both when the latter arrives after an answer.
streamText = text;
hasStreamingFinalText = true;
snapshotBaseText = "";
lastSnapshotTextLength = text.length;
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}
// Send media even when streaming handled the text
if (hasMedia) {
await sendMediaReplies(payload);
} }
return; return;
} }
startStreaming();
if (useCard) { if (streamingStartPromise) {
const cardHeader = resolveCardHeader(agentId, identity); await streamingStartPromise;
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await sendChunkedTextReply({
text,
useCard: true,
infoKind: info?.kind,
sendChunk: async ({ chunk }) => {
await sendStructuredCardFeishu({
cfg,
to: sendTarget,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
header: cardHeader,
note: cardNote,
});
},
});
} else {
const firstChunkMentions =
info?.kind === "final" && mentionTargets?.length ? mentionTargets : undefined;
await sendChunkedTextReply({
text,
useCard: false,
infoKind: info?.kind,
firstChunkMentions,
sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: sendTarget,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
});
},
});
} }
} }
if (hasMedia) { if (info?.kind === "final" && useStreamingCard) {
await sendMediaReplies( startStreaming();
payload, if (streamingStartPromise) {
hasVoiceMedia && hasText ? { fallbackText: text } : undefined, await streamingStartPromise;
); }
} }
},
onError: async (error, info) => { const shouldStreamText = info?.kind === "block" || info?.kind === "final";
streamingCloseErroredForReply = true; if (streaming?.isActive() && shouldStreamText) {
streamingClosedForReply = false; if (info?.kind === "block") {
params.runtime.error?.( // Some runtimes emit block payloads without onPartial/final callbacks.
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`, // Mirror block text into streamText so onIdle close still sends content.
queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
}
if (info?.kind === "final") {
// Final payloads can be cumulative snapshots or independent
// notices. Preserve both when the latter arrives after an answer.
streamText = text;
hasStreamingFinalText = true;
snapshotBaseText = "";
lastSnapshotTextLength = text.length;
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}
// Send media even when streaming handled the text
if (hasMedia) {
await sendMediaReplies(payload);
}
return;
}
if (useCard) {
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await sendChunkedTextReply({
text,
useCard: true,
infoKind: info?.kind,
sendChunk: async ({ chunk }) => {
await sendStructuredCardFeishu({
cfg,
to: sendTarget,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
header: cardHeader,
note: cardNote,
});
},
});
} else {
const firstChunkMentions =
info?.kind === "final" && mentionTargets?.length ? mentionTargets : undefined;
await sendChunkedTextReply({
text,
useCard: false,
infoKind: info?.kind,
firstChunkMentions,
sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: sendTarget,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
});
},
});
}
}
if (hasMedia) {
await sendMediaReplies(
payload,
hasVoiceMedia && hasText ? { fallbackText: text } : undefined,
); );
await queueIdleSideEffects({ markClosedForReply: false }); }
}, },
onIdle: () => queueIdleSideEffects(), onError: async (error, info) => {
onCleanup: () => { streamingCloseErroredForReply = true;
typingCallbacks?.onCleanup?.(); streamingClosedForReply = false;
}, params.runtime.error?.(
}); `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
);
await queueIdleSideEffects({ markClosedForReply: false });
},
onIdle: () => queueIdleSideEffects(),
onCleanup: () => {
typingCallbacks?.onCleanup?.();
},
});
return { return {
dispatcher, dispatcher,
-1
View File
@@ -40,7 +40,6 @@ export type {
} from "openclaw/plugin-sdk/config-contracts"; } from "openclaw/plugin-sdk/config-contracts";
export { extractToolSend } from "openclaw/plugin-sdk/tool-send"; export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; 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 { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
export { export {
registerWebhookTargetWithPluginRoute, registerWebhookTargetWithPluginRoute,
+29 -22
View File
@@ -21,6 +21,19 @@ const routingMocks = vi.hoisted(() => ({
| undefined, | 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", () => ({ vi.mock("./api.js", () => ({
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia, downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage, sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
@@ -43,34 +56,29 @@ beforeEach(() => {
apiMocks.downloadGoogleChatMedia.mockReset(); apiMocks.downloadGoogleChatMedia.mockReset();
apiMocks.sendGoogleChatMessage.mockReset(); apiMocks.sendGoogleChatMessage.mockReset();
accessMocks.applyGoogleChatInboundAccessPolicy.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() { function createInboundClassificationHarness() {
const resolveAgentRoute = vi.fn(() => ({
agentId: "agent-1",
accountId: "work",
sessionKey: "session-1",
}));
const buildContext = vi.fn((payload: unknown) => payload); const buildContext = vi.fn((payload: unknown) => payload);
const runTurn = vi.fn(); const runTurn = vi.fn();
const core = { const core = {
logging: { shouldLogVerbose: () => false }, logging: { shouldLogVerbose: () => false },
channel: { 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 }, inbound: { buildContext, run: runTurn },
}, },
} as unknown as GoogleChatCoreRuntime; } as unknown as GoogleChatCoreRuntime;
return { buildContext, core, resolveAgentRoute, runTurn }; return { buildContext, core, runTurn };
} }
async function processGoogleChatTestEvent(params: { async function processGoogleChatTestEvent(params: {
@@ -177,7 +185,7 @@ describe("googlechat monitor inbound space classification", () => {
] as const; ] as const;
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => { 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 = { const account = {
accountId: "work", accountId: "work",
config: {}, config: {},
@@ -214,7 +222,7 @@ describe("googlechat monitor inbound space classification", () => {
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith( expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
expect.objectContaining({ isGroup }), expect.objectContaining({ isGroup }),
); );
expect(resolveAgentRoute).toHaveBeenCalledWith({ expect(inboundMocks.resolveChannelInboundRouteEnvelope).toHaveBeenCalledWith({
cfg: {}, cfg: {},
channel: "googlechat", channel: "googlechat",
accountId: "work", accountId: "work",
@@ -509,7 +517,6 @@ describe("googlechat monitor direct messages", () => {
it("drops invalid event timestamps from inbound runtime payloads", async () => { it("drops invalid event timestamps from inbound runtime payloads", async () => {
const runTurn = vi.fn(); const runTurn = vi.fn();
const buildContext = vi.fn((payload: unknown) => payload); const buildContext = vi.fn((payload: unknown) => payload);
const formatAgentEnvelope = vi.fn(({ body }: { body: string }) => body);
const core = { const core = {
logging: { shouldLogVerbose: () => false }, logging: { shouldLogVerbose: () => false },
channel: { channel: {
@@ -527,7 +534,7 @@ describe("googlechat monitor direct messages", () => {
}, },
reply: { reply: {
resolveEnvelopeFormatOptions: () => ({}), resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope, formatAgentEnvelope: ({ body }: { body: string }) => body,
dispatchReplyWithBufferedBlockDispatcher: vi.fn(), dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
}, },
inbound: { buildContext, run: runTurn }, inbound: { buildContext, run: runTurn },
@@ -569,7 +576,7 @@ describe("googlechat monitor direct messages", () => {
mediaMaxMb: 10, mediaMaxMb: 10,
}); });
expect(formatAgentEnvelope).toHaveBeenCalledWith( expect(inboundMocks.buildEnvelope).toHaveBeenCalledWith(
expect.objectContaining({ timestamp: undefined }), expect.objectContaining({ timestamp: undefined }),
); );
expect(buildContext).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. // Googlechat plugin module implements monitor behavior.
import { import {
recordChannelBotPairLoopAndCheckSuppression, recordChannelBotPairLoopAndCheckSuppression,
resolveChannelInboundRouteEnvelope,
type ChannelBotLoopProtectionFacts, type ChannelBotLoopProtectionFacts,
} from "openclaw/plugin-sdk/channel-inbound"; } from "openclaw/plugin-sdk/channel-inbound";
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime"; import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenClawConfig } from "../runtime-api.js"; import type { OpenClawConfig } from "../runtime-api.js";
import { import { resolveWebhookPath } from "../runtime-api.js";
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveWebhookPath,
} from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js"; import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js"; import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
@@ -252,7 +250,7 @@ async function processMessageWithPipeline(params: {
return; return;
} }
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config, cfg: config,
channel: "googlechat", channel: "googlechat",
accountId: account.accountId, accountId: account.accountId,
@@ -260,8 +258,6 @@ async function processMessageWithPipeline(params: {
kind: isGroup ? ("group" as const) : ("direct" as const), kind: isGroup ? ("group" as const) : ("direct" as const),
id: spaceId, id: spaceId,
}, },
runtime: core.channel,
sessionStore: config.session?.store,
}); });
let mediaPath: string | undefined; let mediaPath: string | undefined;
@@ -279,7 +275,7 @@ async function processMessageWithPipeline(params: {
? space.displayName || `space:${spaceId}` ? space.displayName || `space:${spaceId}`
: senderName || `user:${senderId}`; : senderName || `user:${senderId}`;
const timestampMs = resolveGoogleChatTimestampMs(event.eventTime); const timestampMs = resolveGoogleChatTimestampMs(event.eventTime);
const { storePath, body } = buildEnvelope({ const body = buildEnvelope({
channel: "Google Chat", channel: "Google Chat",
from: fromLabel, from: fromLabel,
timestamp: timestampMs, timestamp: timestampMs,
@@ -399,13 +395,8 @@ async function processMessageWithPipeline(params: {
cfg: config, cfg: config,
channel: "googlechat", channel: "googlechat",
accountId: route.accountId, accountId: route.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
durable: (payload, info) => durable: (payload, info) =>
resolveGoogleChatDurableReplyOptions({ resolveGoogleChatDurableReplyOptions({
@@ -23,7 +23,6 @@ import {
readChannelAllowFromStore, readChannelAllowFromStore,
upsertChannelPairingRequest, upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime"; } from "openclaw/plugin-sdk/conversation-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime"; import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
@@ -1453,12 +1452,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
raw: decision, raw: decision,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg,
channel: "imessage", channel: "imessage",
accountId: decision.route.accountId, accountId: decision.route.accountId,
routeSessionKey: decision.route.sessionKey, route: {
storePath, agentId: decision.route.agentId,
sessionKey: decision.route.sessionKey,
},
ctxPayload, ctxPayload,
recordInboundSession,
record: { record: {
updateLastRoute: updateLastRoute:
!decision.isGroup && updateTarget !decision.isGroup && updateTarget
+39 -35
View File
@@ -1,5 +1,9 @@
// Irc plugin module implements inbound behavior. // 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 { import {
channelIngressRoutes, channelIngressRoutes,
createChannelIngressResolver, createChannelIngressResolver,
@@ -9,7 +13,6 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import { import {
deliverFormattedTextWithAttachments, deliverFormattedTextWithAttachments,
type OutboundReplyPayload, type OutboundReplyPayload,
@@ -371,7 +374,7 @@ export async function handleIrcInbound(params: {
? message.target ? message.target
: `#${message.target}`; : `#${message.target}`;
const peerId = message.isGroup ? channelTarget : message.senderNick; const peerId = message.isGroup ? channelTarget : message.senderNick;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config as OpenClawConfig, cfg: config as OpenClawConfig,
channel: CHANNEL_ID, channel: CHANNEL_ID,
accountId: account.accountId, accountId: account.accountId,
@@ -379,12 +382,10 @@ export async function handleIrcInbound(params: {
kind: message.isGroup ? "group" : "direct", kind: message.isGroup ? "group" : "direct",
id: peerId, id: peerId,
}, },
runtime: core.channel,
sessionStore: config.session?.store,
}); });
const fromLabel = message.isGroup ? message.target : senderDisplay; const fromLabel = message.isGroup ? message.target : senderDisplay;
const { storePath, body } = buildEnvelope({ const body = buildEnvelope({
channel: "IRC", channel: "IRC",
from: fromLabel, from: fromLabel,
timestamp: message.timestamp, timestamp: message.timestamp,
@@ -394,41 +395,44 @@ export async function handleIrcInbound(params: {
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt); const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config); const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = buildChannelInboundEventContext({
Body: body, channel: CHANNEL_ID,
RawBody: rawBody, accountId: route.accountId,
CommandBody: rawBody, messageId: message.messageId,
From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`, timestamp: message.timestamp,
To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`, from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
SessionKey: route.sessionKey, sender: { id: senderDisplay, name: message.senderNick || undefined },
AccountId: route.accountId, conversation: {
ChatType: message.isGroup ? "group" : "direct", kind: message.isGroup ? "group" : "direct",
ConversationLabel: fromLabel, id: peerId,
SenderName: message.senderNick || undefined, label: fromLabel,
SenderId: senderDisplay, },
GroupSubject: message.isGroup ? message.target : undefined, route: {
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined, agentId: route.agentId,
Provider: CHANNEL_ID, accountId: route.accountId,
Surface: CHANNEL_ID, routeSessionKey: route.sessionKey,
WasMentioned: message.isGroup ? wasMentioned : undefined, },
MessageSid: message.messageId, reply: {
Timestamp: message.timestamp, to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
OriginatingChannel: CHANNEL_ID, originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`, },
CommandAuthorized: commandAuthorized, 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,
},
}); });
await core.channel.inbound.dispatchReply({ await core.channel.inbound.dispatch({
cfg: config as OpenClawConfig, cfg: config as OpenClawConfig,
channel: CHANNEL_ID, channel: CHANNEL_ID,
accountId: account.accountId, accountId: account.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
deliver: async (payload) => { deliver: async (payload) => {
await deliverIrcReply({ await deliverIrcReply({
+1 -6
View File
@@ -191,13 +191,8 @@ export async function monitorLineProvider(
cfg: config, cfg: config,
channel: "line", channel: "line",
accountId: route.accountId, accountId: route.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath: ctx.turn.storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
record: ctx.turn.record, record: ctx.turn.record,
replyPipeline: {}, replyPipeline: {},
...(deliveryControl.abortSignal ...(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 // stays pending until the script's final/cancel step settles it, so the
// handler's post-dispatch flow (including the finally-block draft abandon // handler's post-dispatch flow (including the finally-block draft abandon
// path) runs exactly where the real run would settle. // path) runs exactly where the real run would settle.
@@ -188,7 +188,7 @@ async function setupMatrixTrace(recorder: WireRecorder) {
markRunComplete: () => {}, markRunComplete: () => {},
}; };
}, },
dispatchReplyFromConfig: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => { dispatchInboundMessage: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
capturedReplyOptions = args?.replyOptions; capturedReplyOptions = args?.replyOptions;
notifyCaptured(); notifyCaptured();
const result = await runGate; const result = await runGate;
@@ -42,7 +42,6 @@ function createAudioPreflightHarness(
matchedBy: "binding.account", matchedBy: "binding.account",
}), }),
resolveStorePath: () => "/tmp/openclaw-test-session.json", resolveStorePath: () => "/tmp/openclaw-test-session.json",
readSessionUpdatedAt: () => 123,
getRoomInfo: async () => ({ getRoomInfo: async () => ({
name: "Audio Room", name: "Audio Room",
canonicalAlias: "#audio:example.org", canonicalAlias: "#audio:example.org",
@@ -110,7 +110,7 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
groupPolicy: "open", groupPolicy: "open",
isDirectMessage: false, isDirectMessage: false,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -119,24 +119,17 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
}) => { }) => {
capturedOnError = params?.onError; capturedOnError = params?.onError;
return { return {
dispatcher: {}, dispatcher: {
markComplete: () => {},
waitForIdle: async () => {
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
},
},
replyOptions: {}, replyOptions: {},
markDispatchIdle: () => {}, markDispatchIdle: () => {},
markRunComplete: () => {}, 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", groupPolicy: "open",
isDirectMessage: false, isDirectMessage: false,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -200,7 +193,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
isDirectMessage: false, isDirectMessage: false,
threadReplies: "off", threadReplies: "off",
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -233,7 +226,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
isDirectMessage: false, isDirectMessage: false,
threadReplies: "always", threadReplies: "always",
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -267,7 +260,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
isDirectMessage: false, isDirectMessage: false,
finalizeInboundContext, finalizeInboundContext,
resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)), resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)),
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -313,7 +306,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
groupPolicy: "open", groupPolicy: "open",
isDirectMessage: false, isDirectMessage: false,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -341,7 +334,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
groupPolicy: "open", groupPolicy: "open",
isDirectMessage: false, isDirectMessage: false,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -371,7 +364,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
return "@bot:example.org"; return "@bot:example.org";
}, },
}, },
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -394,7 +387,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
historyLimit: 20, historyLimit: 20,
isDirectMessage: true, isDirectMessage: true,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -428,7 +421,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
historyLimit: 20, historyLimit: 20,
isDirectMessage: true, isDirectMessage: true,
getMemberDisplayName, getMemberDisplayName,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -458,7 +451,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
groupPolicy: "open", groupPolicy: "open",
isDirectMessage: false, isDirectMessage: false,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -520,7 +513,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
getRelations, getRelations,
}, },
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -560,7 +553,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
let firstDispatchStarted = false; let firstDispatchStarted = false;
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx); const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const dispatchReplyFromConfig = vi.fn(async () => { const dispatchInboundMessage = vi.fn(async () => {
if (!firstDispatchStarted) { if (!firstDispatchStarted) {
firstDispatchStarted = true; firstDispatchStarted = true;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@@ -575,7 +568,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
groupPolicy: "open", groupPolicy: "open",
isDirectMessage: false, isDirectMessage: false,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig, dispatchInboundMessage,
}); });
// Step 1: trigger msg A — don't await, let it block in dispatch // 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, isDirectMessage: false,
getMemberDisplayName, getMemberDisplayName,
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, 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" }), getEvent: async () => ({ sender: "@bot:example.org" }),
}, },
finalizeInboundContext, finalizeInboundContext,
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
}), }),
@@ -42,7 +42,6 @@ function createMediaFailureHarness() {
matchedBy: "binding.account", matchedBy: "binding.account",
}), }),
resolveStorePath: () => "/tmp/openclaw-test-session.json", resolveStorePath: () => "/tmp/openclaw-test-session.json",
readSessionUpdatedAt: () => 123,
getRoomInfo: async () => ({ getRoomInfo: async () => ({
name: "Media Room", name: "Media Room",
canonicalAlias: "#media:example.org", canonicalAlias: "#media:example.org",
@@ -14,6 +14,15 @@ import { createMatrixRoomMessageHandler } from "./handler.js";
import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js"; import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js";
type MatrixMonitorHandlerParams = Parameters<typeof createMatrixRoomMessageHandler>[0]; 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 = { const DEFAULT_ROUTE = {
agentId: "ops", agentId: "ops",
@@ -68,9 +77,7 @@ type MatrixHandlerTestHarnessOptions = {
resolveMarkdownTableMode?: () => string; resolveMarkdownTableMode?: () => string;
resolveAgentRoute?: () => typeof DEFAULT_ROUTE; resolveAgentRoute?: () => typeof DEFAULT_ROUTE;
resolveStorePath?: () => string; resolveStorePath?: () => string;
readSessionUpdatedAt?: () => number | undefined;
recordInboundSession?: (...args: unknown[]) => Promise<void>; recordInboundSession?: (...args: unknown[]) => Promise<void>;
resolveEnvelopeFormatOptions?: () => Record<string, never>;
formatAgentEnvelope?: ({ body }: { body: string }) => string; formatAgentEnvelope?: ({ body }: { body: string }) => string;
finalizeInboundContext?: (ctx: unknown) => unknown; finalizeInboundContext?: (ctx: unknown) => unknown;
createReplyDispatcherWithTyping?: (params?: { createReplyDispatcherWithTyping?: (params?: {
@@ -82,19 +89,8 @@ type MatrixHandlerTestHarnessOptions = {
markRunComplete: () => void; markRunComplete: () => void;
}; };
resolveHumanDelayConfig?: () => undefined; resolveHumanDelayConfig?: () => undefined;
dispatchReplyFromConfig?: () => Promise<{ dispatchInboundMessage?: MatrixDispatchInboundMessage;
queuedFinal: boolean;
counts: { final: number; block: number; tool: number };
}>;
runPrepared?: MatrixRunPreparedMock; runPrepared?: MatrixRunPreparedMock;
withReplyDispatcher?: <T>(params: {
dispatcher: {
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => Promise<T>;
inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"]; inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"];
shouldAckReaction?: () => boolean; shouldAckReaction?: () => boolean;
enqueueSystemEvent?: (...args: unknown[]) => void; enqueueSystemEvent?: (...args: unknown[]) => void;
@@ -104,10 +100,7 @@ type MatrixHandlerTestHarnessOptions = {
}; };
type MatrixHandlerTestHarness = { type MatrixHandlerTestHarness = {
dispatchReplyFromConfig: () => Promise<{ dispatchInboundMessage: MatrixDispatchInboundMessage;
queuedFinal: boolean;
counts: { final: number; block: number; tool: number };
}>;
enqueueSystemEvent: (...args: unknown[]) => void; enqueueSystemEvent: (...args: unknown[]) => void;
finalizeInboundContext: (ctx: unknown) => unknown; finalizeInboundContext: (ctx: unknown) => unknown;
handler: ReturnType<typeof createMatrixRoomMessageHandler>; handler: ReturnType<typeof createMatrixRoomMessageHandler>;
@@ -137,12 +130,55 @@ export function createMatrixHandlerTestHarness(
? finalizeCoreInboundContext(ctx as Record<string, unknown>) ? finalizeCoreInboundContext(ctx as Record<string, unknown>)
: ctx, : ctx,
); );
const dispatchReplyFromConfig = const dispatchInboundMessage =
options.dispatchReplyFromConfig ?? options.dispatchInboundMessage ??
(async () => ({ (async () => ({
queuedFinal: false, queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 }, 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 enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn();
const runPrepared = const runPrepared =
options.runPrepared ?? options.runPrepared ??
@@ -184,7 +220,16 @@ export function createMatrixHandlerTestHarness(
: (preflightResult ?? {}); : (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight); const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) { 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"); throw new Error("matrix test helper only supports prepared turn dispatch");
}, },
@@ -233,47 +278,19 @@ export function createMatrixHandlerTestHarness(
buildMentionRegexes: () => options.mentionRegexes ?? [], buildMentionRegexes: () => options.mentionRegexes ?? [],
}, },
session: { session: {
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
readSessionUpdatedAt: options.readSessionUpdatedAt ?? (() => undefined),
recordInboundSession, recordInboundSession,
}, },
reply: { reply: {
resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})), settleReplyDispatcher: async ({
formatAgentEnvelope: dispatcher,
options.formatAgentEnvelope ?? (({ body }: { body: string }) => body), onSettled,
finalizeInboundContext, }: Parameters<
createReplyDispatcherWithTyping: MatrixMonitorHandlerParams["core"]["channel"]["reply"]["settleReplyDispatcher"]
options.createReplyDispatcherWithTyping ?? >[0]) => {
(() => ({ dispatcher.markComplete?.();
dispatcher: {}, await dispatcher.waitForIdle?.();
replyOptions: {}, await onSettled?.();
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 {
dispatcher.markComplete?.();
try {
await dispatcher.waitForIdle?.();
} finally {
await onSettled?.();
}
}
}),
}, },
inbound: { inbound: {
run, run,
@@ -332,11 +349,16 @@ export function createMatrixHandlerTestHarness(
getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"), getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"),
needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false, needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false,
resolveLiveUserAllowlist: options.resolveLiveUserAllowlist, resolveLiveUserAllowlist: options.resolveLiveUserAllowlist,
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
createChannelInboundEnvelopeBuilder,
finalizeInboundContext,
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
dispatchInboundMessageWithBufferedDispatcher,
historyLimit: options.historyLimit ?? 0, historyLimit: options.historyLimit ?? 0,
}); });
return { return {
dispatchReplyFromConfig, dispatchInboundMessage,
enqueueSystemEvent, enqueueSystemEvent,
finalizeInboundContext, finalizeInboundContext,
handler, handler,
@@ -11,7 +11,6 @@ import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { import {
createMatrixHandlerTestHarness, createMatrixHandlerTestHarness,
createMatrixReactionEvent, createMatrixReactionEvent,
@@ -527,6 +526,10 @@ describe("matrix monitor handler pairing account scope", () => {
getMemberDisplayName: async () => "sender", getMemberDisplayName: async () => "sender",
dropPreStartupMessages: true, dropPreStartupMessages: true,
needsRoomAliasesForConfig: false, needsRoomAliasesForConfig: false,
dispatchInboundMessage: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
}); });
await handler( await handler(
@@ -580,12 +583,12 @@ describe("matrix monitor handler pairing account scope", () => {
}); });
it("does not enqueue delivered text messages into system events", async () => { it("does not enqueue delivered text messages into system events", async () => {
const dispatchReplyFromConfig = vi.fn(async () => ({ const dispatchInboundMessage = vi.fn(async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
})); }));
const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({ const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({
dispatchReplyFromConfig, dispatchInboundMessage,
isDirectMessage: true, isDirectMessage: true,
getMemberDisplayName: async () => "sender", getMemberDisplayName: async () => "sender",
}); });
@@ -599,7 +602,7 @@ describe("matrix monitor handler pairing account scope", () => {
}), }),
); );
expect(dispatchReplyFromConfig).toHaveBeenCalled(); expect(dispatchInboundMessage).toHaveBeenCalled();
expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(enqueueSystemEvent).not.toHaveBeenCalled();
}); });
@@ -1275,7 +1278,7 @@ describe("matrix monitor handler pairing account scope", () => {
resolveNotice = resolve; resolveNotice = resolve;
}); });
const sendNotice = vi.fn(() => noticeSent); const sendNotice = vi.fn(() => noticeSent);
const dispatchReplyFromConfig = vi.fn(async () => ({ const dispatchInboundMessage = vi.fn(async () => ({
counts: { block: 0, final: 0, tool: 0 }, counts: { block: 0, final: 0, tool: 0 },
queuedFinal: false, queuedFinal: false,
})); }));
@@ -1289,7 +1292,7 @@ describe("matrix monitor handler pairing account scope", () => {
}); });
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
dispatchReplyFromConfig, dispatchInboundMessage,
isDirectMessage: true, isDirectMessage: true,
resolveStorePath: () => storePath, resolveStorePath: () => storePath,
client: { client: {
@@ -1308,12 +1311,12 @@ describe("matrix monitor handler pairing account scope", () => {
await vi.waitFor(() => { await vi.waitFor(() => {
expect(sendNotice).toHaveBeenCalledTimes(1); expect(sendNotice).toHaveBeenCalledTimes(1);
}); });
expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); expect(dispatchInboundMessage).not.toHaveBeenCalled();
resolveNotice?.("$notice"); resolveNotice?.("$notice");
await handled; await handled;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
} finally { } finally {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }
@@ -1613,7 +1616,7 @@ describe("matrix monitor handler pairing account scope", () => {
altAliases: ["#alt:example.org"], altAliases: ["#alt:example.org"],
}), }),
getMemberDisplayName: async () => "sender", getMemberDisplayName: async () => "sender",
dispatchReplyFromConfig: async () => ({ dispatchInboundMessage: async () => ({
queuedFinal: false, queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 }, 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 () => { it("does not enqueue system events for delivered text replies", async () => {
const enqueueSystemEvent = vi.fn(); const enqueueSystemEvent = vi.fn();
const { handler } = createMatrixHandlerTestHarness({
const handler = createMatrixRoomMessageHandler({ enqueueSystemEvent,
client: { isDirectMessage: false,
getUserId: async () => "@bot:example.org", dispatchInboundMessage: async () => ({
} as never, queuedFinal: true,
core: { counts: { final: 1, block: 0, tool: 0 },
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 () => ({
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( await handler(
@@ -2177,7 +2072,7 @@ describe("matrix monitor handler pairing account scope", () => {
describe("matrix monitor handler live allowlist reload", () => { describe("matrix monitor handler live allowlist reload", () => {
type MatrixHandler = ReturnType<typeof createMatrixHandlerTestHarness>["handler"]; type MatrixHandler = ReturnType<typeof createMatrixHandlerTestHarness>["handler"];
const createDispatchReplyFromConfig = () => const createDispatchInboundMessage = () =>
vi.fn(async () => ({ vi.fn(async () => ({
queuedFinal: false, queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 }, counts: { final: 0, block: 0, tool: 0 },
@@ -2222,7 +2117,7 @@ describe("matrix monitor handler live allowlist reload", () => {
).length; ).length;
it("accepts a DM sender added to live dm.allowFrom", async () => { it("accepts a DM sender added to live dm.allowFrom", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = { const cfg = {
channels: { channels: {
matrix: { matrix: {
@@ -2236,7 +2131,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: [], allowFrom: [],
allowFromResolvedEntries: [], allowFromResolvedEntries: [],
dispatchReplyFromConfig, dispatchInboundMessage,
}); });
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2244,7 +2139,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); expect(dispatchInboundMessage).not.toHaveBeenCalled();
cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"]; cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"];
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2253,11 +2148,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again", body: "hello again",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
}); });
it("blocks a DM sender removed from live dm.allowFrom", async () => { it("blocks a DM sender removed from live dm.allowFrom", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = { const cfg = {
channels: { channels: {
matrix: { matrix: {
@@ -2271,7 +2166,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: ["@alice:example.org"], allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }], allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
dispatchReplyFromConfig, dispatchInboundMessage,
}); });
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2279,7 +2174,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = []; cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2288,11 +2183,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again", body: "hello again",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
}); });
it("blocks a DM sender after live wildcard removal", async () => { it("blocks a DM sender after live wildcard removal", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = { const cfg = {
channels: { channels: {
matrix: { matrix: {
@@ -2306,7 +2201,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: ["*"], allowFrom: ["*"],
allowFromResolvedEntries: [], allowFromResolvedEntries: [],
dispatchReplyFromConfig, dispatchInboundMessage,
}); });
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2314,7 +2209,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = []; cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2323,11 +2218,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again", body: "hello again",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
}); });
it("uses account-scoped live dm.allowFrom overrides", async () => { it("uses account-scoped live dm.allowFrom overrides", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = { const cfg = {
channels: { channels: {
matrix: { matrix: {
@@ -2347,7 +2242,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: ["@alice:example.org"], allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }], allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
dispatchReplyFromConfig, dispatchInboundMessage,
}); });
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2355,7 +2250,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.accounts.ops.dm.allowFrom = []; cfg.channels.matrix.accounts.ops.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2364,11 +2259,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again", 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 () => { it("keeps startup-resolved display names only while the raw input remains configured", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = { const cfg = {
channels: { channels: {
matrix: { matrix: {
@@ -2383,7 +2278,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: ["@alice:example.org"], allowFrom: ["@alice:example.org"],
allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }], allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }],
dispatchReplyFromConfig, dispatchInboundMessage,
}); });
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2391,7 +2286,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dm.allowFrom = []; cfg.channels.matrix.dm.allowFrom = [];
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2400,11 +2295,11 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "hello again", body: "hello again",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
}); });
it("accepts a DM sender added as a live-resolved display name", async () => { it("accepts a DM sender added as a live-resolved display name", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const dispatchInboundMessage = createDispatchInboundMessage();
const resolveLiveUserAllowlist = vi.fn( const resolveLiveUserAllowlist = vi.fn(
async (params: { entries?: ReadonlyArray<string | number> }) => { async (params: { entries?: ReadonlyArray<string | number> }) => {
const entries = (params.entries ?? []).map(String); const entries = (params.entries ?? []).map(String);
@@ -2425,7 +2320,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: [], allowFrom: [],
allowFromResolvedEntries: [], allowFromResolvedEntries: [],
dispatchReplyFromConfig, dispatchInboundMessage,
resolveLiveUserAllowlist, resolveLiveUserAllowlist,
}); });
@@ -2434,7 +2329,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); expect(dispatchInboundMessage).not.toHaveBeenCalled();
cfg.channels.matrix.dm.allowFrom = ["Alice"]; cfg.channels.matrix.dm.allowFrom = ["Alice"];
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2449,11 +2344,11 @@ describe("matrix monitor handler live allowlist reload", () => {
); );
expect(liveAllowlistRequest.accountId).toBe("ops"); expect(liveAllowlistRequest.accountId).toBe("ops");
expect(liveAllowlistRequest.entries).toEqual(["Alice"]); 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 () => { 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) => const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [], isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
); );
@@ -2471,7 +2366,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: [], allowFrom: [],
allowFromResolvedEntries: [], allowFromResolvedEntries: [],
dispatchReplyFromConfig, dispatchInboundMessage,
resolveLiveUserAllowlist, resolveLiveUserAllowlist,
}); });
@@ -2480,7 +2375,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.dangerouslyAllowNameMatching = false; cfg.channels.matrix.dangerouslyAllowNameMatching = false;
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2492,11 +2387,11 @@ describe("matrix monitor handler live allowlist reload", () => {
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe( expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
2, 2,
); );
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
}); });
it("refreshes cached live display-name allowlists when name matching is enabled", async () => { 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) => const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [], isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
); );
@@ -2514,7 +2409,7 @@ describe("matrix monitor handler live allowlist reload", () => {
isDirectMessage: true, isDirectMessage: true,
allowFrom: [], allowFrom: [],
allowFromResolvedEntries: [], allowFromResolvedEntries: [],
dispatchReplyFromConfig, dispatchInboundMessage,
resolveLiveUserAllowlist, resolveLiveUserAllowlist,
}); });
@@ -2523,7 +2418,7 @@ describe("matrix monitor handler live allowlist reload", () => {
sender: "@alice:example.org", sender: "@alice:example.org",
body: "hello", body: "hello",
}); });
expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); expect(dispatchInboundMessage).not.toHaveBeenCalled();
cfg.channels.matrix.dangerouslyAllowNameMatching = true; cfg.channels.matrix.dangerouslyAllowNameMatching = true;
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2535,11 +2430,11 @@ describe("matrix monitor handler live allowlist reload", () => {
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe( expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
2, 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 () => { it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => {
const dispatchReplyFromConfig = createDispatchReplyFromConfig(); const dispatchInboundMessage = createDispatchInboundMessage();
const cfg = { const cfg = {
channels: { channels: {
matrix: { matrix: {
@@ -2557,7 +2452,7 @@ describe("matrix monitor handler live allowlist reload", () => {
{ input: "@alice:example.org", id: "@alice:example.org" }, { input: "@alice:example.org", id: "@alice:example.org" },
{ input: "@bob:example.org", id: "@bob:example.org" }, { input: "@bob:example.org", id: "@bob:example.org" },
], ],
dispatchReplyFromConfig, dispatchInboundMessage,
}); });
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2567,7 +2462,7 @@ describe("matrix monitor handler live allowlist reload", () => {
body: "@room hello", body: "@room hello",
mentions: { room: true }, mentions: { room: true },
}); });
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"]; cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"];
await sendLiveAllowlistMessage(handler, { await sendLiveAllowlistMessage(handler, {
@@ -2578,7 +2473,7 @@ describe("matrix monitor handler live allowlist reload", () => {
mentions: { room: true }, 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({ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
inboundDeduper, inboundDeduper,
dispatchReplyFromConfig: vi.fn(async () => ({ dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
})), })),
@@ -2631,7 +2526,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const recordInboundSession = vi.fn(async () => { const recordInboundSession = vi.fn(async () => {
callOrder.push("record"); callOrder.push("record");
}); });
const dispatchReplyFromConfig = vi.fn(async () => { const dispatchInboundMessage = vi.fn(async () => {
callOrder.push("dispatch"); callOrder.push("dispatch");
return { return {
queuedFinal: true, queuedFinal: true,
@@ -2641,7 +2536,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
inboundDeduper, inboundDeduper,
recordInboundSession, recordInboundSession,
dispatchReplyFromConfig, dispatchInboundMessage,
createReplyDispatcherWithTyping: () => ({ createReplyDispatcherWithTyping: () => ({
dispatcher: { dispatcher: {
markComplete: () => { markComplete: () => {
@@ -2673,9 +2568,9 @@ describe("matrix monitor handler durable inbound dedupe", () => {
"claim", "claim",
"record", "record",
"dispatch", "dispatch",
"run-complete",
"mark-complete", "mark-complete",
"wait-for-idle", "wait-for-idle",
"run-complete",
"dispatch-idle", "dispatch-idle",
"commit", "commit",
]); ]);
@@ -2742,7 +2637,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
recordInboundSession: vi.fn(async () => { recordInboundSession: vi.fn(async () => {
throw new Error("disk failed"); throw new Error("disk failed");
}), }),
dispatchReplyFromConfig: vi.fn(async () => ({ dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
})), })),
@@ -2776,7 +2671,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
inboundDeduper, inboundDeduper,
runtime: runtime as never, runtime: runtime as never,
dispatchReplyFromConfig: vi.fn(async () => ({ dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: true, queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 }, counts: { final: 1, block: 0, tool: 0 },
})), })),
@@ -2823,7 +2718,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
inboundDeduper, inboundDeduper,
runtime: runtime as never, runtime: runtime as never,
dispatchReplyFromConfig: vi.fn(async () => ({ dispatchInboundMessage: vi.fn(async () => ({
queuedFinal: false, queuedFinal: false,
counts: { counts: {
final: 0, final: 0,
@@ -2881,7 +2776,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
recordInboundSession: vi.fn(async () => { recordInboundSession: vi.fn(async () => {
callOrder.push("record"); callOrder.push("record");
}), }),
dispatchReplyFromConfig: vi.fn(async () => { dispatchInboundMessage: vi.fn(async () => {
callOrder.push("dispatch"); callOrder.push("dispatch");
return { return {
queuedFinal: false, queuedFinal: false,
@@ -3031,22 +2926,13 @@ describe("matrix monitor handler draft streaming", () => {
markRunComplete: () => {}, markRunComplete: () => {},
}; };
}, },
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
capturedReplyOpts = args?.replyOptions; capturedReplyOpts = args?.replyOptions;
notifyCaptured(); notifyCaptured();
// Block until the test is done exercising callbacks. // Block until the test is done exercising callbacks.
await runGate; await runGate;
return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } }; return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
}) as never, }) 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 () => { const dispatch = async () => {
@@ -4098,7 +3984,7 @@ describe("matrix monitor handler draft streaming", () => {
markDispatchIdle: () => {}, markDispatchIdle: () => {},
markRunComplete: () => {}, markRunComplete: () => {},
}), }),
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
capturedReplyOpts = args?.replyOptions; capturedReplyOpts = args?.replyOptions;
// Simulate streaming then model error. // Simulate streaming then model error.
capturedReplyOpts?.onPartialReply?.({ text: "partial" }); capturedReplyOpts?.onPartialReply?.({ text: "partial" });
@@ -4107,15 +3993,6 @@ describe("matrix monitor handler draft streaming", () => {
}); });
throw new Error("model timeout"); throw new Error("model timeout");
}) as never, }) 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). // Handler should not throw (outer catch absorbs it).
@@ -4156,7 +4033,7 @@ describe("matrix monitor handler draft streaming", () => {
markDispatchIdle: () => {}, markDispatchIdle: () => {},
markRunComplete: () => {}, markRunComplete: () => {},
}), }),
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
capturedReplyOpts = args?.replyOptions; capturedReplyOpts = args?.replyOptions;
capturedReplyOpts?.onPartialReply?.({ text: "partial" }); capturedReplyOpts?.onPartialReply?.({ text: "partial" });
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -4164,15 +4041,6 @@ describe("matrix monitor handler draft streaming", () => {
}); });
throw new Error("model timeout"); throw new Error("model timeout");
}) as never, }) 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( await handler(
@@ -4448,7 +4316,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
streaming: "off", streaming: "off",
dispatchReplyFromConfig: vi.fn( dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4469,7 +4337,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
streaming: "partial", streaming: "partial",
dispatchReplyFromConfig: vi.fn( dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4490,7 +4358,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
streaming: "quiet", streaming: "quiet",
dispatchReplyFromConfig: vi.fn( dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4512,7 +4380,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
streaming: "partial", streaming: "partial",
blockStreamingEnabled: true, blockStreamingEnabled: true,
dispatchReplyFromConfig: vi.fn( dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
@@ -4534,7 +4402,7 @@ describe("matrix monitor handler block streaming config", () => {
const { handler } = createMatrixHandlerTestHarness({ const { handler } = createMatrixHandlerTestHarness({
streaming: "off", streaming: "off",
blockStreamingEnabled: true, blockStreamingEnabled: true,
dispatchReplyFromConfig: vi.fn( dispatchInboundMessage: vi.fn(
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
+334 -349
View File
@@ -1,28 +1,27 @@
// Matrix plugin module implements handler behavior. import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { import {
buildChannelInboundEventContext, buildChannelInboundEventContext,
createChannelInboundEnvelopeBuilder,
hasFinalInboundReplyDispatch,
resolveInboundMentionDecision, resolveInboundMentionDecision,
toInboundMediaFacts, toInboundMediaFacts,
type ChannelBotLoopProtectionFacts,
} from "openclaw/plugin-sdk/channel-inbound"; } 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 { import {
type AgentPlanStep, type AgentPlanStep,
buildChannelProgressDraftLineForEntry, buildChannelProgressDraftLineForEntry,
createChannelProgressDraftGate,
type ChannelProgressDraftLine, type ChannelProgressDraftLine,
createChannelProgressDraftGate,
createPreviewMessageReceipt,
defineFinalizableLivePreviewAdapter,
deliverWithFinalizableLivePreviewAdapter,
formatChannelProgressDraftLine, formatChannelProgressDraftLine,
formatChannelProgressDraftText, formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName, isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine, mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity, normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLines, resolveChannelProgressDraftMaxLines,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound"; } from "openclaw/plugin-sdk/channel-outbound";
import { import {
evaluateSupplementalContextVisibility, evaluateSupplementalContextVisibility,
@@ -41,10 +40,13 @@ import {
buildTtsSupplementMediaPayload, buildTtsSupplementMediaPayload,
getReplyPayloadTtsSupplement, getReplyPayloadTtsSupplement,
} from "openclaw/plugin-sdk/reply-payload"; } 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 { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; 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 { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { import type {
@@ -228,6 +230,11 @@ type MatrixMonitorHandlerParams = {
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>; getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
needsRoomAliasesForConfig: boolean; needsRoomAliasesForConfig: boolean;
resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist; resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist;
resolveStorePath?: typeof resolveStorePath;
createChannelInboundEnvelopeBuilder?: typeof createChannelInboundEnvelopeBuilder;
finalizeInboundContext?: (ctx: Record<string, unknown>) => unknown;
resolveHumanDelayConfig?: typeof resolveHumanDelayConfig;
dispatchInboundMessageWithBufferedDispatcher?: typeof dispatchInboundMessageWithBufferedDispatcher;
}; };
function resolveMatrixMentionPrecheckText(params: { function resolveMatrixMentionPrecheckText(params: {
@@ -472,6 +479,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
getMemberDisplayName, getMemberDisplayName,
needsRoomAliasesForConfig, needsRoomAliasesForConfig,
resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist, resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist,
resolveStorePath: resolveStorePathImpl = resolveStorePath,
createChannelInboundEnvelopeBuilder:
createChannelInboundEnvelopeBuilderImpl = createChannelInboundEnvelopeBuilder,
finalizeInboundContext,
resolveHumanDelayConfig: resolveHumanDelayConfigImpl = resolveHumanDelayConfig,
dispatchInboundMessageWithBufferedDispatcher:
dispatchInboundMessageWithBufferedDispatcherImpl = dispatchInboundMessageWithBufferedDispatcher,
} = params; } = params;
const contextVisibilityMode = resolveChannelContextVisibilityMode({ const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg, cfg,
@@ -1536,14 +1550,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const roomName = roomInfo?.name; const roomName = roomInfo?.name;
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${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, agentId: _route.agentId,
}); });
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); const buildEnvelope = createChannelInboundEnvelopeBuilderImpl({ cfg, route: _route });
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: _route.sessionKey,
});
const sharedDmNoticeSessionKey = threadTarget const sharedDmNoticeSessionKey = threadTarget
? _route.mainSessionKey || _route.sessionKey ? _route.mainSessionKey || _route.sessionKey
: _route.sessionKey; : _route.sessionKey;
@@ -1560,12 +1570,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
logVerboseMessage, logVerboseMessage,
}) })
: null; : null;
const body = core.channel.reply.formatAgentEnvelope({ const body = buildEnvelope({
channel: "Matrix", channel: "Matrix",
from: envelopeFrom, from: envelopeFrom,
timestamp: eventTs ?? undefined, timestamp: eventTs ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId, body: textWithId,
}); });
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt); const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
@@ -1579,8 +1587,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
); );
const ctxPayload = buildChannelInboundEventContext({ const ctxPayload = buildChannelInboundEventContext({
channel: "matrix", channel: "matrix",
finalize: core.channel.reply.finalizeInboundContext,
contextVisibility: contextVisibilityMode, contextVisibility: contextVisibilityMode,
finalize: finalizeInboundContext,
supplemental: { supplemental: {
quote: replyContext quote: replyContext
? { ? {
@@ -2098,261 +2106,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
resetPreviewToolProgress(); resetPreviewToolProgress();
}; };
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = const dispatcherOptions = {
core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions,
...prefixOptions, humanDelay: resolveHumanDelayConfigImpl(cfg, _route.agentId),
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, _route.agentId), deliver: async (payload: ReplyPayload, info: { kind: string }) => {
deliver: async (payload: ReplyPayload, info: { kind: string }) => { if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) { const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; const ttsSupplement = getReplyPayloadTtsSupplement(payload);
const ttsSupplement = getReplyPayloadTtsSupplement(payload); const fallbackPayload =
const fallbackPayload = ttsSupplement &&
ttsSupplement && ttsSupplement.visibleTextAlreadyDelivered !== true &&
ttsSupplement.visibleTextAlreadyDelivered !== true && !payload.text?.trim()
!payload.text?.trim() ? { ...payload, text: ttsSupplement.spokenText }
? { ...payload, text: ttsSupplement.spokenText } : payload;
: payload;
if (draftConsumed) { if (draftConsumed) {
await draftStream.discardPending(); await draftStream.discardPending();
await deliverMatrixReplies({
cfg,
replies: [fallbackPayload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
return;
}
const payloadReplyToId = normalizeOptionalString(payload.replyToId);
const payloadReplyMismatch =
replyToMode !== "off" &&
!threadTarget &&
payloadReplyToId !== currentDraftReplyToId;
let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
const canPotentiallyFinalizeDraft =
Boolean(payload.text?.trim()) &&
!payload.isError &&
!payloadReplyMismatch &&
!mustDeliverFinalNormally;
if (canPotentiallyFinalizeDraft) {
await draftStream.stop();
mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
} else {
await draftStream.discardPending();
}
const draftEventId = draftStream.eventId();
const draftFinalTextNeedsNormalMentionDelivery =
Boolean(draftEventId) &&
typeof payload.text === "string" &&
Boolean(payload.text.trim()) &&
!payload.isError &&
!payloadReplyMismatch &&
!mustDeliverFinalNormally &&
(await matrixTextWouldActivateMentions(client, payload.text));
if (
draftEventId &&
payload.text &&
!payload.isError &&
!hasMedia &&
!payloadReplyMismatch &&
!mustDeliverFinalNormally &&
!draftFinalTextNeedsNormalMentionDelivery
) {
const finalPreviewText = payload.text;
await deliverWithFinalizableLivePreviewAdapter<
ReplyPayload,
string,
{
text: string;
finalizeLive: boolean;
extraContent?: Record<string, unknown>;
}
>({
kind: "final",
payload,
adapter: defineFinalizableLivePreviewAdapter({
draft: {
flush: async () => {},
clear: async () => {},
discardPending: async () => {},
id: () => draftEventId,
},
buildFinalEdit: () => ({
text: finalPreviewText,
finalizeLive: !(
quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText)
),
...(quietDraftStreaming
? { extraContent: buildMatrixFinalizedPreviewContent() }
: {}),
}),
editFinal: async (_draftEventId, edit) => {
if (edit.finalizeLive) {
if (!(await draftStream.finalizeLive())) {
throw new Error("Matrix draft live finalize failed");
}
return;
}
const { editMessageMatrix } = await loadMatrixSendModule();
await editMessageMatrix(roomId, _draftEventId, edit.text, {
client,
cfg,
threadId: threadTarget,
accountId: _route.accountId,
extraContent: edit.extraContent,
});
},
createPreviewReceipt: (id): MessageReceipt =>
createPreviewMessageReceipt({
id,
...(threadTarget ? { threadId: threadTarget } : {}),
...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}),
}),
logPreviewEditFailure: (err) => {
logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`);
},
}),
deliverNormally: async () => {
await redactMatrixDraftEvent(client, roomId, draftEventId);
await deliverMatrixReplies({
cfg,
replies: [fallbackPayload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
},
});
draftConsumed = true;
} else if (draftEventId && hasMedia && !payloadReplyMismatch) {
let textEditOk = !mustDeliverFinalNormally;
const payloadText = payload.text ?? ttsSupplement?.spokenText;
const payloadTextMatchesDraft =
typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText);
const reusesDraftTextUnchanged =
typeof payloadText === "string" &&
Boolean(payloadText.trim()) &&
payloadTextMatchesDraft;
const mediaTextNeedsNormalMentionDelivery =
typeof payloadText === "string" &&
Boolean(payloadText.trim()) &&
(await matrixTextWouldActivateMentions(client, payloadText));
const requiresFinalTextEdit =
quietDraftStreaming ||
(typeof payloadText === "string" && !payloadTextMatchesDraft);
if (textEditOk && mediaTextNeedsNormalMentionDelivery) {
textEditOk = false;
} else if (textEditOk && payloadText && requiresFinalTextEdit) {
const { editMessageMatrix } = await loadMatrixSendModule();
textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
client,
cfg,
threadId: threadTarget,
accountId: _route.accountId,
extraContent: quietDraftStreaming
? buildMatrixFinalizedPreviewContent()
: undefined,
}).then(
() => true,
() => false,
);
} else if (textEditOk && reusesDraftTextUnchanged) {
textEditOk = await draftStream.finalizeLive();
}
const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk;
if (!reusesDraftAsFinalText) {
await redactMatrixDraftEvent(client, roomId, draftEventId);
}
const mediaPayload =
ttsSupplement && reusesDraftAsFinalText
? buildTtsSupplementMediaPayload(payload)
: {
...payload,
text: reusesDraftAsFinalText
? undefined
: (payload.text ??
(ttsSupplement?.visibleTextAlreadyDelivered === true
? undefined
: ttsSupplement?.spokenText)),
};
await deliverMatrixReplies({
cfg,
replies: [mediaPayload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
draftConsumed = true;
} else {
const draftRedacted =
Boolean(draftEventId) &&
(payload.isError ||
payloadReplyMismatch ||
mustDeliverFinalNormally ||
draftFinalTextNeedsNormalMentionDelivery);
if (draftRedacted && draftEventId) {
await redactMatrixDraftEvent(client, roomId, draftEventId);
}
const deliveredFallback = await deliverMatrixReplies({
cfg,
replies: [fallbackPayload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
if (draftRedacted || deliveredFallback) {
draftConsumed = true;
}
}
if (info.kind === "block") {
draftConsumed = false;
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
draftStream.reset();
currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined;
updateDraftFromLatestFullText();
// Re-assert typing so the user still sees the indicator while
// the next block generates.
const { sendTypingMatrix } = await loadMatrixSendModule();
await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
}
} else {
await deliverMatrixReplies({ await deliverMatrixReplies({
cfg, cfg,
replies: [payload], replies: [fallbackPayload],
roomId, roomId,
client, client,
runtime, runtime,
@@ -2364,22 +2136,255 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
mediaLocalRoots, mediaLocalRoots,
tableMode, tableMode,
}); });
return;
} }
},
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => { const payloadReplyToId = normalizeOptionalString(payload.replyToId);
if (info.kind === "final") { const payloadReplyMismatch =
finalReplyDeliveryFailed = true; replyToMode !== "off" && !threadTarget && payloadReplyToId !== currentDraftReplyToId;
let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
const canPotentiallyFinalizeDraft =
Boolean(payload.text?.trim()) &&
!payload.isError &&
!payloadReplyMismatch &&
!mustDeliverFinalNormally;
if (canPotentiallyFinalizeDraft) {
await draftStream.stop();
mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
} else { } else {
nonFinalReplyDeliveryFailed = true; await draftStream.discardPending();
} }
const draftEventId = draftStream.eventId();
const draftFinalTextNeedsNormalMentionDelivery =
Boolean(draftEventId) &&
typeof payload.text === "string" &&
Boolean(payload.text.trim()) &&
!payload.isError &&
!payloadReplyMismatch &&
!mustDeliverFinalNormally &&
(await matrixTextWouldActivateMentions(client, payload.text));
if (
draftEventId &&
payload.text &&
!payload.isError &&
!hasMedia &&
!payloadReplyMismatch &&
!mustDeliverFinalNormally &&
!draftFinalTextNeedsNormalMentionDelivery
) {
const finalPreviewText = payload.text;
await deliverWithFinalizableLivePreviewAdapter<
ReplyPayload,
string,
{
text: string;
finalizeLive: boolean;
extraContent?: Record<string, unknown>;
}
>({
kind: "final",
payload,
adapter: defineFinalizableLivePreviewAdapter({
draft: {
flush: async () => {},
clear: async () => {},
discardPending: async () => {},
id: () => draftEventId,
},
buildFinalEdit: () => ({
text: finalPreviewText,
finalizeLive: !(
quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText)
),
...(quietDraftStreaming
? { extraContent: buildMatrixFinalizedPreviewContent() }
: {}),
}),
editFinal: async (_draftEventId, edit) => {
if (edit.finalizeLive) {
if (!(await draftStream.finalizeLive())) {
throw new Error("Matrix draft live finalize failed");
}
return;
}
const { editMessageMatrix } = await loadMatrixSendModule();
await editMessageMatrix(roomId, _draftEventId, edit.text, {
client,
cfg,
threadId: threadTarget,
accountId: _route.accountId,
extraContent: edit.extraContent,
});
},
createPreviewReceipt: (id): MessageReceipt =>
createPreviewMessageReceipt({
id,
...(threadTarget ? { threadId: threadTarget } : {}),
...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}),
}),
logPreviewEditFailure: (err) => {
logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`);
},
}),
deliverNormally: async () => {
await redactMatrixDraftEvent(client, roomId, draftEventId);
await deliverMatrixReplies({
cfg,
replies: [fallbackPayload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
},
});
draftConsumed = true;
} else if (draftEventId && hasMedia && !payloadReplyMismatch) {
let textEditOk = !mustDeliverFinalNormally;
const payloadText = payload.text ?? ttsSupplement?.spokenText;
const payloadTextMatchesDraft =
typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText);
const reusesDraftTextUnchanged =
typeof payloadText === "string" &&
Boolean(payloadText.trim()) &&
payloadTextMatchesDraft;
const mediaTextNeedsNormalMentionDelivery =
typeof payloadText === "string" &&
Boolean(payloadText.trim()) &&
(await matrixTextWouldActivateMentions(client, payloadText));
const requiresFinalTextEdit =
quietDraftStreaming ||
(typeof payloadText === "string" && !payloadTextMatchesDraft);
if (textEditOk && mediaTextNeedsNormalMentionDelivery) {
textEditOk = false;
} else if (textEditOk && payloadText && requiresFinalTextEdit) {
const { editMessageMatrix } = await loadMatrixSendModule();
textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
client,
cfg,
threadId: threadTarget,
accountId: _route.accountId,
extraContent: quietDraftStreaming
? buildMatrixFinalizedPreviewContent()
: undefined,
}).then(
() => true,
() => false,
);
} else if (textEditOk && reusesDraftTextUnchanged) {
textEditOk = await draftStream.finalizeLive();
}
const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk;
if (!reusesDraftAsFinalText) {
await redactMatrixDraftEvent(client, roomId, draftEventId);
}
const mediaPayload =
ttsSupplement && reusesDraftAsFinalText
? buildTtsSupplementMediaPayload(payload)
: {
...payload,
text: reusesDraftAsFinalText
? undefined
: (payload.text ??
(ttsSupplement?.visibleTextAlreadyDelivered === true
? undefined
: ttsSupplement?.spokenText)),
};
await deliverMatrixReplies({
cfg,
replies: [mediaPayload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
draftConsumed = true;
} else {
const draftRedacted =
Boolean(draftEventId) &&
(payload.isError ||
payloadReplyMismatch ||
mustDeliverFinalNormally ||
draftFinalTextNeedsNormalMentionDelivery);
if (draftRedacted && draftEventId) {
await redactMatrixDraftEvent(client, roomId, draftEventId);
}
const deliveredFallback = await deliverMatrixReplies({
cfg,
replies: [fallbackPayload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
if (draftRedacted || deliveredFallback) {
draftConsumed = true;
}
}
if (info.kind === "block") { if (info.kind === "block") {
draftConsumed = false;
advanceDraftBlockBoundary({ fallbackToLatestEnd: true }); advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
draftStream.reset();
currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined;
updateDraftFromLatestFullText();
// Re-assert typing so the user still sees the indicator while
// the next block generates.
const { sendTypingMatrix } = await loadMatrixSendModule();
await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
} }
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); } else {
}, await deliverMatrixReplies({
onReplyStart: typingCallbacks.onReplyStart, cfg,
onIdle: typingCallbacks.onIdle, replies: [payload],
}); roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
replyToId: threadTarget ?? replyToEventId ?? undefined,
accountId: _route.accountId,
mediaLocalRoots,
tableMode,
});
}
},
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
if (info.kind === "final") {
finalReplyDeliveryFailed = true;
} else {
nonFinalReplyDeliveryFailed = true;
}
if (info.kind === "block") {
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
}
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
};
const pinnedMainDmOwner = isDirectMessage const pinnedMainDmOwner = isDirectMessage
? await (async () => { ? await (async () => {
const livePinnedCfg = core.config.current() as CoreConfig; const livePinnedCfg = core.config.current() as CoreConfig;
@@ -2422,12 +2427,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
raw: event, raw: event,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg,
channel: "matrix", channel: "matrix",
accountId: _route.accountId, accountId: _route.accountId,
routeSessionKey: _route.sessionKey, route: { agentId: _route.agentId, sessionKey: _route.sessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
botLoopProtection, botLoopProtection,
record: { record: {
updateLastRoute: isDirectMessage updateLastRoute: isDirectMessage
@@ -2464,14 +2468,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}); });
}, },
}, },
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
}),
runDispatch: async () => { runDispatch: async () => {
if ( if (
sharedDmContextNotice && sharedDmContextNotice &&
@@ -2489,61 +2485,50 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} }
} }
return await core.channel.reply.withReplyDispatcher({ return await dispatchInboundMessageWithBufferedDispatcherImpl({
dispatcher, ctx: ctxPayload,
onSettled: () => { cfg,
markDispatchIdle(); dispatcherOptions: {
...dispatcherOptions,
onSettled: () => progressDraftGate.cancel(),
}, },
run: async () => { replyOptions: {
try { skillFilter: roomConfig?.skills,
return await core.channel.reply.dispatchReplyFromConfig({ // Keep block streaming enabled when explicitly requested, even
ctx: ctxPayload, // with draft previews on. The draft remains the live preview
cfg, // for the current assistant block, while block deliveries
dispatcher, // finalize completed blocks into their own preserved events.
replyOptions: { disableBlockStreaming: !blockStreamingEnabled,
...replyOptions, onPartialReply: draftStream
skillFilter: roomConfig?.skills, ? (payload) => {
// Keep block streaming enabled when explicitly requested, even if (progressDraftStreaming) {
// with draft previews on. The draft remains the live preview return;
// for the current assistant block, while block deliveries }
// finalize completed blocks into their own preserved events. latestDraftFullText = payload.text ?? "";
disableBlockStreaming: !blockStreamingEnabled, suppressPreviewToolProgressForAnswerText(latestDraftFullText);
onPartialReply: draftStream updateDraftFromLatestFullText();
? (payload) => { }
if (progressDraftStreaming) { : undefined,
return; onBlockReplyQueued: draftStream
} ? (payload, context) => {
latestDraftFullText = payload.text ?? ""; if (payload.isCompactionNotice === true) {
suppressPreviewToolProgressForAnswerText(latestDraftFullText); return;
updateDraftFromLatestFullText(); }
} queueDraftBlockBoundary(payload, context);
: undefined, }
onBlockReplyQueued: draftStream : undefined,
? (payload, context) => { // Reset draft boundary bookkeeping on assistant message
if (payload.isCompactionNotice === true) { // boundaries so post-tool blocks stream from a fresh
return; // cumulative payload (payload.text resets upstream).
} onAssistantMessageStart: draftStream
queueDraftBlockBoundary(payload, context); ? () => {
} resetDraftBlockOffsets();
: undefined, resetPreviewToolProgress();
// Reset draft boundary bookkeeping on assistant message }
// boundaries so post-tool blocks stream from a fresh : undefined,
// cumulative payload (payload.text resets upstream). onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined,
onAssistantMessageStart: draftStream ...buildPreviewToolProgressReplyOptions(),
? () => { onModelSelected,
resetDraftBlockOffsets();
resetPreviewToolProgress();
}
: undefined,
onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined,
...buildPreviewToolProgressReplyOptions(),
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 type { CoreConfig } from "../types.js";
import { withAuthorizedMatrixReadTarget } from "./read-policy.js"; import { withAuthorizedMatrixReadTarget } from "./read-policy.js";
import type { MatrixClient } from "./sdk.js"; import type { MatrixClient } from "./sdk.js";
@@ -32,6 +33,10 @@ function createClient(
} }
describe("Matrix read policy", () => { describe("Matrix read policy", () => {
beforeEach(() => {
installMatrixTestRuntime();
});
it("allows configured rooms and rejects other rooms before the read", async () => { 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 client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
const cfg = { const cfg = {
@@ -1,4 +1,5 @@
// Matrix helper module supports formatting behavior. // Matrix helper module supports formatting behavior.
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
import { import {
markdownToMatrixHtml, markdownToMatrixHtml,
@@ -187,7 +188,7 @@ export function resolveMatrixVoiceDecision(opts: {
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean { function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
// Matrix currently shares the core voice compatibility policy. // Matrix currently shares the core voice compatibility policy.
// Keep this wrapper as the seam if Matrix policy diverges later. // Keep this wrapper as the seam if Matrix policy diverges later.
return getCore().media.isVoiceCompatibleAudio({ return isVoiceMessageCompatibleAudio({
contentType: opts.contentType, contentType: opts.contentType,
fileName: opts.fileName, fileName: opts.fileName,
}); });
@@ -80,9 +80,10 @@ class FakeWebSocket {
const mockState = vi.hoisted(() => ({ const mockState = vi.hoisted(() => ({
abortController: undefined as AbortController | undefined, abortController: undefined as AbortController | undefined,
createReplyDispatcherWithTyping: vi.fn(),
createMattermostClient: vi.fn(), createMattermostClient: vi.fn(),
createMattermostDraftStream: vi.fn(), createMattermostDraftStream: vi.fn(),
dispatchReplyFromConfig: vi.fn(), dispatchInboundMessage: vi.fn(),
enqueueSystemEvent: vi.fn(), enqueueSystemEvent: vi.fn(),
fetchMattermostMe: vi.fn(), fetchMattermostMe: vi.fn(),
registerMattermostMonitorSlashCommands: vi.fn(), registerMattermostMonitorSlashCommands: vi.fn(),
@@ -96,6 +97,22 @@ const mockState = vi.hoisted(() => ({
updateMattermostPost: vi.fn(), 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 () => { vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js"); const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return { return {
@@ -193,16 +210,43 @@ function createRuntimeCore(
type ReplyDispatcherOptions = { type ReplyDispatcherOptions = {
deliver: (payload: ReplyPayload, info: { kind: "tool" | "block" | "final" }) => Promise<void>; 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( const dispatchPreparedForTest = vi.fn(
async (turn: { async (turn: {
storePath: string; route: { agentId: string; sessionKey: string };
routeSessionKey: string;
ctxPayload: { SessionKey?: string }; ctxPayload: { SessionKey?: string };
recordInboundSession: (params: unknown) => Promise<void>;
record?: { record?: {
groupResolution?: unknown; groupResolution?: unknown;
createIfMissing?: boolean; createIfMissing?: boolean;
updateLastRoute?: unknown; updateLastRoute?: RecordInboundSessionInput["updateLastRoute"];
onRecordError?: (err: unknown) => void; onRecordError?: (err: unknown) => void;
}; };
runDispatch: () => Promise<{ runDispatch: () => Promise<{
@@ -210,9 +254,9 @@ function createRuntimeCore(
counts: { tool: number; block: number; final: number }; counts: { tool: number; block: number; final: number };
}>; }>;
}) => { }) => {
await turn.recordInboundSession({ await recordInboundSession({
storePath: turn.storePath, storePath: "/tmp/openclaw-test-sessions.json",
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
ctx: turn.ctxPayload, ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution, groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing, createIfMissing: turn.record?.createIfMissing,
@@ -224,7 +268,7 @@ function createRuntimeCore(
admission: { kind: "dispatch" as const }, admission: { kind: "dispatch" as const },
dispatched: true, dispatched: true,
ctxPayload: turn.ctxPayload, ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey, routeSessionKey: turn.route.sessionKey,
dispatchResult, dispatchResult,
}; };
}, },
@@ -304,25 +348,7 @@ function createRuntimeCore(
buildPairingReply: () => "pairing required", buildPairingReply: () => "pairing required",
}, },
reply: { reply: {
createReplyDispatcherWithTyping: vi.fn((options: ReplyDispatcherOptions) => ({ settleReplyDispatcher: vi.fn(async ({ onSettled }) => onSettled?.()),
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?.();
}
},
}, },
routing: { routing: {
resolveAgentRoute: () => ({ resolveAgentRoute: () => ({
@@ -335,26 +361,7 @@ function createRuntimeCore(
}, },
session: { session: {
resolveStorePath: () => "/tmp/openclaw-test-sessions.json", resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
recordInboundSession: vi.fn( recordInboundSession,
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;
};
}) => {},
),
updateLastRoute: vi.fn(async () => {}), updateLastRoute: vi.fn(async () => {}),
}, },
inbound: { inbound: {
@@ -457,7 +464,7 @@ describe("mattermost inbound user posts", () => {
mockState.resolveMattermostMedia.mockResolvedValue([]); mockState.resolveMattermostMedia.mockResolvedValue([]);
mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" }); mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
mockState.sendMessageMattermost.mockResolvedValue({}); mockState.sendMessageMattermost.mockResolvedValue({});
mockState.dispatchReplyFromConfig.mockImplementation(async () => { mockState.dispatchInboundMessage.mockImplementation(async () => {
mockState.abortController?.abort(); mockState.abortController?.abort();
}); });
}); });
@@ -503,8 +510,8 @@ describe("mattermost inbound user posts", () => {
await monitor; await monitor;
expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled(); expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello from mattermost"); expect(ctx?.BodyForAgent).toBe("hello from mattermost");
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1"); expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular"); expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular");
@@ -599,8 +606,8 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000); socket.emitClose(1000);
await monitor; await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("@openclaw"); expect(ctx?.BodyForAgent).toBe("@openclaw");
expect(ctx?.MessageSid).toBe("post-bare-mention"); expect(ctx?.MessageSid).toBe("post-bare-mention");
expect(ctx?.OriginatingChannel).toBe("mattermost"); expect(ctx?.OriginatingChannel).toBe("mattermost");
@@ -638,7 +645,7 @@ describe("mattermost inbound user posts", () => {
}, },
}; };
mockState.runtimeCore = createRuntimeCore(progressConfig); mockState.runtimeCore = createRuntimeCore(progressConfig);
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onToolStart?.({ await params.replyOptions?.onToolStart?.({
toolCallId: "read-1", toolCallId: "read-1",
name: "read", name: "read",
@@ -707,7 +714,7 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000); socket.emitClose(1000);
await monitor; 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(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(draftStream.clear).toHaveBeenCalledTimes(1); expect(draftStream.clear).toHaveBeenCalledTimes(1);
const updates = draftStream.update.mock.calls.map((call) => String(call[0])); const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
@@ -779,8 +786,8 @@ describe("mattermost inbound user posts", () => {
await monitor; await monitor;
expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig); expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig);
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello /status"); expect(ctx?.BodyForAgent).toBe("hello /status");
expect(ctx?.CommandAuthorized).toBe(false); expect(ctx?.CommandAuthorized).toBe(false);
// Inline non-control text must not be tagged as an explicit text-slash command turn — // 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); socket.emitClose(1000);
await monitor; await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("/reset"); expect(ctx?.BodyForAgent).toBe("/reset");
expect(ctx?.CommandBody).toBe("/reset"); expect(ctx?.CommandBody).toBe("/reset");
expect(ctx?.CommandAuthorized).toBe(true); expect(ctx?.CommandAuthorized).toBe(true);
@@ -913,8 +920,8 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000); socket.emitClose(1000);
await monitor; await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello with websocket kind"); expect(ctx?.BodyForAgent).toBe("hello with websocket kind");
expect(ctx?.ChatType).toBe("channel"); expect(ctx?.ChatType).toBe("channel");
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1"); expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
@@ -976,7 +983,7 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000); socket.emitClose(1000);
await monitor; await monitor;
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled(); expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled(); expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled();
}); });
@@ -1041,7 +1048,7 @@ describe("mattermost inbound user posts", () => {
user_id: "user-1", user_id: "user-1",
}, },
}); });
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled(); expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
await socket.emitMessage({ await socket.emitMessage({
event: "posted", event: "posted",
@@ -1066,8 +1073,8 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000); socket.emitClose(1000);
await monitor; await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("abort"); expect(ctx?.BodyForAgent).toBe("abort");
expect(ctx?.CommandAuthorized).toBe(true); expect(ctx?.CommandAuthorized).toBe(true);
}); });
@@ -1266,9 +1273,9 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000); socket.emitClose(1000);
await monitor; await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDraftStream).not.toHaveBeenCalled(); 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?.disableBlockStreaming).toBe(false);
expect(replyOptions?.preserveProgressCallbackStartOrder).toBeUndefined(); expect(replyOptions?.preserveProgressCallbackStartOrder).toBeUndefined();
}); });
@@ -1354,7 +1361,7 @@ describe("mattermost inbound user posts", () => {
let finalToolDraft = ""; let finalToolDraft = "";
let secondPartialArrivedBeforeBoundarySettled = false; let secondPartialArrivedBeforeBoundarySettled = false;
let finalDeliveryWaitedForBoundary = false; let finalDeliveryWaitedForBoundary = false;
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onAssistantMessageStart?.();
params.replyOptions?.onPartialReply?.({ text: "A much longer first block" }); params.replyOptions?.onPartialReply?.({ text: "A much longer first block" });
const firstToolStart = params.replyOptions?.onToolStart?.({ const firstToolStart = params.replyOptions?.onToolStart?.({
@@ -1427,8 +1434,7 @@ describe("mattermost inbound user posts", () => {
toolBeforeFinalBoundaryCount = forceNewMessage.mock.calls.length; toolBeforeFinalBoundaryCount = forceNewMessage.mock.calls.length;
finalToolDraft = String(draftUpdate.mock.calls.at(-1)?.[0] ?? ""); finalToolDraft = String(draftUpdate.mock.calls.at(-1)?.[0] ?? "");
const dispatcherOptions = const dispatcherOptions =
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
?.options;
const finalDelivery = dispatcherOptions?.deliver( const finalDelivery = dispatcherOptions?.deliver(
{ text: "Final without a partial" }, { text: "Final without a partial" },
{ kind: "final" }, { kind: "final" },
@@ -1462,14 +1468,14 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000); socket.emitClose(1000);
await monitor; await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
const draftStreamOptions = mockState.createMattermostDraftStream.mock.calls.at(0)?.[0] as const draftStreamOptions = mockState.createMattermostDraftStream.mock.calls.at(0)?.[0] as
| { chunkText?: (text: string) => string[] } | { chunkText?: (text: string) => string[] }
| undefined; | undefined;
chunkMarkdownTextWithMode.mockClear(); chunkMarkdownTextWithMode.mockClear();
expect(draftStreamOptions?.chunkText?.("first\n\nsecond")).toEqual(["first\n\nsecond"]); expect(draftStreamOptions?.chunkText?.("first\n\nsecond")).toEqual(["first\n\nsecond"]);
expect(chunkMarkdownTextWithMode).toHaveBeenCalledWith("first\n\nsecond", 1234, "newline"); 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?.disableBlockStreaming).toBe(true);
expect(replyOptions?.preserveProgressCallbackStartOrder).toBe(true); expect(replyOptions?.preserveProgressCallbackStartOrder).toBe(true);
expect(sameToolUpdateBoundaryCount).toBe(1); expect(sameToolUpdateBoundaryCount).toBe(1);
@@ -1540,14 +1546,13 @@ describe("mattermost inbound user posts", () => {
const socket = new FakeWebSocket(); const socket = new FakeWebSocket();
const abortController = new AbortController(); const abortController = new AbortController();
mockState.abortController = abortController; mockState.abortController = abortController;
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onAssistantMessageStart?.();
await params.replyOptions?.onPartialReply?.({ text: "First block" }); await params.replyOptions?.onPartialReply?.({ text: "First block" });
await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onAssistantMessageStart?.();
await params.replyOptions?.onPartialReply?.({ text: "Second block" }); await params.replyOptions?.onPartialReply?.({ text: "Second block" });
const dispatcherOptions = const dispatcherOptions =
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
?.options;
await dispatcherOptions?.deliver( await dispatcherOptions?.deliver(
{ text: "[bot] First block\n\nSecond block" }, { text: "[bot] First block\n\nSecond block" },
{ kind: "final" }, { kind: "final" },
@@ -1621,13 +1626,12 @@ describe("mattermost inbound user posts", () => {
const socket = new FakeWebSocket(); const socket = new FakeWebSocket();
const abortController = new AbortController(); const abortController = new AbortController();
mockState.abortController = abortController; mockState.abortController = abortController;
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { mockState.dispatchInboundMessage.mockImplementation(async (params) => {
await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onAssistantMessageStart?.();
await params.replyOptions?.onPartialReply?.({ text: "Only block" }); await params.replyOptions?.onPartialReply?.({ text: "Only block" });
await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onAssistantMessageStart?.();
const dispatcherOptions = const dispatcherOptions =
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
?.options;
await dispatcherOptions?.deliver({ text: "Only block" }, { kind: "final" }); await dispatcherOptions?.deliver({ text: "Only block" }, { kind: "final" });
abortController.abort(); abortController.abort();
}); });
+224 -243
View File
@@ -1,10 +1,17 @@
import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound";
// Mattermost plugin module implements monitor behavior. // 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 { import {
buildChannelProgressDraftLineForEntry, buildChannelProgressDraftLineForEntry,
createChannelProgressDraftCompositor, createChannelProgressDraftCompositor,
} from "openclaw/plugin-sdk/channel-outbound"; } from "openclaw/plugin-sdk/channel-outbound";
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; 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 { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -430,7 +437,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const to = const to =
kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`; kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`;
const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`; const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`;
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: bodyText, Body: bodyText,
BodyForAgent: bodyText, BodyForAgent: bodyText,
RawBody: bodyText, RawBody: bodyText,
@@ -497,55 +504,48 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
isDirect: kind === "direct", isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry, dmRetryOptions: account.config.dmChannelRetry,
}); });
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
core.channel.reply.createReplyDispatcherWithTyping({ ...replyPipeline,
...replyPipeline, resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, onDeliverySettled: deliveryBarrier.markDeliverySettled,
onDeliverySettled: deliveryBarrier.markDeliverySettled, humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => {
deliver: async (payload: ReplyPayload) => { await deliverMattermostReplyPayload({
await deliverMattermostReplyPayload({ core,
core,
cfg,
payload,
to,
accountId: account.accountId,
agentId: route.agentId,
replyToId: resolveMattermostReplyRootId({
kind,
threadRootId: threadContext.effectiveReplyToId,
replyToId: payload.replyToId,
}),
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
runtime.log?.(`delivered button-click reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks?.onReplyStart,
});
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg, cfg,
dispatcher, payload,
replyOptions: { to,
...replyOptions, accountId: account.accountId,
disableBlockStreaming: agentId: route.agentId,
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, replyToId: resolveMattermostReplyRootId({
onModelSelected, kind,
}, threadRootId: threadContext.effectiveReplyToId,
}), replyToId: payload.replyToId,
}),
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
runtime.log?.(`delivered button-click reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks?.onReplyStart,
});
await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
}); });
}, },
log: (msg) => runtime.log?.(msg), log: (msg) => runtime.log?.(msg),
@@ -632,7 +632,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
params.kind === "direct" params.kind === "direct"
? `Mattermost DM from ${params.senderName}` ? `Mattermost DM from ${params.senderName}`
: `Mattermost message in ${params.roomLabel} from ${params.senderName}`; : `Mattermost message in ${params.roomLabel} from ${params.senderName}`;
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: params.commandText, Body: params.commandText,
BodyForAgent: params.commandText, BodyForAgent: params.commandText,
RawBody: params.commandText, RawBody: params.commandText,
@@ -707,67 +707,60 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
isDirect: params.kind === "direct", isDirect: params.kind === "direct",
dmRetryOptions: account.config.dmChannelRetry, dmRetryOptions: account.config.dmChannelRetry,
}); });
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
core.channel.reply.createReplyDispatcherWithTyping({ ...replyPipeline,
...replyPipeline, resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, onDeliverySettled: deliveryBarrier.markDeliverySettled,
onDeliverySettled: deliveryBarrier.markDeliverySettled, // Picker-triggered confirmations should stay immediate.
// Picker-triggered confirmations should stay immediate. deliver: async (payload: ReplyPayload) => {
deliver: async (payload: ReplyPayload) => { const trimmedPayload = {
const trimmedPayload = { ...payload,
...payload, text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(), };
};
if (!shouldDeliverReplies) { if (!shouldDeliverReplies) {
if (trimmedPayload.text) { if (trimmedPayload.text) {
capturedTexts.push(trimmedPayload.text); capturedTexts.push(trimmedPayload.text);
}
return;
} }
return;
}
await deliverMattermostReplyPayload({ await deliverMattermostReplyPayload({
core, core,
cfg,
payload: trimmedPayload,
to,
accountId: account.accountId,
agentId: params.route.agentId,
replyToId: resolveMattermostReplyRootId({
kind: params.kind,
threadRootId: params.effectiveReplyToId,
replyToId: trimmedPayload.replyToId,
}),
textLimit,
// The picker path already converts and trims text before capture/delivery.
tableMode: "off",
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
},
onError: (err, info) => {
runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks?.onReplyStart,
});
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg, cfg,
dispatcher, payload: trimmedPayload,
replyOptions: { to,
...replyOptions, accountId: account.accountId,
disableBlockStreaming: agentId: params.route.agentId,
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, replyToId: resolveMattermostReplyRootId({
onModelSelected, kind: params.kind,
}, threadRootId: params.effectiveReplyToId,
}), replyToId: trimmedPayload.replyToId,
}),
textLimit,
// The picker path already converts and trims text before capture/delivery.
tableMode: "off",
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
},
onError: (err, info) => {
runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks?.onReplyStart,
});
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(); 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 textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
const body = core.channel.reply.formatInboundEnvelope({ const body = formatInboundEnvelope({
channel: "Mattermost", channel: "Mattermost",
from: fromLabel, from: fromLabel,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined, timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
@@ -1312,7 +1305,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
limit: historyLimit, limit: historyLimit,
currentMessage: combinedBody, currentMessage: combinedBody,
formatEntry: (entry) => formatEntry: (entry) =>
core.channel.reply.formatInboundEnvelope({ formatInboundEnvelope({
channel: "Mattermost", channel: "Mattermost",
from: fromLabel, from: fromLabel,
timestamp: entry.timestamp, timestamp: entry.timestamp,
@@ -1335,7 +1328,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
limit: historyLimit, limit: historyLimit,
}) })
: undefined; : undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: bodyForAgent, BodyForAgent: bodyForAgent,
InboundHistory: inboundHistory, InboundHistory: inboundHistory,
@@ -1389,10 +1382,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}) })
: null; : null;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const previewLine = truncateUtf16Safe(bodyText, 200).replace(/\n/g, "\\n"); const previewLine = truncateUtf16Safe(bodyText, 200).replace(/\n/g, "\\n");
logVerboseMessage( logVerboseMessage(
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`, `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
@@ -1591,11 +1580,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
dmRetryOptions: account.config.dmChannelRetry, dmRetryOptions: account.config.dmChannelRetry,
}); });
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({ createReplyDispatcherWithTyping({
...replyPipeline, ...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled, onDeliverySettled: deliveryBarrier.markDeliverySettled,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
typingCallbacks, typingCallbacks,
deliver: async (payloadEntry: ReplyPayload, info) => { deliver: async (payloadEntry: ReplyPayload, info) => {
if (info.kind === "final") { if (info.kind === "final") {
@@ -1715,12 +1704,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
raw: post, raw: post,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg,
channel: "mattermost", channel: "mattermost",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, route: { agentId: route.agentId, sessionKey: route.sessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: { record: {
updateLastRoute: updateLastRoute:
kind === "direct" kind === "direct"
@@ -1772,131 +1760,124 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}); });
}, },
runDispatch: () => runDispatch: () =>
core.channel.reply.withReplyDispatcher({ dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher, dispatcher,
onSettled: () => { onSettled: () => markDispatchIdle(),
markDispatchIdle(); replyOptions: {
}, ...replyOptions,
run: () => allowProgressCallbacksWhenSourceDeliverySuppressed: draftToolProgressEnabled
core.channel.reply.dispatchReplyFromConfig({ ? true
ctx: ctxPayload, : undefined,
cfg, preserveProgressCallbackStartOrder: draftPreviewEnabled ? true : undefined,
dispatcher, onObservedReplyDelivery: draftToolProgressEnabled
replyOptions: { ? () => draftStream.clear()
...replyOptions, : undefined,
allowProgressCallbacksWhenSourceDeliverySuppressed: disableBlockStreaming: draftPreviewEnabled
draftToolProgressEnabled ? true : undefined, ? true
preserveProgressCallbackStartOrder: draftPreviewEnabled : typeof account.blockStreaming === "boolean"
? true ? !account.blockStreaming
: undefined, : undefined,
onObservedReplyDelivery: draftToolProgressEnabled ...(suppressDefaultToolProgressMessages
? () => draftStream.clear() ? { suppressDefaultToolProgressMessages: true }
: undefined, : {}),
disableBlockStreaming: draftPreviewEnabled onModelSelected,
? true onPartialReply: (payloadResult) => {
: typeof account.blockStreaming === "boolean" if (account.streamingMode !== "progress") {
? !account.blockStreaming return updateDraftFromPartial(payloadResult.text);
}
return undefined;
},
onAssistantMessageStart: () => {
lastPartialText = "";
progressDraft.resetReasoningProgress();
if (account.streamingMode === "block") {
blockPreviewAssistantMessagePending = true;
return;
}
if (account.streamingMode !== "progress") {
progressDraft.reset();
}
},
onReasoningEnd: () => {
// Hidden reasoning has no visible boundary. Only transitions that
// actually render text, reasoning, or tools rotate preview posts.
lastPartialText = "";
progressDraft.resetReasoningProgress();
if (
account.streamingMode !== "block" &&
account.streamingMode !== "progress"
) {
progressDraft.reset();
}
},
onReasoningStream: async (payloadResult) => {
if (account.streamingMode === "progress") {
await progressDraft.pushReasoningProgress(
payloadResult.text || "Thinking…",
{ snapshot: payloadResult.isReasoningSnapshot === true },
);
return;
}
if (!lastPartialText) {
const boundarySettled = enterBlockPreviewActivity("reasoning");
draftStream.update("Thinking…");
previewBoundaryController.noteUpdate();
await boundarySettled;
}
},
onToolStart: async (payloadValue) => {
if (!draftToolProgressEnabled) {
return;
}
const boundarySettled = enterBlockPreviewActivity("tool");
// Boundary detach and progress staging both happen synchronously before
// their first await; agent callbacks may be dispatched fire-and-forget.
const progressSettled = progressDraft.pushToolProgress(
buildChannelProgressDraftLineForEntry(
account.config,
{
event: "tool",
itemId: payloadValue.itemId,
toolCallId: payloadValue.toolCallId,
name: payloadValue.name,
phase: payloadValue.phase,
args: payloadValue.args,
},
payloadValue.detailMode
? { detailMode: payloadValue.detailMode }
: undefined, : undefined,
...(suppressDefaultToolProgressMessages ),
? { suppressDefaultToolProgressMessages: true } { startImmediately: true },
: {}), );
onModelSelected, previewBoundaryController.noteUpdate();
onPartialReply: (payloadResult) => { await Promise.all([boundarySettled, progressSettled]);
if (account.streamingMode !== "progress") { },
return updateDraftFromPartial(payloadResult.text); onItemEvent: async (payloadLocal) => {
} if (!draftToolProgressEnabled) {
return undefined; return;
}, }
onAssistantMessageStart: () => { const boundarySettled = enterBlockPreviewActivity("tool");
lastPartialText = ""; const progressSettled = progressDraft.pushToolProgress(
progressDraft.resetReasoningProgress(); buildChannelProgressDraftLineForEntry(account.config, {
if (account.streamingMode === "block") { event: "item",
blockPreviewAssistantMessagePending = true; itemId: payloadLocal.itemId,
return; itemKind: payloadLocal.kind,
} title: payloadLocal.title,
if (account.streamingMode !== "progress") { name: payloadLocal.name,
progressDraft.reset(); phase: payloadLocal.phase,
} status: payloadLocal.status,
}, summary: payloadLocal.summary,
onReasoningEnd: () => { progressText: payloadLocal.progressText,
// Hidden reasoning has no visible boundary. Only transitions that meta: payloadLocal.meta,
// actually render text, reasoning, or tools rotate preview posts. }),
lastPartialText = ""; { startImmediately: true },
progressDraft.resetReasoningProgress(); );
if ( previewBoundaryController.noteUpdate();
account.streamingMode !== "block" && await Promise.all([boundarySettled, progressSettled]);
account.streamingMode !== "progress" },
) { },
progressDraft.reset();
}
},
onReasoningStream: async (payloadResult) => {
if (account.streamingMode === "progress") {
await progressDraft.pushReasoningProgress(
payloadResult.text || "Thinking…",
{ snapshot: payloadResult.isReasoningSnapshot === true },
);
return;
}
if (!lastPartialText) {
const boundarySettled = enterBlockPreviewActivity("reasoning");
draftStream.update("Thinking…");
previewBoundaryController.noteUpdate();
await boundarySettled;
}
},
onToolStart: async (payloadValue) => {
if (!draftToolProgressEnabled) {
return;
}
const boundarySettled = enterBlockPreviewActivity("tool");
// Boundary detach and progress staging both happen synchronously before
// their first await; agent callbacks may be dispatched fire-and-forget.
const progressSettled = progressDraft.pushToolProgress(
buildChannelProgressDraftLineForEntry(
account.config,
{
event: "tool",
itemId: payloadValue.itemId,
toolCallId: payloadValue.toolCallId,
name: payloadValue.name,
phase: payloadValue.phase,
args: payloadValue.args,
},
payloadValue.detailMode
? { detailMode: payloadValue.detailMode }
: undefined,
),
{ startImmediately: true },
);
previewBoundaryController.noteUpdate();
await Promise.all([boundarySettled, progressSettled]);
},
onItemEvent: async (payloadLocal) => {
if (!draftToolProgressEnabled) {
return;
}
const boundarySettled = enterBlockPreviewActivity("tool");
const progressSettled = progressDraft.pushToolProgress(
buildChannelProgressDraftLineForEntry(account.config, {
event: "item",
itemId: payloadLocal.itemId,
itemKind: payloadLocal.kind,
title: payloadLocal.title,
name: payloadLocal.name,
phase: payloadLocal.phase,
status: payloadLocal.status,
summary: payloadLocal.summary,
progressText: payloadLocal.progressText,
meta: payloadLocal.meta,
}),
{ startImmediately: true },
);
previewBoundaryController.noteUpdate();
await Promise.all([boundarySettled, progressSettled]);
},
},
}),
}), }),
}), }),
}, },
@@ -6,10 +6,16 @@
*/ */
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { import {
asDateTimestampMs, asDateTimestampMs,
resolveExpiresAtMsFromDurationMs, resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime"; } 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 { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-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 // Build inbound context — the command text is the body
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: commandText, Body: commandText,
BodyForAgent: commandText, BodyForAgent: commandText,
RawBody: commandText, RawBody: commandText,
@@ -886,58 +892,51 @@ async function handleSlashCommandAsync(params: {
}, },
}, },
}); });
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); const humanDelay = resolveHumanDelayConfig(cfg, route.agentId);
const deliveryBarrier = createMattermostReplyDeliveryBarrier({ const deliveryBarrier = createMattermostReplyDeliveryBarrier({
isDirect: kind === "direct", isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry, dmRetryOptions: account.config.dmChannelRetry,
}); });
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
core.channel.reply.createReplyDispatcherWithTyping({ ...replyPipeline,
...replyPipeline, resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, onDeliverySettled: deliveryBarrier.markDeliverySettled,
onDeliverySettled: deliveryBarrier.markDeliverySettled, humanDelay,
humanDelay, deliver: async (payload: ReplyPayload) => {
deliver: async (payload: ReplyPayload) => { await deliverMattermostReplyPayload({
await deliverMattermostReplyPayload({ core,
core,
cfg,
payload,
to,
accountId: account.accountId,
agentId: route.agentId,
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
runtime.log?.(`delivered slash reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(
`mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`,
);
},
onReplyStart: typingCallbacks?.onReplyStart,
});
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg, cfg,
dispatcher, payload,
replyOptions: { to,
...replyOptions, accountId: account.accountId,
disableBlockStreaming: agentId: route.agentId,
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, textLimit,
onModelSelected, tableMode,
}, sendMessage: sendMessageMattermost,
}), onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
runtime.log?.(`delivered slash reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(
`mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`,
);
},
onReplyStart: typingCallbacks?.onReplyStart,
});
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. */ /* 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 { asRecord } from "./dreaming-shared.js";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.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"]); const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
return asRecord(entry?.config) ?? {}; return asRecord(entry?.config) ?? {};
} }
@@ -49,7 +49,7 @@ function formatPhaseGuide(): string {
} }
function formatStatus(cfg: OpenClawConfig): string { function formatStatus(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryCorePluginConfig(cfg); const pluginConfig = resolveDreamingPluginConfig(cfg);
const dreaming = resolveMemoryDreamingConfig({ const dreaming = resolveMemoryDreamingConfig({
pluginConfig, pluginConfig,
cfg, cfg,
@@ -5,8 +5,10 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core"; import { expectDefined } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime"; import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import {
import { resolveMemoryCorePluginConfig } from "openclaw/plugin-sdk/memory-core-host-status"; resolveMemoryDreamingPluginConfig,
resolveSessionTranscriptsDirForAgent,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-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 ( const beforeAgentReply = async (
event: { cleanedBody: string }, event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string }, ctx: { trigger?: string; workspaceDir?: string },
@@ -434,7 +436,7 @@ describe("memory-core dreaming phases", () => {
await runDreamingSweepPhases({ await runDreamingSweepPhases({
workspaceDir, workspaceDir,
cfg: testConfig, cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig), pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
logger, logger,
subagent, subagent,
nowMs, nowMs,
@@ -501,7 +503,7 @@ describe("memory-core dreaming phases", () => {
runDreamingSweepPhases({ runDreamingSweepPhases({
workspaceDir, workspaceDir,
cfg: testConfig, cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig), pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
logger, logger,
subagent, subagent,
nowMs: Date.parse("2026-04-05T10:05:00.000Z"), nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
@@ -737,7 +739,7 @@ describe("memory-core dreaming phases", () => {
await runDreamingSweepPhases({ await runDreamingSweepPhases({
workspaceDir, workspaceDir,
cfg: testConfig, cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig), pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
logger, logger,
subagent, subagent,
nowMs, nowMs,
+5 -5
View File
@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Memory Core plugin module implements dreaming behavior. // Memory Core plugin module implements dreaming behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { resolveMemoryDreamingPluginConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { import {
DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS as DEFAULT_MEMORY_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS, 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, 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_NAME as MANAGED_DREAMING_CRON_NAME,
MANAGED_MEMORY_DREAMING_CRON_TAG as MANAGED_DREAMING_CRON_TAG, MANAGED_MEMORY_DREAMING_CRON_TAG as MANAGED_DREAMING_CRON_TAG,
MEMORY_DREAMING_SYSTEM_EVENT_TEXT as DREAMING_SYSTEM_EVENT_TEXT, MEMORY_DREAMING_SYSTEM_EVENT_TEXT as DREAMING_SYSTEM_EVENT_TEXT,
resolveMemoryCorePluginConfig,
resolveMemoryDeepDreamingConfig, resolveMemoryDeepDreamingConfig,
resolveMemoryDreamingWorkspaces, resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-core-host-status"; } from "openclaw/plugin-sdk/memory-core-host-status";
@@ -550,7 +550,7 @@ async function runShortTermDreamingPromotionIfTriggered(params: {
let totalCandidates = 0; let totalCandidates = 0;
let totalApplied = 0; let totalApplied = 0;
let failedWorkspaces = 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 detachNarratives = params.trigger === "cron";
const [ const [
{ writeDeepDreamingReport }, { writeDeepDreamingReport },
@@ -793,10 +793,10 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig(); params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
const pluginConfig = const pluginConfig =
params.reason === "startup" params.reason === "startup"
? (resolveMemoryCorePluginConfig(startupCfg) ?? ? (resolveMemoryDreamingPluginConfig(startupCfg) ??
resolveMemoryCorePluginConfig(api.config) ?? resolveMemoryDreamingPluginConfig(api.config) ??
api.pluginConfig) api.pluginConfig)
: resolveMemoryCorePluginConfig(startupCfg); : resolveMemoryDreamingPluginConfig(startupCfg);
const config = resolveShortTermPromotionDreamingConfig({ const config = resolveShortTermPromotionDreamingConfig({
pluginConfig, pluginConfig,
cfg: startupCfg, cfg: startupCfg,
+2 -2
View File
@@ -10,6 +10,7 @@ import {
readFiniteNumberParam, readFiniteNumberParam,
readPositiveIntegerParam, readPositiveIntegerParam,
readStringParam, readStringParam,
resolveMemoryDreamingPluginConfig,
type MemoryCorpusSearchResult, type MemoryCorpusSearchResult,
type OpenClawConfig, type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core"; } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
@@ -18,7 +19,6 @@ import type {
MemorySearchRuntimeDebug, MemorySearchRuntimeDebug,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files"; } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingConfig, resolveMemoryDreamingConfig,
resolveMemoryDeepDreamingConfig, resolveMemoryDeepDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status"; } from "openclaw/plugin-sdk/memory-core-host-status";
@@ -551,7 +551,7 @@ export function createMemorySearchTool(options: {
mode: citationsMode, mode: citationsMode,
sessionKey: options.agentSessionKey, sessionKey: options.agentSessionKey,
}); });
const pluginConfig = resolveMemoryCorePluginConfig(cfg); const pluginConfig = resolveMemoryDreamingPluginConfig(cfg);
const dreamingEnabled = resolveMemoryDreamingConfig({ const dreamingEnabled = resolveMemoryDreamingConfig({
pluginConfig, pluginConfig,
cfg, cfg,
+2 -2
View File
@@ -6,7 +6,7 @@ import {
TRUSTED_CLIENT_TOKEN, TRUSTED_CLIENT_TOKEN,
generateSecMsGecToken, generateSecMsGecToken,
} from "node-edge-tts/dist/drm.js"; } from "node-edge-tts/dist/drm.js";
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { import {
assertOkOrThrowProviderError, assertOkOrThrowProviderError,
readProviderJsonResponse, readProviderJsonResponse,
@@ -288,7 +288,7 @@ export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin {
audioBuffer, audioBuffer,
outputFormat: format, outputFormat: format,
fileExtension, fileExtension,
voiceCompatible: isVoiceCompatibleAudio({ fileName: outputPath }), voiceCompatible: isVoiceMessageCompatibleAudio({ fileName: outputPath }),
}; };
}; };
+23 -6
View File
@@ -24,12 +24,22 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { chunkMarkdownTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import { chunkMarkdownTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-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 type { OpenClawConfig, ReplyPayload } from "../runtime-api.js";
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js"; import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
import { setMSTeamsRuntime } from "./runtime.js"; import { setMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.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). */ /** Options msteams passes into core createReplyDispatcherWithTyping (capture seam). */
type CapturedDispatcherOptions = { type CapturedDispatcherOptions = {
onReplyStart?: () => Promise<void> | void; onReplyStart?: () => Promise<void> | void;
@@ -230,11 +240,18 @@ const MSTEAMS_TRACE_CASES: readonly MSTeamsTraceCase[] = [
function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) { function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) {
let captured: CapturedDispatcherOptions | undefined; let captured: CapturedDispatcherOptions | undefined;
setMSTeamsRuntime( setMSTeamsRuntime(createTraceRuntimeStub(recorder, () => undefined));
createTraceRuntimeStub(recorder, (options) => { createReplyDispatcherWithTypingMock.mockImplementation((options: CapturedDispatcherOptions) => {
captured = options; captured = options;
}), return {
); dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {
options.typingCallbacks?.onIdle?.();
},
markRunComplete: () => {},
};
});
const stream = createRecordingStream(recorder, traceCase.streamWriteFault); const stream = createRecordingStream(recorder, traceCase.streamWriteFault);
const context = createRecordingTurnContext({ const context = createRecordingTurnContext({
recorder, recorder,
+6 -15
View File
@@ -1,7 +1,6 @@
// Msteams plugin module implements feedback invoke behavior. // 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 { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatUnknownError } from "./errors.js"; import { formatUnknownError } from "./errors.js";
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js"; import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
@@ -131,19 +130,12 @@ export async function runMSTeamsFeedbackInvokeHandler(
hasComment: Boolean(userComment), hasComment: Boolean(userComment),
}); });
// Write feedback event to session transcript
try { try {
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, { await recordChannelFeedbackEvent({
cfg: deps.cfg,
agentId: route.agentId, agentId: route.agentId,
}); sessionKey: route.sessionKey,
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_"); event: feedbackEvent,
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
}); });
} catch { } catch {
// Best effort // Best effort
@@ -181,12 +173,11 @@ export async function runMSTeamsFeedbackInvokeHandler(
runFeedbackReflection({ runFeedbackReflection({
cfg: deps.cfg, cfg: deps.cfg,
app: deps.app, app: deps.app,
appId: deps.appId,
conversationRef, conversationRef,
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
agentId: route.agentId, agentId: route.agentId,
conversationId, conversationId,
feedbackMessageId: messageId, conversationKind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
userComment, userComment,
log: deps.log, log: deps.log,
}).catch((err: unknown) => { }).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 crypto from "node:crypto";
import { getMSTeamsRuntime } from "./runtime.js"; 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 LEARNINGS_NAMESPACE = "feedback-learnings";
const MAX_LEARNING_ENTRIES = 10_000; 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"); 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: { export async function storeSessionLearning(params: {
storePath: string; storePath: string;
sessionKey: string; sessionKey: string;
learning: string; learning: string;
}): Promise<void> { }): Promise<void> {
const store = openLearningStore(); const store = getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
const key = learningStoreKey(params.storePath, params.sessionKey); namespace: LEARNINGS_NAMESPACE,
const existing = await store.lookup(key); maxEntries: MAX_LEARNING_ENTRIES,
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 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. // Msteams plugin module implements feedback reflection behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { import {
dispatchReplyFromConfigWithSettledDispatcher, DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS,
type OpenClawConfig, runChannelFeedbackReflection,
} from "../runtime-api.js"; } 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 { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
import type { StoredConversationReference } from "./conversation-store.js"; import type { StoredConversationReference } from "./conversation-store.js";
import { formatUnknownError } from "./errors.js"; import { formatUnknownError } from "./errors.js";
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js"; import { storeSessionLearning } from "./feedback-reflection-store.js";
import {
DEFAULT_COOLDOWN_MS,
isReflectionAllowed,
recordReflectionTime,
storeSessionLearning,
} from "./feedback-reflection-store.js";
import { buildConversationReference } from "./messenger.js"; import { buildConversationReference } from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js"; import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
import type { MSTeamsApp } from "./sdk.js"; import type { MSTeamsApp } from "./sdk.js";
@@ -30,7 +24,6 @@ type FeedbackEvent = {
sessionKey: string; sessionKey: string;
agentId: string; agentId: string;
conversationId: string; conversationId: string;
reflectionLearning?: string;
}; };
export function buildFeedbackEvent(params: { export function buildFeedbackEvent(params: {
@@ -57,173 +50,65 @@ export function buildFeedbackEvent(params: {
type RunFeedbackReflectionParams = { type RunFeedbackReflectionParams = {
cfg: OpenClawConfig; cfg: OpenClawConfig;
app: MSTeamsApp; app: MSTeamsApp;
appId: string;
conversationRef: StoredConversationReference; conversationRef: StoredConversationReference;
sessionKey: string; sessionKey: string;
agentId: string; agentId: string;
conversationId: string; conversationId: string;
feedbackMessageId: string; conversationKind: "direct" | "group" | "channel";
thumbedDownResponse?: string; thumbedDownResponse?: string;
userComment?: string; userComment?: string;
log: MSTeamsMonitorLogger; 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. * Run a background reflection after negative feedback.
* This is designed to be called fire-and-forget (don't await in the invoke handler). * This is designed to be called fire-and-forget (don't await in the invoke handler).
*/ */
export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> { export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> {
const { cfg, log, sessionKey } = params; const { cfg, log, sessionKey } = params;
const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS; const cooldownMs =
if (!isReflectionAllowed(sessionKey, cooldownMs)) { cfg.channels?.msteams?.feedbackReflectionCooldownMs ??
log.debug?.("skipping reflection (cooldown active)", { sessionKey }); DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS;
return; let reflection;
}
const reflectionPrompt = buildReflectionPrompt({
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 { try {
await dispatchReplyFromConfigWithSettledDispatcher({ reflection = await runChannelFeedbackReflection({
ctxPayload,
cfg, cfg,
dispatcher: capture.dispatcher, channel: "msteams",
onSettled: () => {}, channelLabel: "Teams",
replyOptions: capture.replyOptions, agentId: params.agentId,
sessionKey,
conversationId: params.conversationId,
conversationKind: params.conversationKind,
thumbedDownResponse: params.thumbedDownResponse,
userComment: params.userComment,
cooldownMs,
onRecordError: (err) =>
log.debug?.("reflection session record failed", { error: formatUnknownError(err) }),
onDispatchError: (err) =>
log.debug?.("reflection reply error", { error: formatUnknownError(err) }),
}); });
} catch (err) { } catch (err) {
log.error("reflection dispatch failed", { error: formatUnknownError(err) }); log.error("reflection dispatch failed", { error: formatUnknownError(err) });
return; return;
} }
if (reflection.status === "cooldown") {
const reflectionResponse = capture.readResponse().trim(); log.debug?.("skipping reflection (cooldown active)", { sessionKey });
if (!reflectionResponse) { return;
}
if (reflection.status === "empty") {
log.debug?.("reflection produced no output"); log.debug?.("reflection produced no output");
return; return;
} }
const parsedReflection = parseReflectionResponse(reflectionResponse);
if (!parsedReflection) {
log.debug?.("reflection produced no structured output");
return;
}
recordReflectionTime(sessionKey, cooldownMs);
log.info("reflection complete", { log.info("reflection complete", {
sessionKey, sessionKey,
responseLength: reflectionResponse.length, responseLength: reflection.responseLength,
followUp: parsedReflection.followUp, followUp: reflection.followUp,
}); });
try { try {
await storeSessionLearning({ await storeSessionLearning({
storePath, storePath: reflection.storePath,
sessionKey: params.sessionKey, sessionKey,
learning: parsedReflection.learning, learning: reflection.learning,
}); });
} catch (err) { } catch (err) {
log.debug?.("failed to store reflection learning", { error: formatUnknownError(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, params.conversationRef.conversation?.conversationType,
); );
const shouldNotify = const shouldNotify =
conversationType === "personal" && conversationType === "personal" && reflection.followUp && Boolean(reflection.userMessage);
parsedReflection.followUp &&
Boolean(parsedReflection.userMessage);
if (!shouldNotify) { if (!shouldNotify) {
if (parsedReflection.followUp && conversationType !== "personal") { if (reflection.followUp && conversationType !== "personal") {
log.debug?.("skipping reflection follow-up outside direct message", { log.debug?.("skipping reflection follow-up outside direct message", {
sessionKey, sessionKey,
conversationType, conversationType,
@@ -248,12 +131,12 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
} }
try { try {
await sendReflectionFollowUp({ await sendMSTeamsActivityWithReference(
cfg, params.app,
app: params.app, buildConversationReference(params.conversationRef),
conversationRef: params.conversationRef, { type: "message", text: reflection.userMessage! },
userMessage: parsedReflection.userMessage!, { serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(cfg.channels?.msteams) },
}); );
log.info("sent reflection follow-up", { sessionKey }); log.info("sent reflection follow-up", { sessionKey });
} catch (err) { } catch (err) {
log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(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. // 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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js"; import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js";
@@ -13,6 +10,14 @@ import type { MSTeamsTurnContext } from "./sdk-types.js";
const feedbackReflectionMockState = vi.hoisted(() => ({ const feedbackReflectionMockState = vi.hoisted(() => ({
runFeedbackReflection: vi.fn(), 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", () => ({ vi.mock("./monitor-handler/message-handler.js", () => ({
createMSTeamsMessageHandler: () => async () => {}, createMSTeamsMessageHandler: () => async () => {},
@@ -57,7 +62,7 @@ function createRuntimeStub(readAllowFromStore: ReturnType<typeof vi.fn>): Plugin
}), }),
}, },
session: { session: {
resolveStorePath: (storePath?: string) => storePath ?? tmpdir(), resolveStorePath: (storePath?: string) => storePath ?? "/tmp",
}, },
}, },
} as unknown as PluginRuntime; } as unknown as PluginRuntime;
@@ -126,41 +131,21 @@ function createFeedbackInvokeContext(params: {
} as unknown as MSTeamsTurnContext; } 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: { async function withFeedbackHandler(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
context: Parameters<typeof createFeedbackInvokeContext>[0]; context: Parameters<typeof createFeedbackInvokeContext>[0];
assertResult: (args: { tmpDir: string }) => Promise<void>; assertResult: () => Promise<void>;
}) { }) {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-")); const deps = createDeps({ cfg: params.cfg });
try { await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
const deps = createDeps({ await params.assertResult();
cfg: {
...params.cfg,
session: { store: tmpDir },
},
});
await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
await params.assertResult({ tmpDir });
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
} }
describe("msteams feedback invoke authz", () => { describe("msteams feedback invoke authz", () => {
beforeEach(() => { beforeEach(() => {
feedbackReflectionMockState.runFeedbackReflection.mockReset(); feedbackReflectionMockState.runFeedbackReflection.mockReset();
feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined); feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined);
channelInboundMockState.recordChannelFeedbackEvent.mockClear();
}); });
it("records feedback for an allowlisted DM sender", async () => { it("records feedback for an allowlisted DM sender", async () => {
@@ -181,34 +166,22 @@ describe("msteams feedback invoke authz", () => {
senderName: "Owner", senderName: "Owner",
comment: "allowed feedback", comment: "allowed feedback",
}, },
assertResult: async ({ tmpDir }) => { assertResult: async () => {
const transcript = await readFile( expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith({
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"), cfg: expect.any(Object),
"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 feedback",
sessionKey: "msteams:direct:owner-aad",
agentId: "default", agentId: "default",
conversationId: "a:personal-chat", sessionKey: "msteams:direct:owner-aad",
event: {
type: "custom",
event: "feedback",
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", senderName: "Owner",
comment: "allowed dm feedback", comment: "allowed dm feedback",
}, },
assertResult: async ({ tmpDir }) => { assertResult: async () => {
const transcript = await readFile( expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith(
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"), expect.objectContaining({
"utf-8", agentId: "default",
sessionKey: "msteams:direct:owner-aad",
event: expect.objectContaining({ comment: "allowed dm feedback" }),
}),
); );
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",
agentId: "default",
conversationId: "a:personal-chat",
});
}, },
}); });
}); });
@@ -290,47 +242,41 @@ describe("msteams feedback invoke authz", () => {
senderName: "Attacker", senderName: "Attacker",
comment: "blocked feedback", comment: "blocked feedback",
}, },
assertResult: async ({ tmpDir }) => { assertResult: async () => {
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl")); expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled(); expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
}, },
}); });
}); });
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => { it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-")); const deps = createDeps({
try { cfg: {
const deps = createDeps({ channels: {
cfg: { msteams: {
session: { store: tmpDir }, groupPolicy: "allowlist",
channels: { groupAllowFrom: ["owner-aad"],
msteams: { feedbackReflection: true,
groupPolicy: "allowlist",
groupAllowFrom: ["owner-aad"],
feedbackReflection: true,
},
}, },
} as OpenClawConfig, },
}); } as OpenClawConfig,
});
await runMSTeamsFeedbackInvokeHandler( await runMSTeamsFeedbackInvokeHandler(
createFeedbackInvokeContext({ createFeedbackInvokeContext({
reaction: "dislike", reaction: "dislike",
conversationId: "19:group@thread.tacv2;messageid=bot-msg-1", conversationId: "19:group@thread.tacv2;messageid=bot-msg-1",
conversationType: "groupChat", conversationType: "groupChat",
senderId: "attacker-aad", senderId: "attacker-aad",
senderName: "Attacker", senderName: "Attacker",
teamId: "team-1", teamId: "team-1",
channelName: "General", channelName: "General",
comment: "blocked reflection", comment: "blocked reflection",
}), }),
deps, deps,
); );
await expectFileMissing(path.join(tmpDir, "msteams_group_19_group_thread_tacv2.jsonl")); expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
expect(feedbackReflectionMockState.runFeedbackReflection).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 { 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>) => { const runPrepared = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
await turn.recordInboundSession({ await turn.recordInboundSession({
storePath: turn.storePath, storePath: turn.storePath,
@@ -63,7 +65,16 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
: (preflightResult ?? {}); : (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight); const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) { 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"); throw new Error("msteams test runtime only supports prepared turn dispatch");
}); });
@@ -124,8 +135,8 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
resolveHumanDelayConfig: () => undefined, resolveHumanDelayConfig: () => undefined,
}, },
session: { session: {
recordInboundSession: options.recordInboundSession ?? vi.fn(async () => undefined), recordInboundSession,
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}), resolveStorePath,
}, },
inbound: { inbound: {
run: run as unknown as PluginRuntime["channel"]["inbound"]["run"], run: run as unknown as PluginRuntime["channel"]["inbound"]["run"],
@@ -2,9 +2,9 @@
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import { import {
buildChannelInboundEventContext, buildChannelInboundEventContext,
createChannelInboundEnvelopeBuilder,
logInboundDrop, logInboundDrop,
resolveInboundMentionDecision, resolveInboundMentionDecision,
resolveInboundSessionEnvelopeContext,
resolveInboundSupplementalSenderAllowed, resolveInboundSupplementalSenderAllowed,
} from "openclaw/plugin-sdk/channel-inbound"; } from "openclaw/plugin-sdk/channel-inbound";
import { import {
@@ -788,17 +788,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
quoteSenderName ??= quoteInfo?.sender; quoteSenderName ??= quoteInfo?.sender;
const envelopeFrom = isDirectMessage ? senderName : conversationType; const envelopeFrom = isDirectMessage ? senderName : conversationType;
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({ const buildEnvelope = createChannelInboundEnvelopeBuilder({ cfg, route });
cfg, const body = buildEnvelope({
agentId: route.agentId,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams", channel: "Teams",
from: envelopeFrom, from: envelopeFrom,
timestamp, timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: agentBody, body: agentBody,
}); });
let combinedBody = body; let combinedBody = body;
@@ -811,12 +805,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
limit: historyLimit, limit: historyLimit,
currentMessage: combinedBody, currentMessage: combinedBody,
formatEntry: (entry) => formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({ buildEnvelope({
channel: "Teams", channel: "Teams",
from: conversationType, from: conversationType,
timestamp: entry.timestamp, timestamp: entry.timestamp,
previousTimestamp: null,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, 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; isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined;
const ctxPayload = buildChannelInboundEventContext({ const ctxPayload = buildChannelInboundEventContext({
channel: "msteams", channel: "msteams",
finalize: core.channel.reply.finalizeInboundContext,
contextVisibility: contextVisibilityMode, contextVisibility: contextVisibilityMode,
supplemental: { supplemental: {
quote: quoteInfo quote: quoteInfo
@@ -975,12 +968,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
raw: activity, raw: activity,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg,
channel: "msteams", channel: "msteams",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, route: { agentId: route.agentId, sessionKey: route.sessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: { record: {
onRecordError: (err) => { onRecordError: (err) => {
logVerboseMessage( logVerboseMessage(
@@ -14,6 +14,14 @@ vi.mock("../runtime-api.js", () => ({
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024), 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", () => ({ vi.mock("./runtime.js", () => ({
getMSTeamsRuntime: getMSTeamsRuntimeMock, 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. // Msteams plugin module implements reply dispatcher behavior.
import { import {
buildChannelProgressDraftLine, buildChannelProgressDraftLine,
@@ -8,6 +9,7 @@ import {
resolveChannelStreamingPreviewToolProgress, resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages, resolveChannelStreamingSuppressDefaultToolProgressMessages,
} from "openclaw/plugin-sdk/channel-outbound"; } from "openclaw/plugin-sdk/channel-outbound";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { import {
createChannelMessageReplyPipeline, createChannelMessageReplyPipeline,
@@ -295,9 +297,9 @@ export function createMSTeamsReplyDispatcher(params: {
dispatcher, dispatcher,
replyOptions, replyOptions,
markDispatchIdle: baseMarkDispatchIdle, markDispatchIdle: baseMarkDispatchIdle,
} = core.channel.reply.createReplyDispatcherWithTyping({ } = createReplyDispatcherWithTyping({
...replyPipeline, ...replyPipeline,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
onReplyStart: async () => { onReplyStart: async () => {
await streamController.onReplyStart(); await streamController.onReplyStart();
// Always start the typing keepalive loop when typing is enabled and // Always start the typing keepalive loop when typing is enabled and
+31 -37
View File
@@ -1,10 +1,13 @@
import {
buildChannelInboundEventContext,
resolveChannelInboundRouteEnvelope,
} from "openclaw/plugin-sdk/channel-inbound";
// Nextcloud Talk plugin module implements inbound behavior. // Nextcloud Talk plugin module implements inbound behavior.
import { import {
channelIngressRoutes, channelIngressRoutes,
resolveStableChannelMessageIngress, resolveStableChannelMessageIngress,
} from "openclaw/plugin-sdk/channel-ingress-runtime"; } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import { import {
normalizeOptionalString, normalizeOptionalString,
normalizeStringEntries, normalizeStringEntries,
@@ -304,7 +307,7 @@ export async function handleNextcloudTalkInbound(params: {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`); runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
return; return;
} }
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config as OpenClawConfig, cfg: config as OpenClawConfig,
channel: CHANNEL_ID, channel: CHANNEL_ID,
accountId: account.accountId, accountId: account.accountId,
@@ -312,14 +315,10 @@ export async function handleNextcloudTalkInbound(params: {
kind: isGroup ? "group" : "direct", kind: isGroup ? "group" : "direct",
id: isGroup ? roomToken : senderId, 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 fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
const { storePath, body } = buildEnvelope({ const body = buildEnvelope({
channel: "Nextcloud Talk", channel: "Nextcloud Talk",
from: fromLabel, from: fromLabel,
timestamp: message.timestamp, timestamp: message.timestamp,
@@ -329,42 +328,37 @@ export async function handleNextcloudTalkInbound(params: {
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt); const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config); const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = buildChannelInboundEventContext({
Body: body, channel: CHANNEL_ID,
BodyForAgent: rawBody, accountId: route.accountId,
RawBody: rawBody, messageId: message.messageId,
CommandBody: rawBody, timestamp: message.timestamp,
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`, from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
To: `nextcloud-talk:${roomToken}`, sender: { id: senderId, name: senderName || undefined },
SessionKey: route.sessionKey, conversation: { kind: isGroup ? "group" : "direct", id: roomToken, label: fromLabel },
AccountId: route.accountId, route: {
ChatType: isGroup ? "group" : "direct", agentId: route.agentId,
ConversationLabel: fromLabel, accountId: route.accountId,
SenderName: senderName || undefined, routeSessionKey: route.sessionKey,
SenderId: senderId, },
GroupSubject: isGroup ? roomName || roomToken : undefined, reply: { to: `nextcloud-talk:${roomToken}`, originatingTo: `nextcloud-talk:${roomToken}` },
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody },
Provider: CHANNEL_ID, access: {
Surface: CHANNEL_ID, commands: { authorized: commandAuthorized },
WasMentioned: isGroup ? wasMentioned : undefined, mentions: { canDetectMention: isGroup, wasMentioned: isGroup && wasMentioned },
MessageSid: message.messageId, },
Timestamp: message.timestamp, extra: {
OriginatingChannel: CHANNEL_ID, GroupSubject: isGroup ? roomName || roomToken : undefined,
OriginatingTo: `nextcloud-talk:${roomToken}`, GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
CommandAuthorized: commandAuthorized, },
}); });
await core.channel.inbound.dispatchReply({ await core.channel.inbound.dispatch({
cfg: config as OpenClawConfig, cfg: config as OpenClawConfig,
channel: CHANNEL_ID, channel: CHANNEL_ID,
accountId: account.accountId, accountId: account.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
preparePayload: (payload) => preparePayload: (payload) =>
payload.text === undefined 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. // Nostr tests cover channel.inbound plugin behavior.
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers"; import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -7,6 +8,7 @@ import { setNostrRuntime } from "./runtime.js";
import { buildResolvedNostrAccount } from "./test-fixtures.js"; import { buildResolvedNostrAccount } from "./test-fixtures.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
dispatchInboundDirectDm: vi.fn(),
normalizePubkey: vi.fn((value: string) => normalizePubkey: vi.fn((value: string) =>
value value
.trim() .trim()
@@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({
startNostrBus: vi.fn(), 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", () => ({ vi.mock("./nostr-bus.js", () => ({
DEFAULT_RELAYS: ["wss://relay.example.com"], DEFAULT_RELAYS: ["wss://relay.example.com"],
startNostrBus: mocks.startNostrBus, startNostrBus: mocks.startNostrBus,
@@ -127,6 +133,7 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
describe("nostr inbound gateway path", () => { describe("nostr inbound gateway path", () => {
afterEach(() => { afterEach(() => {
mocks.dispatchInboundDirectDm.mockReset();
mocks.normalizePubkey.mockClear(); mocks.normalizePubkey.mockClear();
mocks.startNostrBus.mockReset(); mocks.startNostrBus.mockReset();
}); });
@@ -159,15 +166,19 @@ describe("nostr inbound gateway path", () => {
}); });
it("routes allowed DMs through the standard reply pipeline", async () => { 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({ account: buildResolvedNostrAccount({
publicKey: "bot-pubkey", publicKey: "bot-pubkey",
config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] }, config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
}), }),
cfg: { cfg: {
session: { store: { type: "jsonl" } },
commands: { useAccessGroups: true }, commands: { useAccessGroups: true },
} as never, },
}); });
const options = mockCallArg(mocks.startNostrBus) as { const options = mockCallArg(mocks.startNostrBus) as {
@@ -185,17 +196,18 @@ describe("nostr inbound gateway path", () => {
createdAt: 1_710_000_000, createdAt: 1_710_000_000,
}); });
expect(harness.recordInboundSession).toHaveBeenCalledTimes(1); expect(mocks.dispatchInboundDirectDm).toHaveBeenCalledWith(
expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); expect.objectContaining({
const ctx = ( channel: "nostr",
mockCallArg(harness.dispatchReplyWithBufferedBlockDispatcher) as { accountId: "default",
ctx?: Record<string, unknown>; peer: { kind: "direct", id: "sender-pubkey" },
} senderId: "sender-pubkey",
).ctx; rawBody: "hello from nostr",
expect(ctx?.BodyForAgent).toBe("hello from nostr"); messageId: "event-123",
expect(ctx?.SenderId).toBe("sender-pubkey"); timestamp: 1_710_000_000_000,
expect(ctx?.MessageSid).toBe("event-123"); commandAuthorized: true,
expect(ctx?.CommandAuthorized).toBe(true); }),
);
expect(sendReply).toHaveBeenCalledWith("converted:|a|b|"); expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
await cleanup.stop(); await cleanup.stop();
+2 -4
View File
@@ -163,11 +163,9 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
return; return;
} }
const { dispatchInboundDirectDmWithRuntime } = const { dispatchInboundDirectDm } = await import("./inbound-direct-dm-runtime.js");
await import("./inbound-direct-dm-runtime.js"); await dispatchInboundDirectDm({
await dispatchInboundDirectDmWithRuntime({
cfg: ctx.cfg, cfg: ctx.cfg,
runtime,
channel: "nostr", channel: "nostr",
channelLabel: "Nostr", channelLabel: "Nostr",
accountId: account.accountId, accountId: account.accountId,
@@ -1,2 +1,2 @@
// Nostr plugin module implements inbound direct dm runtime behavior. // 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 { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
import type { ChannelMessageActionName } from "./runtime-api.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(() => { afterEach(() => {
resetPluginRuntimeStateForTest(); resetPluginRuntimeStateForTest();
@@ -66,7 +66,6 @@ function createMockQaRuntime(params?: {
onDispatch?: (ctx: Record<string, unknown>) => void; onDispatch?: (ctx: Record<string, unknown>) => void;
toolStarts?: Array<{ name?: string; phase?: string; args?: Record<string, unknown> }>; toolStarts?: Array<{ name?: string; phase?: string; args?: Record<string, unknown> }>;
}): PluginRuntime { }): PluginRuntime {
const sessionUpdatedAt = new Map<string, number>();
return createPluginRuntimeMock({ return createPluginRuntimeMock({
channel: { channel: {
mentions: { mentions: {
@@ -77,104 +76,24 @@ function createMockQaRuntime(params?: {
return patterns.some((pattern) => pattern.test(text)); return patterns.some((pattern) => pattern.test(text));
}, },
}, },
routing: { inbound: {
resolveAgentRoute({ async dispatch(turn: QaDispatchTurn) {
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;
};
}) {
for (const toolStart of params?.toolStarts ?? []) { for (const toolStart of params?.toolStarts ?? []) {
await replyOptions?.onToolStart?.(toolStart); await turn.replyOptions?.onToolStart?.(toolStart);
} }
params?.onDispatch?.(ctx as Record<string, unknown>); params?.onDispatch?.(turn.ctxPayload as Record<string, unknown>);
await dispatcherOptions.deliver( await turn.delivery.deliver(
{ {
text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`, text: `qa-echo: ${turn.ctxPayload.BodyForAgent ?? turn.ctxPayload.Body ?? ""}`,
}, },
{ kind: "final" }, { 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 { return {
admission: turn.admission ?? { kind: "dispatch" as const }, admission: turn.admission ?? { kind: "dispatch" as const },
dispatched: true, dispatched: true,
ctxPayload: turn.ctxPayload, ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey, routeSessionKey: turn.route.sessionKey,
dispatchResult: await turn.dispatchReplyWithBufferedBlockDispatcher({ dispatchResult: undefined,
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,
}),
}; };
}, },
}, },
@@ -505,7 +424,7 @@ describe("qa-channel plugin", () => {
expect(ctx.ChatType).toBe("group"); expect(ctx.ChatType).toBe("group");
expect(ctx.From).toBe("group:qa-room"); expect(ctx.From).toBe("group:qa-room");
expect(ctx.To).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.SenderId).toBe("alice");
expect(ctx.GroupSubject).toBe("QA Room"); expect(ctx.GroupSubject).toBe("QA Room");
expect("conversation" in outbound).toBe(true); expect("conversation" in outbound).toBe(true);
+10 -10
View File
@@ -59,7 +59,7 @@ function createQaInboundParams(
} }
function firstRunAssembledParams(runtime: ReturnType<typeof createPluginRuntimeMock>) { 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) { if (!call) {
throw new Error("expected assembled turn 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); const assembled = firstRunAssembledParams(runtime);
expect(assembled.replyPipeline).toEqual({}); expect(assembled.replyPipeline).toEqual({});
expect(assembled.ctxPayload.WasMentioned).toBe(true); 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 () => { 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; const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload?.CommandAuthorized).toBe(true); expect(ctxPayload?.CommandAuthorized).toBe(true);
expect(ctxPayload?.SenderId).toBe("alice"); expect(ctxPayload?.SenderId).toBe("alice");
@@ -318,14 +318,14 @@ describe("handleQaInbound", () => {
expect(assembled.ctxPayload).toMatchObject({ expect(assembled.ctxPayload).toMatchObject({
CommandAuthorized: true, CommandAuthorized: true,
CommandSource: "native", CommandSource: "native",
CommandTargetSessionKey: assembled.routeSessionKey, CommandTargetSessionKey: assembled.route.sessionKey,
CommandTurn: { CommandTurn: {
body: "/stop", body: "/stop",
source: "native", source: "native",
}, },
}); });
expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice"); 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 () => { 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; const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload.MediaPath).toBeUndefined(); expect(ctxPayload.MediaPath).toBeUndefined();
expect(ctxPayload.MediaPaths).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; const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
expect(ctxPayload.MediaPath).toBeUndefined(); expect(ctxPayload.MediaPath).toBeUndefined();
expect(ctxPayload.MediaPaths).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 () => { 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();
}); });
}); });
+57 -57
View File
@@ -1,9 +1,12 @@
import {
buildChannelInboundEventContext,
resolveChannelInboundRouteEnvelope,
} from "openclaw/plugin-sdk/channel-inbound";
// Qa Channel plugin module implements inbound behavior. // Qa Channel plugin module implements inbound behavior.
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import { import {
buildAgentMediaPayload, buildAgentMediaPayload,
saveMediaBuffer, saveMediaBuffer,
@@ -219,7 +222,7 @@ export async function handleQaInbound(params: {
target, target,
toolCalls, toolCalls,
}); });
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: params.config as OpenClawConfig, cfg: params.config as OpenClawConfig,
channel: params.channelId, channel: params.channelId,
accountId: params.account.accountId, accountId: params.account.accountId,
@@ -232,8 +235,6 @@ export async function handleQaInbound(params: {
: "channel", : "channel",
id: target, id: target,
}, },
runtime: runtime.channel,
sessionStore: params.config.session?.store,
}); });
const isGroup = inbound.conversation.kind !== "direct"; const isGroup = inbound.conversation.kind !== "direct";
const wasMentioned = isGroup const wasMentioned = isGroup
@@ -286,7 +287,7 @@ export async function handleQaInbound(params: {
if (access.ingress.admission !== "dispatch") { if (access.ingress.admission !== "dispatch") {
return; return;
} }
const { storePath, body } = buildEnvelope({ const body = buildEnvelope({
channel: params.channelLabel, channel: params.channelLabel,
from: inbound.senderName || inbound.senderId, from: inbound.senderName || inbound.senderId,
timestamp: inbound.timestamp, timestamp: inbound.timestamp,
@@ -304,65 +305,64 @@ export async function handleQaInbound(params: {
: undefined; : undefined;
const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text; const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text;
const ctxPayload = runtime.channel.reply.finalizeInboundContext({ const sessionKey = commandTargets?.sessionKey ?? route.sessionKey;
Body: body, const ctxPayload = buildChannelInboundEventContext({
BodyForAgent: inbound.text, channel: params.channelId,
RawBody: inbound.text, accountId: route.accountId ?? params.account.accountId,
CommandBody: commandBody, messageId: inbound.id,
From: target, messageIdFull: inbound.id,
To: target, timestamp: inbound.timestamp,
SessionKey: commandTargets?.sessionKey ?? route.sessionKey, from: target,
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey, sender: { id: inbound.senderId, name: inbound.senderName },
AccountId: route.accountId ?? params.account.accountId, conversation: {
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group", kind: inbound.conversation.kind === "direct" ? "direct" : "group",
WasMentioned: wasMentioned, id: inbound.conversation.id,
ConversationLabel: label:
inbound.threadTitle || inbound.threadTitle ||
inbound.conversation.title || inbound.conversation.title ||
inbound.senderName || inbound.senderName ||
inbound.conversation.id, inbound.conversation.id,
GroupSubject: isGroup threadId: inbound.threadId,
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id 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, : undefined,
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined, extra: {
NativeChannelId: inbound.conversation.id, CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
MessageThreadId: inbound.threadId, GroupSubject: isGroup
ThreadLabel: inbound.threadTitle, ? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
ThreadParentId: inbound.threadId ? inbound.conversation.id : undefined, : undefined,
SenderName: inbound.senderName, GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
SenderId: inbound.senderId, ThreadLabel: inbound.threadTitle,
Provider: params.channelId, ...mediaPayload,
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, cfg: params.config as OpenClawConfig,
channel: params.channelId, channel: params.channelId,
accountId: params.account.accountId, accountId: params.account.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
deliver: async (payload, info) => { deliver: async (payload, info) => {
const text = const text =
@@ -1,5 +1,6 @@
// Qqbot plugin module implements inbound context behavior. // Qqbot plugin module implements inbound context behavior.
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime"; 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 { EngineAdapters } from "../adapter/index.js";
import type { QQBotGroupCommandLevel } from "../config/group.js"; import type { QQBotGroupCommandLevel } from "../config/group.js";
import type { GroupActivationMode } from "../group/activation.js"; import type { GroupActivationMode } from "../group/activation.js";
@@ -69,7 +70,7 @@ export interface InboundContext {
export interface InboundPipelineDeps { export interface InboundPipelineDeps {
account: GatewayAccount; account: GatewayAccount;
cfg: unknown; cfg: OpenClawConfig;
log?: EngineLogger; log?: EngineLogger;
runtime: GatewayPluginRuntime; runtime: GatewayPluginRuntime;
startTyping: (event: QueuedMessage) => Promise<{ 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({ const dispatchPromise = runtime.channel.inbound.run({
channel: "qqbot", channel: "qqbot",
accountId: inbound.route.accountId, accountId: inbound.route.accountId,
@@ -438,12 +434,11 @@ export async function dispatchOutbound(
raw: inbound, raw: inbound,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg: openClawCfg,
channel: "qqbot", channel: "qqbot",
accountId: inbound.route.accountId, accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey, route: { agentId: routeAgentId, sessionKey: inbound.route.sessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
record: { record: {
onRecordError: (err: unknown) => { onRecordError: (err: unknown) => {
log?.error( log?.error(
@@ -773,7 +768,6 @@ async function buildCtxPayload(
const commandSource = resolveCommandSource(inbound, runtime, cfg); const commandSource = resolveCommandSource(inbound, runtime, cfg);
const hasImageMedia = inbound.localMediaPaths.length > 0 || inbound.remoteMediaUrls.length > 0; const hasImageMedia = inbound.localMediaPaths.length > 0 || inbound.remoteMediaUrls.length > 0;
return buildChannelInboundEventContext({ return buildChannelInboundEventContext({
finalize: runtime.channel.reply.finalizeInboundContext,
channel: "qqbot", channel: "qqbot",
accountId: inbound.route.accountId, accountId: inbound.route.accountId,
messageId: event.messageId, messageId: event.messageId,
@@ -80,13 +80,13 @@ function buildAllowAccess(): QQBotInboundAccess {
} }
function buildDeps( function buildDeps(
cfg: unknown, cfg: StubCfg,
runtime: GatewayPluginRuntime, runtime: GatewayPluginRuntime,
account: GatewayAccount, account: GatewayAccount,
): InboundPipelineDeps { ): InboundPipelineDeps {
return { return {
account, account,
cfg, cfg: cfg as InboundPipelineDeps["cfg"],
runtime, runtime,
startTyping: vi.fn(), startTyping: vi.fn(),
adapters: { adapters: {
@@ -15,6 +15,11 @@
* sees directly. * sees directly.
*/ */
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
type EnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { import {
buildMergedMessageContext, buildMergedMessageContext,
@@ -103,13 +108,13 @@ export function buildAgentBody(input: BuildAgentBodyInput): string {
return base; return base;
} }
const envelopeOpts = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg); const envelopeOpts = resolveEnvelopeFormatOptions(deps.cfg);
return deps.adapters.history.buildPendingHistoryContext({ return deps.adapters.history.buildPendingHistoryContext({
historyMap: deps.groupHistories, historyMap: deps.groupHistories,
historyKey: event.groupOpenid, historyKey: event.groupOpenid,
limit: groupInfo.historyLimit, limit: groupInfo.historyLimit,
currentMessage: base, 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})`; return name.includes(id) ? name : `${name} (${id})`;
} }
function formatHistoryEntry( function formatHistoryEntry(entry: HistoryEntry, envelopeOpts: unknown): string {
entry: HistoryEntry,
deps: InboundPipelineDeps,
envelopeOpts: unknown,
): string {
const attachmentDesc = formatAttachmentTags(entry.attachments); const attachmentDesc = formatAttachmentTags(entry.attachments);
const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body; const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body;
return deps.runtime.channel.reply.formatInboundEnvelope({ return formatInboundEnvelope({
channel: "qqbot", channel: "qqbot",
from: entry.sender, from: entry.sender,
timestamp: entry.timestamp, timestamp: entry.timestamp,
body: bodyWithAttachments, body: bodyWithAttachments,
chatType: "group", chatType: "group",
envelope: envelopeOpts, envelope: envelopeOpts as EnvelopeFormatOptions,
}); });
} }
@@ -7,6 +7,11 @@
* dispatcher needs. No decisions / gating. * 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 { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ProcessedAttachments } from "../inbound-attachments.js"; import type { ProcessedAttachments } from "../inbound-attachments.js";
import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js"; import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js";
@@ -25,16 +30,16 @@ interface BuildBodyInput {
/** Format the inbound envelope (Web UI body). */ /** Format the inbound envelope (Web UI body). */
export function buildBody(input: BuildBodyInput): string { export function buildBody(input: BuildBodyInput): string {
const { event, deps, userContent, isGroupChat, imageUrls } = input; const { event, deps, userContent, isGroupChat, imageUrls } = input;
const envelopeOptions = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg); const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg as OpenClawConfig);
return deps.runtime.channel.reply.formatInboundEnvelope({ return formatInboundEnvelope({
channel: "qqbot", channel: "qqbot",
from: event.senderName ?? event.senderId, from: event.senderName ?? event.senderId,
timestamp: new Date(event.timestamp).getTime(), timestamp: new Date(event.timestamp).getTime(),
body: userContent, body: userContent,
...(imageUrls.length > 0 ? { imageUrls } : {}),
chatType: isGroupChat ? "group" : "direct", chatType: isGroupChat ? "group" : "direct",
sender: { id: event.senderId, name: event.senderName }, sender: { id: event.senderId, name: event.senderName },
envelope: envelopeOptions, envelope: envelopeOptions,
...(imageUrls.length > 0 ? { imageUrls } : {}),
}); });
} }
+1 -9
View File
@@ -87,20 +87,12 @@ export async function dispatchRaftWake(params: {
bodyForAgent: input.textForAgent, bodyForAgent: input.textForAgent,
}, },
}); });
const storePath = channelRuntime.session.resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
return { return {
cfg: ctx.cfg, cfg: ctx.cfg,
channel: RAFT_CHANNEL_ID, channel: RAFT_CHANNEL_ID,
accountId: ctx.accountId, accountId: ctx.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: channelRuntime.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
// Raft's bridge only transports wake hints. The agent owns CLI delivery // 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 // after it reads the pending Raft messages, so OpenClaw must not emit a
// duplicate synthetic reply through the channel dispatcher. // duplicate synthetic reply through the channel dispatcher.
+3 -5
View File
@@ -1,5 +1,5 @@
import { import {
dispatchInboundDirectDmWithRuntime, dispatchInboundDirectDm,
recordChannelBotPairLoopAndCheckSuppression, recordChannelBotPairLoopAndCheckSuppression,
} from "openclaw/plugin-sdk/channel-inbound"; } from "openclaw/plugin-sdk/channel-inbound";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
@@ -235,9 +235,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
}); });
return; return;
} }
await dispatchInboundDirectDmWithRuntime({ await dispatchInboundDirectDm({
cfg: ctx.cfg, cfg: ctx.cfg,
runtime,
channel: "reef", channel: "reef",
channelLabel: "Reef", channelLabel: "Reef",
accountId: "default", accountId: "default",
@@ -293,9 +292,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
async (notice) => { async (notice) => {
let resendText = ""; let resendText = "";
let dispatchFailure: Error | undefined; let dispatchFailure: Error | undefined;
await dispatchInboundDirectDmWithRuntime({ await dispatchInboundDirectDm({
cfg: ctx.cfg, cfg: ctx.cfg,
runtime,
channel: "reef", channel: "reef",
channelLabel: "Reef", channelLabel: "Reef",
accountId: "default", 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 () => { vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>( const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime", "openclaw/plugin-sdk/conversation-runtime",
@@ -33,7 +33,6 @@ import {
resolveChannelGroupRequireMention, resolveChannelGroupRequireMention,
} from "openclaw/plugin-sdk/channel-policy"; } from "openclaw/plugin-sdk/channel-policy";
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection"; 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 { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { import {
createInternalHookEvent, createInternalHookEvent,
@@ -536,12 +535,11 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
raw: entry, raw: entry,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg: deps.cfg,
channel: "signal", channel: "signal",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, route: { agentId: route.agentId, sessionKey: route.sessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession,
record: { record: {
updateLastRoute: !entry.isGroup updateLastRoute: !entry.isGroup
? { ? {
+3 -3
View File
@@ -1,5 +1,5 @@
// Slack-private authored text placement after block compilation. // 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"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export type SlackAuthoredTextPlacement = "none" | "blocks" | "outside-blocks"; export type SlackAuthoredTextPlacement = "none" | "blocks" | "outside-blocks";
@@ -10,7 +10,7 @@ function normalizeComparableSlackText(text: string): string {
function isSlackAuthoredTextRepresentedInInteractive( function isSlackAuthoredTextRepresentedInInteractive(
text: string, text: string,
interactive?: InteractiveReply, interactive?: LegacyInteractiveReply,
): boolean { ): boolean {
return isSlackAuthoredTextRepresentedInFragments( return isSlackAuthoredTextRepresentedInFragments(
text, text,
@@ -43,7 +43,7 @@ function isSlackAuthoredTextRepresentedInFragments(
/** Resolve placement from producer facts, before accessibility text changes the payload text. */ /** Resolve placement from producer facts, before accessibility text changes the payload text. */
export function resolveSlackAuthoredTextPlacement(params: { export function resolveSlackAuthoredTextPlacement(params: {
text?: string; text?: string;
interactive?: InteractiveReply; interactive?: LegacyInteractiveReply;
renderedInBlocks?: boolean; renderedInBlocks?: boolean;
renderedTextFragments?: readonly string[]; renderedTextFragments?: readonly string[];
}): SlackAuthoredTextPlacement { }): SlackAuthoredTextPlacement {
+4 -4
View File
@@ -2,12 +2,12 @@
import type { Block, KnownBlock } from "@slack/web-api"; import type { Block, KnownBlock } from "@slack/web-api";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import { import {
reduceInteractiveReply, reduceLegacyInteractiveReply,
resolveMessagePresentationButtonAction, resolveMessagePresentationButtonAction,
resolveMessagePresentationOptionAction, resolveMessagePresentationOptionAction,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
import type { import type {
InteractiveReply, LegacyInteractiveReply,
MessagePresentation, MessagePresentation,
MessagePresentationAction, MessagePresentationAction,
MessagePresentationButtonsBlock, MessagePresentationButtonsBlock,
@@ -228,7 +228,7 @@ export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackB
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation. * @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
*/ */
export function buildSlackInteractiveBlocks( export function buildSlackInteractiveBlocks(
interactive?: InteractiveReply, interactive?: LegacyInteractiveReply,
options: SlackBlockRenderOptions = {}, options: SlackBlockRenderOptions = {},
): SlackBlock[] { ): SlackBlock[] {
const initialState = { const initialState = {
@@ -236,7 +236,7 @@ export function buildSlackInteractiveBlocks(
buttonIndex: options.buttonIndexOffset ?? 0, buttonIndex: options.buttonIndexOffset ?? 0,
selectIndex: options.selectIndexOffset ?? 0, selectIndex: options.selectIndexOffset ?? 0,
}; };
return reduceInteractiveReply(interactive, initialState, (state, block) => { return reduceLegacyInteractiveReply(interactive, initialState, (state, block) => {
if (block.type === "text") { if (block.type === "text") {
const trimmed = block.text.trim(); const trimmed = block.text.trim();
if (!trimmed) { if (!trimmed) {
+18 -17
View File
@@ -2,7 +2,7 @@
// //
// Drives the real dispatch wiring (dispatchPreparedSlackMessage → deliverSlackPayload // Drives the real dispatch wiring (dispatchPreparedSlackMessage → deliverSlackPayload
// → native stream / draft preview / preview finalize / deliverReplies → sendMessageSlack) // → 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, // 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 // 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 // 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, // deliver/typing/replyOptions wiring (dedupe, thread plan, native stream ladder,
// draft preview, preview finalize, deliverReplies chunking, sendMessageSlack) // draft preview, preview finalize, deliverReplies chunking, sendMessageSlack)
// stays the real production code. // stays the real production code.
vi.mock("./monitor/reply.runtime.js", async (importOriginal) => { vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("./monitor/reply.runtime.js")>(); const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
return { return {
...actual, ...actual,
dispatchReplyWithBufferedBlockDispatcher: async (params: { dispatchChannelInboundTurn: async (params: DispatchParams) => {
dispatcherOptions: unknown;
replyOptions?: unknown;
}) => {
traceState.turn = { 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, replyOptions: (params.replyOptions ?? {}) as CapturedReplyOptions,
}; };
traceState.turnStarted?.resolve(); traceState.turnStarted?.resolve();
if (!traceState.turnOutcome) { if (!traceState.turnOutcome) {
throw new Error("trace turn outcome gate not initialized"); 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 // 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. // resolution to the scenario's recording client so all wire calls are captured.
vi.mock("./client.js", async (importOriginal) => { vi.mock("./client.js", async (importOriginal) => {
@@ -138,8 +140,7 @@ vi.mock("./client.js", async (importOriginal) => {
import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js"; import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js";
afterAll(() => { afterAll(() => {
vi.doUnmock("./monitor/reply.runtime.js"); vi.doUnmock("openclaw/plugin-sdk/channel-inbound");
vi.doUnmock("./monitor/conversation.runtime.js");
vi.doUnmock("./client.js"); vi.doUnmock("./client.js");
vi.resetModules(); vi.resetModules();
}); });
@@ -5,7 +5,7 @@ import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { import {
normalizeInteractiveReply, normalizeLegacyInteractiveReply,
normalizeMessagePresentation, normalizeMessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; 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 mediaUrl = readStringParam(actionParams, "media", { trim: false });
const presentation = normalizeMessagePresentation(actionParams.presentation); const presentation = normalizeMessagePresentation(actionParams.presentation);
const interactive = normalizeInteractiveReply(actionParams.interactive); const interactive = normalizeLegacyInteractiveReply(actionParams.interactive);
const hasStructuredContent = Boolean(presentation || interactive?.blocks.length); const hasStructuredContent = Boolean(presentation || interactive?.blocks.length);
const resolution = resolveSlackReplyBlockResolution( 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 () => { vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await vi.importActual<typeof import("./monitor/reply.runtime.js")>( const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
"./monitor/reply.runtime.js", type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
); type ReplyResolver = NonNullable<DispatchParams["replyResolver"]>;
type BufferedDispatchParams = Parameters<
typeof actual.dispatchReplyWithBufferedBlockDispatcher
>[0];
type ReplyResolver = NonNullable<BufferedDispatchParams["replyResolver"]>;
const replyResolver: ReplyResolver = (...args) => const replyResolver: ReplyResolver = (...args) =>
slackTestState.replyMock(...args) as ReturnType<ReplyResolver>; slackTestState.replyMock(...args) as ReturnType<ReplyResolver>;
return { return {
...actual, ...actual,
dispatchReplyWithBufferedBlockDispatcher: (params: BufferedDispatchParams) => dispatchChannelInboundTurn: (params: DispatchParams) =>
actual.dispatchReplyWithBufferedBlockDispatcher({ actual.dispatchChannelInboundTurn({ ...params, replyResolver }),
...params,
replyResolver,
}),
}; };
}); });
@@ -349,7 +342,6 @@ vi.mock("./monitor/conversation.runtime.js", async () => {
...actual, ...actual,
readChannelAllowFromStore: (...args: unknown[]) => readChannelAllowFromStore: (...args: unknown[]) =>
slackTestState.readAllowFromStoreMock(...args), slackTestState.readAllowFromStoreMock(...args),
recordInboundSession: vi.fn().mockResolvedValue(undefined),
upsertChannelPairingRequest: (...args: unknown[]) => upsertChannelPairingRequest: (...args: unknown[]) =>
slackTestState.upsertPairingRequestMock(...args), slackTestState.upsertPairingRequestMock(...args),
}; };
@@ -2,7 +2,6 @@
export { export {
buildPluginBindingResolvedText, buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId, parsePluginBindingApprovalCustomId,
recordInboundSession,
resolveConversationLabel, resolveConversationLabel,
resolvePluginConversationBindingApproval, resolvePluginConversationBindingApproval,
upsertChannelPairingRequest, upsertChannelPairingRequest,
@@ -1,4 +1,5 @@
// Slack tests cover dispatch.preview fallback plugin behavior. // 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"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const FINAL_REPLY_TEXT = "final answer"; const FINAL_REPLY_TEXT = "final answer";
@@ -13,7 +14,6 @@ const finalizeSlackPreviewEditMock = vi.fn(async () => {});
const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim()); const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim());
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" })); const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const chatUpdateMock = 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 recordSlackThreadParticipationMock = vi.fn();
const updateLastRouteMock = vi.fn(async () => {}); const updateLastRouteMock = vi.fn(async () => {});
const appendSlackStreamMock = vi.fn(async () => {}); const appendSlackStreamMock = vi.fn(async () => {});
@@ -518,10 +518,6 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
removeAckReactionAfterReply: () => {}, removeAckReactionAfterReply: () => {},
})); }));
vi.mock("../conversation.runtime.js", () => ({
recordInboundSession: recordInboundSessionMock,
}));
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => { vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>(); const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
return { return {
@@ -1018,361 +1014,134 @@ vi.mock("../replies.js", () => ({
resolveSlackThreadTs: () => mockedReplyThreadTs, resolveSlackThreadTs: () => mockedReplyThreadTs,
})); }));
vi.mock("../reply.runtime.js", () => ({ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
createReplyDispatcherWithTyping: (params: { const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null; type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
beforeDeliver?: ( return {
payload: TestReplyPayload, ...actual,
info: { kind: TestReplyDispatchKind }, dispatchChannelInboundTurn: async (params: DispatchParams) => {
) => Promise<TestReplyPayload | null> | TestReplyPayload | null; capturedReplyOptions = params.replyOptions as typeof capturedReplyOptions;
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>; if (mockedReplyOptionEvents.length > 0) {
}) => ({ for (const entry of mockedReplyOptionEvents) {
dispatcher: { if (entry.kind === "item") {
deliver: async (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => { await params.replyOptions?.onItemEvent?.({
const transformed = params.transformReplyPayload kind: entry.itemKind,
? params.transformReplyPayload(payload) itemId: entry.itemId,
toolCallId: entry.toolCallId,
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 === "command_output") {
await params.replyOptions?.onCommandOutput?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
phase: entry.phase,
title: entry.title,
name: entry.name,
status: entry.status,
exitCode: entry.exitCode,
});
} 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 === "assistant_start") {
await params.replyOptions?.onAssistantMessageStart?.();
} else if (entry.kind === "reasoning") {
await params.replyOptions?.onReasoningStream?.({
text: entry.text,
isReasoningSnapshot: entry.isReasoningSnapshot,
});
} else if (entry.kind === "reasoning_end") {
await params.replyOptions?.onReasoningEnd?.();
} else {
await params.replyOptions?.onPartialReply?.({ text: entry.text });
}
}
} 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;
}
const payload = entry.payload as ReplyPayload;
const transformed = params.dispatcherOptions?.transformReplyPayload
? params.dispatcherOptions.transformReplyPayload(payload)
: payload; : payload;
if (!transformed) { if (!transformed) {
return; continue;
} }
const deliverPayload = params.beforeDeliver const deliverPayload = params.dispatcherOptions?.beforeDeliver
? await params.beforeDeliver(transformed, info) ? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind })
: transformed; : transformed;
if (!deliverPayload) { if (!deliverPayload) {
return; continue;
} }
mockedQueuedDispatchCounts[info.kind] += 1; mockedQueuedDispatchCounts[entry.kind] += 1;
await params.deliver(deliverPayload, info); try {
}, await params.delivery.deliver(deliverPayload, { kind: entry.kind });
} catch (error) {
if (!mockedDispatcherCapturesDeliveryErrors) {
throw error;
}
mockedQueuedDispatchCounts[entry.kind] -= 1;
}
}
return {
admission: { kind: "dispatch" } as const,
dispatched: true as const,
ctxPayload: params.ctxPayload,
routeSessionKey: params.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { ...mockedQueuedDispatchCounts },
},
};
}, },
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;
if (mockedReplyOptionEvents.length > 0) {
for (const entry of mockedReplyOptionEvents) {
if (entry.kind === "item") {
await params.replyOptions?.onItemEvent?.({
kind: entry.itemKind,
itemId: entry.itemId,
toolCallId: entry.toolCallId,
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 === "command_output") {
await params.replyOptions?.onCommandOutput?.({
itemId: entry.itemId,
toolCallId: entry.toolCallId,
phase: entry.phase,
title: entry.title,
name: entry.name,
status: entry.status,
exitCode: entry.exitCode,
});
} 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 === "assistant_start") {
await params.replyOptions?.onAssistantMessageStart?.();
} else if (entry.kind === "reasoning") {
await params.replyOptions?.onReasoningStream?.({
text: entry.text,
isReasoningSnapshot: entry.isReasoningSnapshot,
});
} else if (entry.kind === "reasoning_end") {
await params.replyOptions?.onReasoningEnd?.();
} else {
await params.replyOptions?.onPartialReply?.({ text: entry.text });
}
}
} 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;
}
const transformed = params.dispatcherOptions.transformReplyPayload
? params.dispatcherOptions.transformReplyPayload(entry.payload)
: entry.payload;
if (!transformed) {
continue;
}
const deliverPayload = params.dispatcherOptions.beforeDeliver
? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind })
: transformed;
if (!deliverPayload) {
continue;
}
mockedQueuedDispatchCounts[entry.kind] += 1;
try {
await params.dispatcherOptions.deliver(deliverPayload, { kind: entry.kind });
} catch (error) {
if (!mockedDispatcherCapturesDeliveryErrors) {
throw error;
}
mockedQueuedDispatchCounts[entry.kind] -= 1;
}
}
return {
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", () => ({ vi.mock("./preview-finalize.js", () => ({
finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock, finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock,
@@ -1392,7 +1161,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
normalizeSlackOutboundTextMock.mockClear(); normalizeSlackOutboundTextMock.mockClear();
postMessageMock.mockClear(); postMessageMock.mockClear();
chatUpdateMock.mockClear(); chatUpdateMock.mockClear();
recordInboundSessionMock.mockReset();
recordSlackThreadParticipationMock.mockReset(); recordSlackThreadParticipationMock.mockReset();
updateLastRouteMock.mockReset(); updateLastRouteMock.mockReset();
appendSlackStreamMock.mockReset(); appendSlackStreamMock.mockReset();
@@ -1490,168 +1258,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS }); 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 () => { it("updates non-main DM last-route metadata on the prepared direct session", async () => {
mockedPinnedMainDmOwner = "U2"; mockedPinnedMainDmOwner = "U2";
await dispatchPreparedSlackMessage( await dispatchPreparedSlackMessage(
@@ -9,7 +9,7 @@ import {
type StatusReactionAdapter, type StatusReactionAdapter,
} from "openclaw/plugin-sdk/channel-feedback"; } from "openclaw/plugin-sdk/channel-feedback";
import { import {
dispatchChannelInboundReply, dispatchChannelInboundTurn,
type InboundReplyRecordOptions, type InboundReplyRecordOptions,
} from "openclaw/plugin-sdk/channel-inbound"; } from "openclaw/plugin-sdk/channel-inbound";
import { import {
@@ -89,7 +89,6 @@ import { resolveSlackThreadTargets } from "../../threading.js";
import type { SlackMessageEvent } from "../../types.js"; import type { SlackMessageEvent } from "../../types.js";
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js"; import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
import { resolveStorePath, updateLastRoute } from "../config.runtime.js"; import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
import { recordInboundSession } from "../conversation.runtime.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js"; import { escapeSlackMrkdwn } from "../mrkdwn.js";
import { import {
createSlackReplyDeliveryPlan, createSlackReplyDeliveryPlan,
@@ -98,7 +97,6 @@ import {
resolveDeliveredSlackReplyThreadTs, resolveDeliveredSlackReplyThreadTs,
resolveSlackThreadTs, resolveSlackThreadTs,
} from "../replies.js"; } from "../replies.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../reply.runtime.js";
import { finalizeSlackPreviewEdit } from "./preview-finalize.js"; import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
import { resolveSlackTimestampMs } from "./timestamp.js"; import { resolveSlackTimestampMs } from "./timestamp.js";
import type { PreparedSlackMessage } from "./types.js"; import type { PreparedSlackMessage } from "./types.js";
@@ -2074,16 +2072,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
let queuedFinal = false; let queuedFinal = false;
let counts: Partial<Record<ReplyDispatchKind, number>> = {}; let counts: Partial<Record<ReplyDispatchKind, number>> = {};
try { try {
const turnResult = await dispatchChannelInboundReply({ const turnResult = await dispatchChannelInboundTurn({
cfg, cfg,
channel: "slack", channel: "slack",
accountId: route.accountId, accountId: route.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath: prepared.turn.storePath,
ctxPayload: prepared.ctxPayload, ctxPayload: prepared.ctxPayload,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
dispatcherOptions: { dispatcherOptions: {
...replyPipeline, ...replyPipeline,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId), 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"; } from "openclaw/plugin-sdk/channel-send-result";
import { import {
normalizeMessagePresentation, normalizeMessagePresentation,
resolveInteractiveTextFallback, resolveLegacyInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { import {
@@ -299,7 +299,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
const payload = { const payload = {
...ctx.payload, ...ctx.payload,
text: text:
resolveInteractiveTextFallback({ resolveLegacyInteractiveTextFallback({
text: ctx.payload.text, text: ctx.payload.text,
interactive: ctx.payload.interactive, interactive: ctx.payload.interactive,
}) ?? "", }) ?? "",
+4 -2
View File
@@ -46,7 +46,9 @@ function createRuntime() {
messageSid: string; messageSid: string;
accountSid: string; accountSid: string;
}) => unknown; }) => unknown;
resolveTurn: (ingested: unknown) => Promise<{ routeSessionKey: string }>; resolveTurn: (
ingested: unknown,
) => Promise<{ route: { agentId: string; sessionKey: string } }>;
}; };
}) => void }) => 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, To: params.msg.to,
}, },
}); });
const storePath = params.channelRuntime.session.resolveStorePath(
params.cfg.session?.store,
{
agentId: route.agentId,
},
);
return { return {
cfg: params.cfg, cfg: params.cfg,
channel: CHANNEL_ID, channel: CHANNEL_ID,
accountId: params.account.accountId, accountId: params.account.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey },
routeSessionKey: sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: params.channelRuntime.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
durable: () => ({ durable: () => ({
to: from, to: from,
@@ -14,7 +14,7 @@ export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () =
); );
export const dispatchReplyWithBufferedBlockDispatcher: Mock< export const dispatchReplyWithBufferedBlockDispatcher: Mock<
() => Promise<{ counts: Record<string, number> }> (_params: unknown) => Promise<{ counts: Record<string, number> }>
> = vi.fn().mockResolvedValue({ counts: {} }); > = vi.fn().mockResolvedValue({ counts: {} });
export const finalizeInboundContextMock: Mock< export const finalizeInboundContextMock: Mock<
(ctx: Record<string, unknown>) => Record<string, unknown> (ctx: Record<string, unknown>) => Record<string, unknown>
@@ -152,7 +152,7 @@ vi.mock("./runtime.js", () => ({
kind: "message", kind: "message",
canStartAgentTurn: true, canStartAgentTurn: true,
}); });
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({ const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
ctx: resolved.ctxPayload, ctx: resolved.ctxPayload,
cfg: mockRuntimeConfig, cfg: mockRuntimeConfig,
dispatcherOptions: { dispatcherOptions: {
@@ -166,7 +166,7 @@ vi.mock("./runtime.js", () => ({
dispatched: true, dispatched: true,
dispatchResult, dispatchResult,
ctxPayload: resolved.ctxPayload, ctxPayload: resolved.ctxPayload,
routeSessionKey: resolved.routeSessionKey, routeSessionKey: resolved.route.sessionKey,
}; };
}), }),
buildContext: buildChannelInboundEventContextMock, buildContext: buildChannelInboundEventContextMock,
@@ -125,20 +125,15 @@ export async function dispatchSynologyChatInboundEvent(params: {
CommandAuthorized: params.msg.commandAuthorized, CommandAuthorized: params.msg.commandAuthorized,
}, },
}); });
const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, {
agentId: resolved.route.agentId,
});
return { return {
cfg: currentCfg, cfg: currentCfg,
channel: CHANNEL_ID, channel: CHANNEL_ID,
accountId: params.account.accountId, accountId: params.account.accountId,
agentId: resolved.route.agentId, route: {
routeSessionKey: resolved.route.sessionKey, agentId: resolved.route.agentId,
storePath, sessionKey: resolved.route.sessionKey,
},
ctxPayload: msgCtx, ctxPayload: msgCtx,
recordInboundSession: resolved.rt.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
durable: () => ({ durable: () => ({
to: sendUserId, to: sendUserId,
@@ -95,12 +95,14 @@ export async function runTelegramDispatchTurn(params: {
raw: context, raw: context,
}), }),
resolveTurn: () => ({ resolveTurn: () => ({
cfg: params.cfg,
channel: "telegram", channel: "telegram",
accountId: context.route.accountId, accountId: context.route.accountId,
routeSessionKey: context.route.sessionKey, route: {
storePath: context.turn.storePath, agentId: context.route.agentId,
sessionKey: context.route.sessionKey,
},
ctxPayload: context.ctxPayload, ctxPayload: context.ctxPayload,
recordInboundSession: context.turn.recordInboundSession,
record: context.turn.record, record: context.turn.record,
runDispatch: () => runDispatch: () =>
params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ 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) => { vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
const actual = const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>(); 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. // Telegram plugin module implements button types behavior.
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; 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 { import {
isMessagePresentationInteractiveBlock, isMessagePresentationInteractiveBlock,
normalizeMessagePresentation, normalizeMessagePresentation,
normalizeInteractiveReply, normalizeLegacyInteractiveReply,
resolveMessagePresentationButtonAction, resolveMessagePresentationButtonAction,
type InteractiveReply, type LegacyInteractiveReply,
type MessagePresentation, type MessagePresentation,
type MessagePresentationButton, type MessagePresentationButton,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
@@ -100,9 +100,9 @@ function chunkInteractiveButtons(
* @deprecated Use buildTelegramPresentationButtons with MessagePresentation. * @deprecated Use buildTelegramPresentationButtons with MessagePresentation.
*/ */
function buildTelegramInteractiveButtons( function buildTelegramInteractiveButtons(
interactive?: InteractiveReply, interactive?: LegacyInteractiveReply,
): TelegramInlineButtons | undefined { ): TelegramInlineButtons | undefined {
const rows = reduceInteractiveReply( const rows = reduceLegacyInteractiveReply(
interactive, interactive,
[] as TelegramInlineButton[][], [] as TelegramInlineButton[][],
(state, block) => { (state, block) => {
@@ -159,7 +159,7 @@ export function resolveTelegramInlineButtons(params: {
}): TelegramInlineButtons | undefined { }): TelegramInlineButtons | undefined {
return ( return (
params.buttons ?? params.buttons ??
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) ?? buildTelegramInteractiveButtons(normalizeLegacyInteractiveReply(params.interactive)) ??
buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation)) buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation))
); );
} }
@@ -1,12 +1,12 @@
// Telegram plugin module implements interactive fallback behavior. // Telegram plugin module implements interactive fallback behavior.
import { import {
adaptMessagePresentationForChannel, adaptMessagePresentationForChannel,
interactiveReplyToPresentation, legacyInteractiveReplyToPresentation,
isMessagePresentationInteractiveBlock, isMessagePresentationInteractiveBlock,
normalizeMessagePresentation, normalizeMessagePresentation,
normalizeInteractiveReply, normalizeLegacyInteractiveReply,
renderMessagePresentationFallbackText, renderMessagePresentationFallbackText,
resolveInteractiveTextFallback, resolveLegacyInteractiveTextFallback,
type MessagePresentation, type MessagePresentation,
type MessagePresentationInteractiveBlock, type MessagePresentationInteractiveBlock,
} from "openclaw/plugin-sdk/interactive-runtime"; } from "openclaw/plugin-sdk/interactive-runtime";
@@ -122,7 +122,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
capabilities: TELEGRAM_PRESENTATION_CAPABILITIES, capabilities: TELEGRAM_PRESENTATION_CAPABILITIES,
}); });
const interactive = normalizeInteractiveReply(payload.interactive); const interactive = normalizeLegacyInteractiveReply(payload.interactive);
const existingButtons = resolveTelegramInlineButtons({ const existingButtons = resolveTelegramInlineButtons({
buttons: telegramData?.buttons, buttons: telegramData?.buttons,
interactive, interactive,
@@ -141,7 +141,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
presentation: { ...presentation, blocks: fallbackBlocks }, presentation: { ...presentation, blocks: fallbackBlocks },
}); });
const currentText = const currentText =
resolveInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? ""; resolveLegacyInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
const hasFallback = const hasFallback =
fallbackText.length > 0 && fallbackText.length > 0 &&
(currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`)); (currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`));
@@ -168,8 +168,8 @@ export function resolveTelegramInteractiveTextFallback(params: {
interactive?: unknown; interactive?: unknown;
presentation?: unknown; presentation?: unknown;
}): string | undefined { }): string | undefined {
const interactive = normalizeInteractiveReply(params.interactive); const interactive = normalizeLegacyInteractiveReply(params.interactive);
const text = resolveInteractiveTextFallback({ const text = resolveLegacyInteractiveTextFallback({
text: params.text ?? undefined, text: params.text ?? undefined,
interactive, interactive,
}); });
@@ -189,7 +189,7 @@ export function resolveTelegramInteractiveTextFallback(params: {
if (!interactive) { if (!interactive) {
return text; return text;
} }
const interactivePresentation = interactiveReplyToPresentation(interactive); const interactivePresentation = legacyInteractiveReplyToPresentation(interactive);
if (!interactivePresentation) { if (!interactivePresentation) {
return text; return text;
} }
+2 -2
View File
@@ -1,5 +1,5 @@
// Telegram plugin module implements voice behavior. // 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: { function resolveTelegramVoiceDecision(opts: {
wantsVoice: boolean; wantsVoice: boolean;
@@ -9,7 +9,7 @@ function resolveTelegramVoiceDecision(opts: {
if (!opts.wantsVoice) { if (!opts.wantsVoice) {
return { useVoice: false }; return { useVoice: false };
} }
if (isVoiceCompatibleAudio(opts)) { if (isVoiceMessageCompatibleAudio(opts)) {
return { useVoice: true }; return { useVoice: true };
} }
const contentType = opts.contentType ?? "unknown"; 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 { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
@@ -502,7 +503,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
bodyWithAttachments = mediaLines + "\n" + messageText; bodyWithAttachments = mediaLines + "\n" + messageText;
} }
const body = core.channel.reply.formatAgentEnvelope({ const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
channel: "Tlon", channel: "Tlon",
from: fromLabel, from: fromLabel,
timestamp, timestamp,
@@ -559,10 +560,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
cfg, cfg,
route.agentId, route.agentId,
).responsePrefix; ).responsePrefix;
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); const humanDelay = resolveHumanDelayConfig(cfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const deliveryTarget = isGroup ? groupChannel : senderShip; const deliveryTarget = isGroup ? groupChannel : senderShip;
const prepareReplyPayload = (payload: ReplyPayload): ReplyPayload => { 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}`); runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
}; };
await core.channel.inbound.dispatchReply({ await core.channel.inbound.dispatch({
channel: "tlon", channel: "tlon",
accountId: route.accountId, accountId: route.accountId,
cfg, cfg,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
preparePayload: prepareReplyPayload, preparePayload: prepareReplyPayload,
durable: deliveryTarget durable: deliveryTarget
+3 -11
View File
@@ -5,6 +5,7 @@
* resolves agent routes, and handles replies. * 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 type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-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 senderId = message.userId ?? message.username;
const fromLabel = message.displayName ?? message.username; const fromLabel = message.displayName ?? message.username;
const body = core.channel.reply.formatAgentEnvelope({ const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
channel: "Twitch", channel: "Twitch",
from: fromLabel, from: fromLabel,
timestamp: input.timestamp, timestamp: input.timestamp,
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
body: input.rawText, body: input.rawText,
}); });
const ctxPayload = core.channel.inbound.buildContext({ const ctxPayload = core.channel.inbound.buildContext({
@@ -113,9 +113,6 @@ async function processTwitchMessage(params: {
commandBody: input.textForCommands, commandBody: input.textForCommands,
}, },
}); });
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const tableMode = core.channel.text.resolveMarkdownTableMode({ const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg, cfg,
channel: "twitch", channel: "twitch",
@@ -125,13 +122,8 @@ async function processTwitchMessage(params: {
cfg, cfg,
channel: "twitch", channel: "twitch",
accountId, accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
durable: () => ({ durable: () => ({
to: `twitch:channel:${message.channel}`, to: `twitch:channel:${message.channel}`,
@@ -5,7 +5,6 @@ import {
type AckReactionHandle, type AckReactionHandle,
} from "openclaw/plugin-sdk/channel-feedback"; } from "openclaw/plugin-sdk/channel-feedback";
import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound"; import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { import {
createInternalHookEvent, createInternalHookEvent,
deriveInboundMessageHookContext, deriveInboundMessageHookContext,
@@ -544,12 +543,11 @@ export async function processMessage(params: {
}; };
}, },
resolveTurn: () => ({ resolveTurn: () => ({
cfg: params.cfg,
channel: "whatsapp", channel: "whatsapp",
accountId: params.route.accountId, accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey, route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey },
storePath,
ctxPayload, ctxPayload,
recordInboundSession,
record: { record: {
onRecordError: (err) => { onRecordError: (err) => {
params.replyLogger.warn( params.replyLogger.warn(
-1
View File
@@ -53,7 +53,6 @@ export {
type ReplyPayload, type ReplyPayload,
resolveClientIp, resolveClientIp,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveOpenProviderRuntimeGroupPolicy, resolveOpenProviderRuntimeGroupPolicy,
resolveWebhookPath, resolveWebhookPath,
resolveWebhookTargetWithAuthOrRejectSync, resolveWebhookTargetWithAuthOrRejectSync,
@@ -106,14 +106,6 @@ function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean):
describe("Zalo polling media replies", () => { describe("Zalo polling media replies", () => {
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx); const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const recordInboundSessionMock = vi.fn(async () => undefined); 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(); const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
beforeAll(async () => { beforeAll(async () => {
@@ -143,10 +135,6 @@ describe("Zalo polling media replies", () => {
); );
setLifecycleRuntimeCore( setLifecycleRuntimeCore(
{ {
routing: {
resolveAgentRoute:
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
reply: { reply: {
finalizeInboundContext: finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
@@ -21,14 +21,6 @@ describe("Zalo reply-once lifecycle", () => {
const recordInboundSessionMock = vi.fn( const recordInboundSessionMock = vi.fn(
async (_input: { sessionKey?: string; ctx?: Record<string, unknown> }) => undefined, 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(); const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
beforeAll(async () => { beforeAll(async () => {
@@ -38,10 +30,6 @@ describe("Zalo reply-once lifecycle", () => {
beforeEach(async () => { beforeEach(async () => {
await resetLifecycleTestState(); await resetLifecycleTestState();
setLifecycleRuntimeCore({ setLifecycleRuntimeCore({
routing: {
resolveAgentRoute:
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
reply: { reply: {
finalizeInboundContext: finalizeInboundContext:
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
@@ -60,10 +48,17 @@ describe("Zalo reply-once lifecycle", () => {
}); });
function createReplyOnceMonitorSetup() { function createReplyOnceMonitorSetup() {
return createLifecycleMonitorSetup({ const setup = createLifecycleMonitorSetup({
accountId: "acct-zalo-lifecycle", accountId: "acct-zalo-lifecycle",
dmPolicy: "open", dmPolicy: "open",
}); });
return {
...setup,
config: {
...setup.config,
session: { dmScope: "per-channel-peer" as const },
},
};
} }
function requireRecordInboundSessionArgs() { function requireRecordInboundSessionArgs() {
+9 -14
View File
@@ -1,15 +1,17 @@
// Zalo plugin module implements monitor behavior. // Zalo plugin module implements monitor behavior.
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; 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 { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; 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 { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { import {
deliverTextOrMediaReply, deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
type OutboundReplyPayload, type OutboundReplyPayload,
} from "openclaw/plugin-sdk/reply-payload"; } from "openclaw/plugin-sdk/reply-payload";
import { sleepWithAbort, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env"; 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 { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
const agentBody = agentBodyOverride ?? rawBody; const agentBody = agentBodyOverride ?? rawBody;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
cfg: config, cfg: config,
channel: "zalo", channel: "zalo",
accountId: account.accountId, accountId: account.accountId,
@@ -576,8 +578,6 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
kind: isGroup ? ("group" as const) : ("direct" as const), kind: isGroup ? ("group" as const) : ("direct" as const),
id: chatId, id: chatId,
}, },
runtime: core.channel,
sessionStore: config.session?.store,
}); });
if ( if (
@@ -591,7 +591,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const timestamp = resolveZaloTimestampMs(date); const timestamp = resolveZaloTimestampMs(date);
const { storePath, body } = buildEnvelope({ const body = buildEnvelope({
channel: "Zalo", channel: "Zalo",
from: fromLabel, from: fromLabel,
timestamp, timestamp,
@@ -673,17 +673,12 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
}, },
}; };
await core.channel.inbound.dispatchReply({ await core.channel.inbound.dispatch({
cfg: config, cfg: config,
channel: "zalo", channel: "zalo",
accountId: account.accountId, accountId: account.accountId,
agentId: route.agentId, route: { agentId: route.agentId, sessionKey: route.sessionKey },
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: { delivery: {
preparePayload: (payload) => preparePayload: (payload) =>
prepareZaloDurableReplyPayload({ prepareZaloDurableReplyPayload({
-1
View File
@@ -63,7 +63,6 @@ export {
isNumericTargetId, isNumericTargetId,
sendPayloadWithChunkedTextAndMedia, sendPayloadWithChunkedTextAndMedia,
} from "./runtime-support.js"; } from "./runtime-support.js";
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./runtime-support.js";
export { waitForAbortSignal } from "./runtime-support.js"; export { waitForAbortSignal } from "./runtime-support.js";
export { export {
WEBHOOK_ANOMALY_COUNTER_DEFAULTS, WEBHOOK_ANOMALY_COUNTER_DEFAULTS,

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