mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
765bb37364
commit
0e792b6de3
@@ -1,2 +1,2 @@
|
||||
cd7189431f2805258afc4ecb993fe286a2606035636a5f4055b1158a76c27d62 plugin-sdk-api-baseline.json
|
||||
660dbaa276792415bf7eb87c19240b19decd400b7e58745e87fe0f1f1cc33353 plugin-sdk-api-baseline.jsonl
|
||||
7e2ff4dedb7b220a133cb419ee91e67585b9b9f2799706f336cdea973be80bfb plugin-sdk-api-baseline.json
|
||||
0a85effc98fb17a65d463956704bcb2dab31076cf0fa0d85b463a765ec9dc439 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -571,7 +571,7 @@ Matrix inherits global defaults from `session.threadBindings` and supports per-c
|
||||
- `threadBindings.idleHours`
|
||||
- `threadBindings.maxAgeHours`
|
||||
- `threadBindings.spawnSessions`: gates both subagent and ACP thread spawns.
|
||||
- `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions`: narrower overrides for subagent-only or ACP-only spawns.
|
||||
- Deprecated `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions` keys are migrated to `spawnSessions` by `openclaw doctor --fix`.
|
||||
- `threadBindings.defaultSpawnContext`
|
||||
|
||||
Matrix thread-bound session spawns default on. Set `threadBindings.spawnSessions: false` to block top-level `/focus` and `/acp spawn --thread auto|here` from creating/binding Matrix threads. Set `threadBindings.defaultSpawnContext: "isolated"` when native subagent thread spawns should not fork the parent transcript.
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("handleClickClackInbound", () => {
|
||||
correlationId: "fakeco.case_1",
|
||||
});
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
||||
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
|
||||
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
|
||||
const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0];
|
||||
expect(completionRequest?.agentId).toBe("service-bot");
|
||||
@@ -270,9 +270,9 @@ describe("handleClickClackInbound", () => {
|
||||
message: createMessage(),
|
||||
});
|
||||
|
||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
||||
expect(dispatchReply).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||
expect(dispatchTurn).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates account toolsAllow into agent reply dispatch", async () => {
|
||||
@@ -297,9 +297,9 @@ describe("handleClickClackInbound", () => {
|
||||
message: createMessage(),
|
||||
});
|
||||
|
||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
||||
expect(dispatchReply).toHaveBeenCalledTimes(1);
|
||||
const dispatchParams = dispatchReply.mock.calls[0]?.[0] as
|
||||
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||
expect(dispatchTurn).toHaveBeenCalledTimes(1);
|
||||
const dispatchParams = dispatchTurn.mock.calls[0]?.[0] as
|
||||
| (Record<string, unknown> & {
|
||||
toolsAllow?: unknown;
|
||||
})
|
||||
@@ -335,12 +335,12 @@ describe("handleClickClackInbound", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
||||
expect(dispatchReply).toHaveBeenCalledTimes(2);
|
||||
const withoutOptIn = dispatchReply.mock.calls[0]?.[0] as {
|
||||
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||
expect(dispatchTurn).toHaveBeenCalledTimes(2);
|
||||
const withoutOptIn = dispatchTurn.mock.calls[0]?.[0] as {
|
||||
replyOptions?: { runId?: unknown; onItemEvent?: unknown; onModelSelected?: unknown };
|
||||
};
|
||||
const withOptIn = dispatchReply.mock.calls[1]?.[0] as {
|
||||
const withOptIn = dispatchTurn.mock.calls[1]?.[0] as {
|
||||
replyOptions?: {
|
||||
onItemEvent?: unknown;
|
||||
onModelSelected?: unknown;
|
||||
@@ -377,7 +377,7 @@ describe("handleClickClackInbound", () => {
|
||||
correlationId: "fakeco.case_2",
|
||||
});
|
||||
|
||||
const dispatchParams = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0];
|
||||
const dispatchParams = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0];
|
||||
expect(dispatchParams?.replyOptions?.runId).toBe(`clickclack:${VALID_MESSAGE_ID}`);
|
||||
|
||||
await dispatchParams?.delivery.deliver({ text: "correlated reply" }, {} as never);
|
||||
@@ -404,7 +404,7 @@ describe("handleClickClackInbound", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const delivery = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].delivery;
|
||||
const delivery = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].delivery;
|
||||
if (typeof delivery?.durable !== "function") {
|
||||
throw new Error("expected ClickClack media durable delivery resolver");
|
||||
}
|
||||
@@ -437,7 +437,7 @@ describe("handleClickClackInbound", () => {
|
||||
message: createMessage({ id: "msg_invalid" }),
|
||||
});
|
||||
|
||||
expect(vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].replyOptions).toBe(
|
||||
expect(vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].replyOptions).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -461,15 +461,15 @@ describe("handleClickClackInbound", () => {
|
||||
}),
|
||||
config: cfg,
|
||||
message: createMessage({
|
||||
channel_id: undefined,
|
||||
channel_id: "",
|
||||
direct_conversation_id: "dcn_1",
|
||||
}),
|
||||
});
|
||||
|
||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
||||
expect(dispatchReply).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
|
||||
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||
expect(dispatchTurn).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
|
||||
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves session policy when an account overrides the routed agent", async () => {
|
||||
@@ -503,8 +503,8 @@ describe("handleClickClackInbound", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
||||
expect(dispatchReply.mock.calls[0]?.[0].routeSessionKey).toBe(
|
||||
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||
expect(dispatchTurn.mock.calls[0]?.[0].route.sessionKey).toBe(
|
||||
"agent:service-bot:clickclack:direct:alice",
|
||||
);
|
||||
expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({
|
||||
@@ -546,10 +546,12 @@ describe("handleClickClackInbound", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
||||
expect(dispatchReply.mock.calls[0]?.[0]).toMatchObject({
|
||||
agentId: "service-bot",
|
||||
routeSessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner",
|
||||
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||
expect(dispatchTurn.mock.calls[0]?.[0]).toMatchObject({
|
||||
route: {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
|
||||
/**
|
||||
* Converts authorized ClickClack messages into OpenClaw agent/model replies and
|
||||
@@ -150,6 +151,10 @@ export async function handleClickClackInbound(params: {
|
||||
if (!access.shouldDispatch) {
|
||||
return;
|
||||
}
|
||||
const conversationId = message.channel_id || message.direct_conversation_id;
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
const isDirect = Boolean(message.direct_conversation_id);
|
||||
const target = buildClickClackTarget(
|
||||
isDirect
|
||||
@@ -200,52 +205,53 @@ export async function handleClickClackInbound(params: {
|
||||
});
|
||||
}
|
||||
const senderName = message.author?.display_name || message.author_id;
|
||||
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
||||
storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
}),
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
// Preserve both normalized channel fields and ClickClack-native ids so reply
|
||||
// routing, session recovery, and command authorization see the same message.
|
||||
const body = runtime.channel.reply.formatAgentEnvelope({
|
||||
const body = createChannelInboundEnvelopeBuilder({
|
||||
cfg: params.config as OpenClawConfig,
|
||||
route,
|
||||
})({
|
||||
channel: "ClickClack",
|
||||
from: senderName,
|
||||
timestamp: new Date(message.created_at),
|
||||
previousTimestamp,
|
||||
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
|
||||
body: message.body,
|
||||
});
|
||||
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: message.body,
|
||||
RawBody: message.body,
|
||||
CommandBody: message.body,
|
||||
From: target,
|
||||
To: target,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId ?? params.account.accountId,
|
||||
ChatType: isDirect ? "direct" : "group",
|
||||
WasMentioned: isDirect ? undefined : true,
|
||||
ConversationLabel: isDirect ? senderName : message.channel_id,
|
||||
GroupChannel: message.channel_id,
|
||||
NativeChannelId: message.channel_id || message.direct_conversation_id,
|
||||
MessageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||
ThreadParentId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: message.author_id,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
MessageSid: message.id,
|
||||
MessageSidFull: message.id,
|
||||
ReplyToId: message.id,
|
||||
Timestamp: message.created_at,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: target,
|
||||
CommandAuthorized: access.commandAuthorized,
|
||||
const ctxPayload = runtime.channel.inbound.buildContext({
|
||||
channel: CHANNEL_ID,
|
||||
accountId: route.accountId ?? params.account.accountId,
|
||||
messageId: message.id,
|
||||
messageIdFull: message.id,
|
||||
timestamp: new Date(message.created_at).getTime(),
|
||||
from: target,
|
||||
sender: { id: message.author_id, name: senderName },
|
||||
conversation: {
|
||||
kind: isDirect ? "direct" : "group",
|
||||
id: conversationId,
|
||||
label: isDirect ? senderName : message.channel_id,
|
||||
threadId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||
nativeChannelId: conversationId,
|
||||
},
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
},
|
||||
reply: {
|
||||
to: target,
|
||||
originatingTo: target,
|
||||
replyToId: message.id,
|
||||
messageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||
threadParentId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||
},
|
||||
message: { body, bodyForAgent: message.body, rawBody: message.body, commandBody: message.body },
|
||||
access: {
|
||||
commands: { authorized: access.commandAuthorized },
|
||||
mentions: {
|
||||
canDetectMention: !isDirect,
|
||||
wasMentioned: !isDirect,
|
||||
},
|
||||
},
|
||||
extra: { GroupChannel: message.channel_id },
|
||||
});
|
||||
const runId = resolveClickClackAgentRunId(message.id);
|
||||
const activityReplyOptions = activity
|
||||
@@ -266,17 +272,12 @@ export async function handleClickClackInbound(params: {
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
||||
}
|
||||
: undefined;
|
||||
const dispatchPromise = runtime.channel.inbound.dispatchReply({
|
||||
const dispatchPromise = runtime.channel.inbound.dispatch({
|
||||
cfg: params.config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
toolsAllow: params.account.toolsAllow,
|
||||
// Provenance stamping shares the agentActivity opt-in: with the flag off
|
||||
// the extension's wire payloads stay byte-identical to pre-activity
|
||||
|
||||
@@ -66,7 +66,7 @@ export function createCodexAppServerAgentHarness(options: {
|
||||
delegatedExecutionPluginIds: ["voice-call"],
|
||||
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
|
||||
deliveryDefaults: {
|
||||
sourceVisibleReplies: "message_tool",
|
||||
visibleReplies: "message_tool",
|
||||
},
|
||||
authBootstrap: "harness",
|
||||
authBinding: {
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("codex plugin", () => {
|
||||
expect(agentHarnessRegistration.id).toBe("codex");
|
||||
expect(agentHarnessRegistration.label).toBe("Codex agent harness");
|
||||
expect(agentHarnessRegistration.deliveryDefaults).toEqual({
|
||||
sourceVisibleReplies: "message_tool",
|
||||
visibleReplies: "message_tool",
|
||||
});
|
||||
expect(typeof agentHarnessRegistration.dispose).toBe("function");
|
||||
expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function");
|
||||
@@ -419,7 +419,7 @@ describe("codex plugin", () => {
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
|
||||
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
|
||||
expect(harness.deliveryDefaults?.visibleReplies).toBe("message_tool");
|
||||
expect(
|
||||
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
|
||||
.supported,
|
||||
|
||||
@@ -153,9 +153,7 @@ export function createCodexAttemptLifecycleController(
|
||||
startedAt: attemptStartedAt,
|
||||
endedAt: Date.now(),
|
||||
...data,
|
||||
...((params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd)
|
||||
? { phase: "finishing" }
|
||||
: {}),
|
||||
...(params.deferTerminalLifecycle ? { phase: "finishing" } : {}),
|
||||
},
|
||||
});
|
||||
state.lifecycleTerminalEmitted = true;
|
||||
|
||||
@@ -51,25 +51,15 @@ setupRunAttemptTestHooks();
|
||||
|
||||
describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
it.each([
|
||||
{ label: "completed", status: "completed" as const, error: undefined, legacy: false },
|
||||
{ label: "failed", status: "failed" as const, error: "codex exploded", legacy: false },
|
||||
{
|
||||
label: "completed legacy alias",
|
||||
status: "completed" as const,
|
||||
error: undefined,
|
||||
legacy: true,
|
||||
},
|
||||
])("defers $label lifecycle terminal ownership", async ({ status, error, legacy }) => {
|
||||
{ label: "completed", status: "completed" as const, error: undefined },
|
||||
{ label: "failed", status: "failed" as const, error: "codex exploded" },
|
||||
])("defers $label lifecycle terminal ownership", async ({ status, error }) => {
|
||||
const onRunAgentEvent = vi.fn();
|
||||
const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`);
|
||||
const workspaceDir = path.join(tempDir, `workspace-${status}`);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
if (legacy) {
|
||||
params.deferTerminalLifecycleEnd = true;
|
||||
} else {
|
||||
params.deferTerminalLifecycle = true;
|
||||
}
|
||||
params.deferTerminalLifecycle = true;
|
||||
params.onAgentEvent = onRunAgentEvent;
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
adaptMessagePresentationForChannel,
|
||||
normalizeInteractiveReply,
|
||||
normalizeLegacyInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
renderMessagePresentationFallbackText,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
@@ -153,7 +153,7 @@ export async function handleDiscordMessageAction(
|
||||
? undefined
|
||||
: (params.components ??
|
||||
presentationComponents ??
|
||||
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive)));
|
||||
buildDiscordInteractiveComponents(normalizeLegacyInteractiveReply(params.interactive)));
|
||||
const hasComponents =
|
||||
Boolean(rawComponents) &&
|
||||
(typeof rawComponents === "function" || typeof rawComponents === "object");
|
||||
|
||||
@@ -178,11 +178,9 @@ export async function dispatchDiscordComponentEvent(params: {
|
||||
|
||||
const {
|
||||
createReplyReferencePlanner,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
finalizeInboundContext,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
recordInboundSession,
|
||||
} = await (async () => {
|
||||
const conversationRuntime = await loadConversationRuntime();
|
||||
return {
|
||||
@@ -273,12 +271,8 @@ export async function dispatchDiscordComponentEvent(params: {
|
||||
cfg: ctx.cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
agentId,
|
||||
routeSessionKey: sessionKey,
|
||||
storePath,
|
||||
route: { agentId, sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
record: {
|
||||
updateLastRoute: interactionCtx.isDirectMessage
|
||||
? {
|
||||
|
||||
@@ -369,6 +369,55 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||
const replyRuntime = await import("openclaw/plugin-sdk/reply-runtime");
|
||||
return {
|
||||
...actual,
|
||||
dispatchChannelInboundTurn: async (
|
||||
plan: Parameters<typeof actual.dispatchChannelInboundTurn>[0],
|
||||
) => {
|
||||
const { cfg, route, delivery, sessionInitRetry, ...prepared } = plan;
|
||||
const runDispatch = async () => {
|
||||
for (let retryIndex = 0; ; retryIndex += 1) {
|
||||
try {
|
||||
return await replyRuntime.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: plan.ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...plan.dispatcherOptions,
|
||||
deliver: delivery.deliver,
|
||||
onError: delivery.onError,
|
||||
},
|
||||
toolsAllow: plan.toolsAllow,
|
||||
replyOptions: plan.replyOptions,
|
||||
replyResolver: plan.replyResolver,
|
||||
});
|
||||
} catch (error) {
|
||||
const delayMs = sessionInitRetry?.delaysMs[retryIndex];
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
delayMs === undefined ||
|
||||
sessionInitRetry?.signal?.aborted === true ||
|
||||
!/^reply session initialization conflicted for \S+$/u.test(message)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await sessionInitRetry?.sleep?.(delayMs, sessionInitRetry.signal);
|
||||
}
|
||||
}
|
||||
};
|
||||
return await actual.runPreparedInboundReply({
|
||||
...prepared,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId: route.agentId }),
|
||||
recordInboundSession,
|
||||
runDispatch,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSession(...args),
|
||||
resolvePinnedMainDmOwnerFromAllowlist: (params: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
shouldAckReaction as shouldAckReactionGate,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import {
|
||||
dispatchChannelInboundReply,
|
||||
dispatchChannelInboundTurn,
|
||||
hasFinalInboundReplyDispatch,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveTranscriptBackedChannelFinalText,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
@@ -36,7 +34,13 @@ import {
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { danger, logVerbose, shouldLogVerbose, sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
danger,
|
||||
logVerbose,
|
||||
shouldLogVerbose,
|
||||
sleep,
|
||||
sleepWithAbort,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
@@ -57,15 +61,11 @@ import {
|
||||
import { buildDiscordMessageProcessContext } from "./message-handler.context.js";
|
||||
import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import {
|
||||
completeDiscordSessionConflict,
|
||||
withDiscordSessionRetry,
|
||||
} from "./message-handler.retry.js";
|
||||
import { completeDiscordSessionConflict } from "./message-handler.retry.js";
|
||||
import { deliverDiscordReply, formatDiscordReplyDeliveryFailure } from "./reply-delivery.js";
|
||||
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
|
||||
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
||||
|
||||
const loadReplyRuntime = createLazyRuntimeModule(() => import("openclaw/plugin-sdk/reply-runtime"));
|
||||
const TARGETED_ONLY_ALLOWED_MENTIONS = {
|
||||
parse: ["users", "roles"],
|
||||
} as APIAllowedMentions;
|
||||
@@ -185,7 +185,6 @@ async function processDiscordMessageInner(
|
||||
if (boundThreadId && typeof threadBindings.touchThread === "function") {
|
||||
threadBindings.touchThread({ threadId: boundThreadId });
|
||||
}
|
||||
const { dispatchReplyWithBufferedBlockDispatcher: dispatchReply } = await loadReplyRuntime();
|
||||
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
|
||||
cfg,
|
||||
ctx: {
|
||||
@@ -991,7 +990,11 @@ async function processDiscordMessageInner(
|
||||
);
|
||||
};
|
||||
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
||||
let dispatchResult: Awaited<ReturnType<typeof dispatchReply>> | null = null;
|
||||
let dispatchResult: {
|
||||
queuedFinal: boolean;
|
||||
counts: Record<ReplyDispatchKind, number>;
|
||||
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
|
||||
} | null = null;
|
||||
let dispatchError = false;
|
||||
let dispatchAborted = false;
|
||||
const deliverPendingToolWarningFinalIfNeeded = async () => {
|
||||
@@ -1015,17 +1018,18 @@ async function processDiscordMessageInner(
|
||||
dispatchAborted = true;
|
||||
return;
|
||||
}
|
||||
const preparedResult = await dispatchChannelInboundReply({
|
||||
const preparedResult = await dispatchChannelInboundTurn({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: persistedSessionKey,
|
||||
storePath: turn.storePath,
|
||||
route: { agentId: route.agentId, sessionKey: persistedSessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
afterRecord: queueInitialAckReactionAfterRecord,
|
||||
dispatchReplyWithBufferedBlockDispatcher: withDiscordSessionRetry(dispatchReply, abortSignal),
|
||||
sessionInitRetry: {
|
||||
delaysMs: [250, 1_000, 2_500],
|
||||
signal: abortSignal,
|
||||
sleep: sleepWithAbort,
|
||||
},
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// Discord plugin module implements narrow inbound dispatch retry behavior.
|
||||
import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { DiscordRetryableInboundError } from "./inbound-dedupe.js";
|
||||
|
||||
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /^reply session initialization conflicted for \S+$/u;
|
||||
const DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS = [250, 1_000, 2_500] as const;
|
||||
const DISCORD_SESSION_CONFLICT_FAILURE_TEXT =
|
||||
"⚠️ Couldn't process this message because the session stayed busy. Please try again in a moment.";
|
||||
|
||||
type AsyncDispatch<TParams, TResult> = (params: TParams) => Promise<TResult>;
|
||||
type TerminalFailureDelivery = (
|
||||
payload: { text: string; isError: true },
|
||||
info: { kind: "final" },
|
||||
@@ -19,63 +15,12 @@ function isReplySessionInitConflictError(error: unknown): boolean {
|
||||
return REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(message);
|
||||
}
|
||||
|
||||
class DiscordReplySessionConflictExhaustedError extends DiscordRetryableInboundError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "DiscordReplySessionConflictExhaustedError";
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchDiscordReplyWithSessionConflictRetry<T>(params: {
|
||||
dispatch: () => Promise<T>;
|
||||
abortSignal?: AbortSignal;
|
||||
onRetry?: (attempt: number, delayMs: number) => void;
|
||||
}): Promise<T> {
|
||||
for (let retryIndex = 0; ; retryIndex += 1) {
|
||||
try {
|
||||
return await params.dispatch();
|
||||
} catch (error) {
|
||||
if (!isReplySessionInitConflictError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const delayMs = DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS[retryIndex];
|
||||
if (delayMs === undefined) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Let the caller either complete with a visible terminal notice or
|
||||
// reopen replay ownership when that notice cannot land.
|
||||
throw new DiscordReplySessionConflictExhaustedError(
|
||||
`discord: reply session init conflict persisted after shared and channel retries: ${message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
params.onRetry?.(retryIndex + 1, delayMs);
|
||||
await sleepWithAbort(delayMs, params.abortSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function withDiscordSessionRetry<TParams, TResult>(
|
||||
dispatch: AsyncDispatch<TParams, TResult>,
|
||||
abortSignal: AbortSignal | undefined,
|
||||
): AsyncDispatch<TParams, TResult> {
|
||||
return (dispatchParams) =>
|
||||
dispatchDiscordReplyWithSessionConflictRetry({
|
||||
dispatch: () => dispatch(dispatchParams),
|
||||
abortSignal,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
logVerbose(
|
||||
`discord: reply session init conflict; retrying dispatch ${attempt} after ${delayMs}ms`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeDiscordSessionConflict(
|
||||
error: unknown,
|
||||
deliver: TerminalFailureDelivery,
|
||||
onDeliveryError: DeliveryErrorHandler,
|
||||
): Promise<boolean> {
|
||||
if (!(error instanceof DiscordReplySessionConflictExhaustedError)) {
|
||||
if (!isReplySessionInitConflictError(error)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -87,7 +32,10 @@ export async function completeDiscordSessionConflict(
|
||||
} catch (deliveryError) {
|
||||
// Keep the conflict retryable when its visible terminal notice cannot land.
|
||||
onDeliveryError(deliveryError, { kind: "final" });
|
||||
return false;
|
||||
throw new DiscordRetryableInboundError(
|
||||
`discord: reply session init conflict exhausted and terminal notice failed: ${String(deliveryError)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Discord plugin module implements shared interactive behavior.
|
||||
import {
|
||||
reduceInteractiveReply,
|
||||
reduceLegacyInteractiveReply,
|
||||
resolveMessagePresentationButtonAction,
|
||||
resolveMessagePresentationOptionAction,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import type {
|
||||
InteractiveButtonStyle,
|
||||
InteractiveReply,
|
||||
LegacyInteractiveReply,
|
||||
MessagePresentation,
|
||||
MessagePresentationButton,
|
||||
MessagePresentationOption,
|
||||
@@ -141,9 +141,9 @@ function appendDiscordButtonBlocks(
|
||||
* @deprecated Use buildDiscordPresentationComponents with MessagePresentation.
|
||||
*/
|
||||
export function buildDiscordInteractiveComponents(
|
||||
interactive?: InteractiveReply,
|
||||
interactive?: LegacyInteractiveReply,
|
||||
): DiscordComponentMessageSpec | undefined {
|
||||
const blocks = reduceInteractiveReply(
|
||||
const blocks = reduceLegacyInteractiveReply(
|
||||
interactive,
|
||||
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
|
||||
(state, block) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Feishu tests cover bot.broadcast plugin behavior.
|
||||
import type { EnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import { feishuGroupNameCache } from "./bot-group-name-state.js";
|
||||
@@ -7,25 +6,71 @@ import type { FeishuMessageEvent } from "./bot.js";
|
||||
import { handleFeishuMessage } from "./bot.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
|
||||
const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } =
|
||||
vi.hoisted(() => ({
|
||||
mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback: vi.fn(),
|
||||
})),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveAgentRoute: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
builtInboundContextCalls,
|
||||
mockCreateFeishuReplyDispatcher,
|
||||
mockCreateFeishuClient,
|
||||
mockDispatchInboundMessage,
|
||||
mockRecordInboundSession,
|
||||
mockResolveAgentRoute,
|
||||
mockResolveStorePath,
|
||||
} = vi.hoisted(() => ({
|
||||
builtInboundContextCalls: [] as Array<Record<string, unknown>>,
|
||||
mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
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", () => ({
|
||||
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
||||
@@ -48,43 +93,7 @@ function createRuntimeEnv() {
|
||||
}
|
||||
|
||||
describe("broadcast dispatch", () => {
|
||||
const finalizeInboundContextCalls: Array<Record<string, unknown>> = [];
|
||||
const mockGetChatInfo = vi.fn();
|
||||
const mockFinalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"] = (
|
||||
ctx,
|
||||
) => {
|
||||
finalizeInboundContextCalls.push(ctx);
|
||||
return {
|
||||
...ctx,
|
||||
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
|
||||
CommandTurn: {
|
||||
kind: "normal",
|
||||
source: "message",
|
||||
authorized: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
const mockDispatchReplyFromConfig = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
||||
const mockWithReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"] = async ({
|
||||
dispatcher,
|
||||
run,
|
||||
onSettled,
|
||||
}) => {
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
const resolveEnvelopeFormatOptionsMock: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"] =
|
||||
() => ({}) satisfies EnvelopeFormatOptions;
|
||||
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
|
||||
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
path: "/tmp/inbound-clip.mp4",
|
||||
@@ -99,18 +108,10 @@ describe("broadcast dispatch", () => {
|
||||
resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
|
||||
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: resolveEnvelopeFormatOptionsMock,
|
||||
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
||||
finalizeInboundContext:
|
||||
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
||||
withReplyDispatcher:
|
||||
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
resolveStorePath: mockResolveStorePath,
|
||||
recordInboundSession: mockRecordInboundSession,
|
||||
},
|
||||
reply: {},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
@@ -135,9 +136,13 @@ describe("broadcast dispatch", () => {
|
||||
if (!("runDispatch" in turn)) {
|
||||
throw new Error("feishu broadcast test runtime only supports prepared turns");
|
||||
}
|
||||
await turn.recordInboundSession({
|
||||
storePath: turn.storePath,
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
||||
const routeSessionKey = "route" in turn ? turn.route.sessionKey : turn.routeSessionKey;
|
||||
const storePath = "storePath" in turn ? turn.storePath : mockResolveStorePath();
|
||||
const recordInboundSession =
|
||||
"recordInboundSession" in turn ? turn.recordInboundSession : mockRecordInboundSession;
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? routeSessionKey,
|
||||
ctx: turn.ctxPayload,
|
||||
groupResolution: turn.record?.groupResolution,
|
||||
createIfMissing: turn.record?.createIfMissing,
|
||||
@@ -148,7 +153,7 @@ describe("broadcast dispatch", () => {
|
||||
admission: { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: turn.ctxPayload,
|
||||
routeSessionKey: turn.routeSessionKey,
|
||||
routeSessionKey,
|
||||
dispatchResult: await turn.runDispatch(),
|
||||
};
|
||||
}),
|
||||
@@ -219,8 +224,12 @@ describe("broadcast dispatch", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDispatchInboundMessage.mockReset().mockResolvedValue({
|
||||
queuedFinal: false,
|
||||
counts: { final: 1 },
|
||||
});
|
||||
feishuGroupNameCache.clear();
|
||||
finalizeInboundContextCalls.length = 0;
|
||||
builtInboundContextCalls.length = 0;
|
||||
mockResolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
@@ -277,8 +286,8 @@ describe("broadcast dispatch", () => {
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
||||
const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey);
|
||||
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
|
||||
const sessionKeys = builtInboundContextCalls.map((call) => call.SessionKey);
|
||||
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
|
||||
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
|
||||
const recordCalls = (
|
||||
@@ -320,7 +329,7 @@ describe("broadcast dispatch", () => {
|
||||
]);
|
||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
finalizeInboundContextCalls
|
||||
builtInboundContextCalls
|
||||
.map((call) => ({
|
||||
sessionKey: call.SessionKey,
|
||||
groupSubject: call.GroupSubject,
|
||||
@@ -347,7 +356,7 @@ describe("broadcast dispatch", () => {
|
||||
});
|
||||
|
||||
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
|
||||
mockDispatchReplyFromConfig
|
||||
mockDispatchInboundMessage
|
||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
queuedFinal: false,
|
||||
@@ -389,7 +398,7 @@ describe("broadcast dispatch", () => {
|
||||
});
|
||||
|
||||
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
|
||||
mockDispatchReplyFromConfig
|
||||
mockDispatchInboundMessage
|
||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
queuedFinal: true,
|
||||
@@ -430,7 +439,7 @@ describe("broadcast dispatch", () => {
|
||||
});
|
||||
|
||||
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
|
||||
mockDispatchReplyFromConfig
|
||||
mockDispatchInboundMessage
|
||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
queuedFinal: false,
|
||||
@@ -484,7 +493,7 @@ describe("broadcast dispatch", () => {
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
|
||||
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
|
||||
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -502,7 +511,7 @@ describe("broadcast dispatch", () => {
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
|
||||
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
|
||||
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -539,14 +548,14 @@ describe("broadcast dispatch", () => {
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeInboundContextCalls).toHaveLength(1);
|
||||
expect(finalizeInboundContextCalls[0]?.SessionKey).toBe(
|
||||
expect(builtInboundContextCalls).toHaveLength(1);
|
||||
expect(builtInboundContextCalls[0]?.SessionKey).toBe(
|
||||
"agent:main:feishu:group:oc-broadcast-group",
|
||||
);
|
||||
expect(finalizeInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
|
||||
expect(finalizeInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
|
||||
expect(builtInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
|
||||
expect(builtInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
|
||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -584,11 +593,11 @@ describe("broadcast dispatch", () => {
|
||||
runtime: createRuntimeEnv(),
|
||||
accountId: "account-A",
|
||||
});
|
||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
mockDispatchReplyFromConfig.mockClear();
|
||||
mockDispatchInboundMessage.mockClear();
|
||||
mockGetChatInfo.mockClear();
|
||||
finalizeInboundContextCalls.length = 0;
|
||||
builtInboundContextCalls.length = 0;
|
||||
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
@@ -596,7 +605,7 @@ describe("broadcast dispatch", () => {
|
||||
runtime: createRuntimeEnv(),
|
||||
accountId: "account-B",
|
||||
});
|
||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
|
||||
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -634,10 +643,10 @@ describe("broadcast dispatch", () => {
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const sessionKey =
|
||||
typeof finalizeInboundContextCalls[0]?.SessionKey === "string"
|
||||
? finalizeInboundContextCalls[0].SessionKey
|
||||
typeof builtInboundContextCalls[0]?.SessionKey === "string"
|
||||
? builtInboundContextCalls[0].SessionKey
|
||||
: "";
|
||||
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ function buildDefaultResolveRoute(): ResolvedAgentRoute {
|
||||
let currentRuntimeConfig = {} as ClawdbotConfig;
|
||||
|
||||
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
||||
return {
|
||||
const runtime = {
|
||||
config: {
|
||||
current: vi.fn(() => currentRuntimeConfig),
|
||||
},
|
||||
@@ -209,9 +209,14 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
|
||||
kind: "message",
|
||||
canStartAgentTurn: true,
|
||||
});
|
||||
await turn.recordInboundSession({
|
||||
storePath: turn.storePath,
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
||||
if (!("route" in turn) || !("runDispatch" in turn)) {
|
||||
throw new Error("expected a prepared channel turn plan");
|
||||
}
|
||||
await runtime.channel.session.recordInboundSession({
|
||||
storePath: runtime.channel.session.resolveStorePath(turn.cfg.session?.store, {
|
||||
agentId: turn.route.agentId,
|
||||
}),
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
|
||||
ctx: turn.ctxPayload,
|
||||
groupResolution: turn.record?.groupResolution,
|
||||
createIfMissing: turn.record?.createIfMissing,
|
||||
@@ -229,6 +234,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
|
||||
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
|
||||
...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
|
||||
} as unknown as PluginRuntime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
|
||||
@@ -239,7 +245,6 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession
|
||||
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
|
||||
mockResolveStorePath(params);
|
||||
const resolveEnvelopeFormatOptionsMock = () => ({});
|
||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const withReplyDispatcherMock = async ({
|
||||
run,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
|
||||
@@ -299,6 +304,8 @@ const {
|
||||
mockResolveFeishuReasoningPreviewEnabled,
|
||||
mockTranscribeFirstAudio,
|
||||
mockMaybeCreateDynamicAgent,
|
||||
mockBuildChannelInboundEventContext,
|
||||
mockDispatchInboundMessage,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||
dispatcher: createReplyDispatcher(),
|
||||
@@ -336,8 +343,49 @@ const {
|
||||
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
|
||||
mockTranscribeFirstAudio: vi.fn(),
|
||||
mockMaybeCreateDynamicAgent: vi.fn(),
|
||||
mockBuildChannelInboundEventContext: vi.fn(),
|
||||
mockDispatchInboundMessage: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }),
|
||||
}));
|
||||
|
||||
const finalizeInboundContextMock = mockBuildChannelInboundEventContext;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
|
||||
"openclaw/plugin-sdk/channel-inbound",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
buildChannelInboundEventContext: (
|
||||
params: Parameters<typeof actual.buildChannelInboundEventContext>[0],
|
||||
) =>
|
||||
actual.buildChannelInboundEventContext({
|
||||
...params,
|
||||
finalize: (ctx) => {
|
||||
mockBuildChannelInboundEventContext(ctx);
|
||||
return ctx as never;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
|
||||
"openclaw/plugin-sdk/reply-runtime",
|
||||
);
|
||||
return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage };
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
|
||||
"openclaw/plugin-sdk/session-store-runtime",
|
||||
);
|
||||
return { ...actual, resolveStorePath: mockResolveStorePath };
|
||||
});
|
||||
|
||||
vi.mock("./reply-dispatcher.js", () => ({
|
||||
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
||||
}));
|
||||
@@ -966,42 +1014,11 @@ describe("handleFeishuMessage ACP routing", () => {
|
||||
);
|
||||
expect(dispatcherOptions.allowReasoningPreview).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to full runtime channel when partial channelRuntime lacks inbound", async () => {
|
||||
const partialChannelRuntime = {
|
||||
runtimeContexts: {} as PluginRuntime["channel"]["runtimeContexts"],
|
||||
} as PluginRuntime["channel"];
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-partial-runtime",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
channelRuntime: partialChannelRuntime,
|
||||
});
|
||||
|
||||
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage command authorization", () => {
|
||||
const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({
|
||||
...ctx,
|
||||
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
|
||||
}));
|
||||
const mockDispatchReplyFromConfig = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
||||
const mockFinalizeInboundContext = mockBuildChannelInboundEventContext;
|
||||
const mockDispatchReplyFromConfig = mockDispatchInboundMessage;
|
||||
const mockWithReplyDispatcher = vi.fn(
|
||||
async ({
|
||||
dispatcher,
|
||||
@@ -1037,6 +1054,10 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDispatchReplyFromConfig.mockReset().mockResolvedValue({
|
||||
queuedFinal: false,
|
||||
counts: { final: 1 },
|
||||
});
|
||||
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
||||
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
|
||||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Feishu plugin module implements bot behavior.
|
||||
import {
|
||||
buildChannelInboundEventContext,
|
||||
formatAgentEnvelope,
|
||||
formatInboundMediaUnavailableText,
|
||||
resolveEnvelopeFormatOptions,
|
||||
toInboundMediaFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/channel-outbound";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
createChannelHistoryWindow,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
resolveDefaultGroupPolicy,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
@@ -977,7 +980,7 @@ export async function handleFeishuMessage(params: {
|
||||
(groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender");
|
||||
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const messageBody = buildFeishuAgentBody({
|
||||
ctx: agentFacingCtx,
|
||||
quotedContent,
|
||||
@@ -990,7 +993,7 @@ export async function handleFeishuMessage(params: {
|
||||
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
||||
}
|
||||
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
from: envelopeFrom,
|
||||
timestamp: new Date(),
|
||||
@@ -1008,7 +1011,7 @@ export async function handleFeishuMessage(params: {
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
// Preserve speaker identity in group history as well.
|
||||
from: `${ctx.chatId}:${entry.sender}`,
|
||||
@@ -1116,7 +1119,7 @@ export async function handleFeishuMessage(params: {
|
||||
return threadContext;
|
||||
}
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: agentSessionKey,
|
||||
@@ -1182,7 +1185,7 @@ export async function handleFeishuMessage(params: {
|
||||
: relevantMessages.slice(1);
|
||||
const historyParts = historyMessages.map((msg) => {
|
||||
const role = msg.senderType === "app" ? "assistant" : "user";
|
||||
return core.channel.reply.formatAgentEnvelope({
|
||||
return formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
from: `${msg.senderId ?? "Unknown"} (${role})`,
|
||||
timestamp: msg.createTime,
|
||||
@@ -1216,7 +1219,6 @@ export async function handleFeishuMessage(params: {
|
||||
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
|
||||
return buildChannelInboundEventContext({
|
||||
channel: "feishu",
|
||||
finalize: core.channel.reply.finalizeInboundContext,
|
||||
supplemental: {
|
||||
quote: quotedContent ? { id: ctx.parentId, body: quotedContent } : undefined,
|
||||
thread: {
|
||||
@@ -1388,7 +1390,7 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
|
||||
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
||||
const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
const agentStorePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId,
|
||||
});
|
||||
const agentRecord = {
|
||||
@@ -1456,12 +1458,11 @@ export async function handleFeishuMessage(params: {
|
||||
raw: ctx,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: agentSessionKey,
|
||||
storePath: agentStorePath,
|
||||
route: { agentId, sessionKey: agentSessionKey },
|
||||
ctxPayload: agentCtx,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
record: agentRecord,
|
||||
onPreDispatchFailure: () =>
|
||||
core.channel.reply.settleReplyDispatcher({
|
||||
@@ -1469,16 +1470,12 @@ export async function handleFeishuMessage(params: {
|
||||
onSettled: () => markDispatchIdle(),
|
||||
}),
|
||||
runDispatch: () =>
|
||||
core.channel.reply.withReplyDispatcher({
|
||||
dispatchInboundMessage({
|
||||
ctx: agentCtx,
|
||||
cfg,
|
||||
dispatcher,
|
||||
onSettled: () => markDispatchIdle(),
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: agentCtx,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
}),
|
||||
replyOptions,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
@@ -1524,22 +1521,17 @@ export async function handleFeishuMessage(params: {
|
||||
raw: ctx,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: agentSessionKey,
|
||||
storePath: agentStorePath,
|
||||
route: { agentId, sessionKey: agentSessionKey },
|
||||
ctxPayload: agentCtx,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
record: agentRecord,
|
||||
runDispatch: () =>
|
||||
core.channel.reply.withReplyDispatcher({
|
||||
dispatchInboundMessage({
|
||||
ctx: agentCtx,
|
||||
cfg,
|
||||
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 storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
|
||||
const storePath = resolveStorePath(effectiveCfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
|
||||
@@ -1637,12 +1629,11 @@ export async function handleFeishuMessage(params: {
|
||||
raw: ctx,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: effectiveCfg,
|
||||
channel: "feishu",
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
record: {
|
||||
updateLastRoute: buildFeishuInboundLastRouteUpdate({
|
||||
sessionKey: route.sessionKey,
|
||||
@@ -1666,18 +1657,12 @@ export async function handleFeishuMessage(params: {
|
||||
onSettled: () => markDispatchIdle(),
|
||||
}),
|
||||
runDispatch: () =>
|
||||
core.channel.reply.withReplyDispatcher({
|
||||
dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg: effectiveCfg,
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg: effectiveCfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
}),
|
||||
onSettled: () => markDispatchIdle(),
|
||||
replyOptions,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -30,10 +30,10 @@ import {
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import {
|
||||
interactiveReplyToPresentation,
|
||||
normalizeInteractiveReply,
|
||||
legacyInteractiveReplyToPresentation,
|
||||
normalizeLegacyInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
resolveInteractiveTextFallback,
|
||||
resolveLegacyInteractiveTextFallback,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
@@ -1074,10 +1074,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
const textCard = readNativeFeishuCardJson(text, {
|
||||
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
|
||||
});
|
||||
const interactive = normalizeInteractiveReply(ctx.params.interactive);
|
||||
const interactive = normalizeLegacyInteractiveReply(ctx.params.interactive);
|
||||
const presentation =
|
||||
normalizeMessagePresentation(ctx.params.presentation) ??
|
||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
||||
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||
const mediaUrl = readFeishuMediaParam(ctx.params);
|
||||
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
|
||||
if (textCard && !presentation) {
|
||||
@@ -1088,7 +1088,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
presentation,
|
||||
fallbackText: textCard
|
||||
? undefined
|
||||
: resolveInteractiveTextFallback({ text, interactive }),
|
||||
: resolveLegacyInteractiveTextFallback({ text, interactive }),
|
||||
})
|
||||
: undefined;
|
||||
const presentationCard =
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Feishu plugin module implements comment dispatcher behavior.
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import {
|
||||
@@ -56,10 +58,10 @@ export function createFeishuCommentReplyDispatcher(
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
onReplyStart: async () => {
|
||||
await typingReaction.start();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Feishu tests cover comment handler plugin behavior.
|
||||
import type { PreparedInboundReply } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import { handleFeishuCommentEvent } from "./comment-handler.js";
|
||||
@@ -10,6 +9,7 @@ const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
|
||||
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
|
||||
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchInboundMessageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./monitor.comment.js", () => ({
|
||||
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
|
||||
@@ -31,6 +31,11 @@ vi.mock("./drive.js", () => ({
|
||||
deliverCommentThreadText: deliverCommentThreadTextMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>()),
|
||||
dispatchInboundMessage: dispatchInboundMessageMock,
|
||||
}));
|
||||
|
||||
async function raceWithNextMacrotask<T>(promise: Promise<T>): Promise<T | "pending"> {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
@@ -83,38 +88,19 @@ function createTestRuntime(overrides?: {
|
||||
readAllowFromStore?: () => Promise<unknown[]>;
|
||||
upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>;
|
||||
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
|
||||
dispatchReplyFromConfig?: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"];
|
||||
withReplyDispatcher?: PluginRuntime["channel"]["reply"]["withReplyDispatcher"];
|
||||
}) {
|
||||
const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const dispatchReplyFromConfig =
|
||||
overrides?.dispatchReplyFromConfig ??
|
||||
vi.fn(async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
}));
|
||||
const withReplyDispatcher =
|
||||
overrides?.withReplyDispatcher ??
|
||||
vi.fn(
|
||||
async ({
|
||||
run,
|
||||
onSettled,
|
||||
}: {
|
||||
run: () => Promise<unknown>;
|
||||
onSettled?: () => Promise<void> | void;
|
||||
}) => {
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
const recordInboundSession = vi.fn(async () => {});
|
||||
const dispatchPreparedForTest = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
|
||||
await turn.recordInboundSession({
|
||||
storePath: turn.storePath,
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
||||
const recordInboundSession = vi.fn(async (_params: unknown) => {});
|
||||
type PreparedCommentTurnPlan = {
|
||||
route: { agentId: string; sessionKey: string };
|
||||
ctxPayload: { SessionKey?: string };
|
||||
record?: Record<string, unknown> & { onRecordError?: (error: unknown) => void };
|
||||
runDispatch: () => Promise<unknown>;
|
||||
};
|
||||
const dispatchPreparedForTest = vi.fn(async (turn: PreparedCommentTurnPlan) => {
|
||||
const storePath = "/tmp/feishu-session-store.json";
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
|
||||
ctx: turn.ctxPayload,
|
||||
groupResolution: turn.record?.groupResolution,
|
||||
createIfMissing: turn.record?.createIfMissing,
|
||||
@@ -126,7 +112,7 @@ function createTestRuntime(overrides?: {
|
||||
admission: { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: turn.ctxPayload,
|
||||
routeSessionKey: turn.routeSessionKey,
|
||||
routeSessionKey: turn.route.sessionKey,
|
||||
dispatchResult,
|
||||
};
|
||||
});
|
||||
@@ -151,9 +137,11 @@ function createTestRuntime(overrides?: {
|
||||
resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())),
|
||||
},
|
||||
reply: {
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig,
|
||||
withReplyDispatcher,
|
||||
settleReplyDispatcher: vi.fn(async ({ dispatcher, onSettled }) => {
|
||||
dispatcher.markComplete();
|
||||
await dispatcher.waitForIdle();
|
||||
await onSettled?.();
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
|
||||
@@ -176,7 +164,7 @@ function createTestRuntime(overrides?: {
|
||||
if (!("runDispatch" in turn)) {
|
||||
throw new Error("feishu comment test runtime only supports prepared turns");
|
||||
}
|
||||
return await dispatchPreparedForTest(turn as PreparedInboundReply<unknown>);
|
||||
return await dispatchPreparedForTest(turn as PreparedCommentTurnPlan);
|
||||
}) as unknown as PluginRuntime["channel"]["inbound"]["run"],
|
||||
},
|
||||
pairing: {
|
||||
@@ -206,6 +194,10 @@ describe("handleFeishuCommentEvent", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dispatchInboundMessageMock.mockResolvedValue({
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
});
|
||||
currentRuntimeConfig = buildConfig();
|
||||
maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({
|
||||
created: false,
|
||||
@@ -270,20 +262,16 @@ describe("handleFeishuCommentEvent", () => {
|
||||
);
|
||||
|
||||
const runtime = (await import("./runtime.js")).getFeishuRuntime();
|
||||
const finalizeInboundContext = runtime.channel.reply.finalizeInboundContext as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
expect(finalizeInboundContext).toHaveBeenCalledTimes(1);
|
||||
const finalizedContext = mockCallArg(finalizeInboundContext, "finalizeInboundContext") as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
const finalizedContext = (
|
||||
mockCallArg(dispatchInboundMessageMock, "dispatchInboundMessage") as {
|
||||
ctx?: Record<string, unknown>;
|
||||
}
|
||||
).ctx;
|
||||
expect({
|
||||
from: finalizedContext?.From,
|
||||
to: finalizedContext?.To,
|
||||
@@ -306,7 +294,6 @@ describe("handleFeishuCommentEvent", () => {
|
||||
| { sessionKey?: string }
|
||||
| undefined;
|
||||
expect(recordArgs?.sessionKey).toBe("agent:main:feishu:direct:comment-doc:docx:doc_token_1");
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows comment senders matched by user_id allowlist entries", async () => {
|
||||
@@ -332,10 +319,7 @@ describe("handleFeishuCommentEvent", () => {
|
||||
} as never,
|
||||
});
|
||||
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -376,10 +360,7 @@ describe("handleFeishuCommentEvent", () => {
|
||||
| undefined;
|
||||
expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender");
|
||||
expect(dynamicAgentArgs?.accountId).toBe("default");
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops a comment denied by refreshed dynamic-agent policy", async () => {
|
||||
@@ -410,12 +391,9 @@ describe("handleFeishuCommentEvent", () => {
|
||||
} as never,
|
||||
});
|
||||
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
|
||||
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("issues a pairing challenge before dynamic comment-agent creation", async () => {
|
||||
@@ -446,12 +424,9 @@ describe("handleFeishuCommentEvent", () => {
|
||||
} as never,
|
||||
});
|
||||
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
|
||||
expect(deliverCommentThreadTextMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
|
||||
@@ -506,10 +481,7 @@ describe("handleFeishuCommentEvent", () => {
|
||||
].join("\n"),
|
||||
is_whole_comment: false,
|
||||
});
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes whole-comment metadata to the comment reply dispatcher", async () => {
|
||||
@@ -565,10 +537,8 @@ describe("handleFeishuCommentEvent", () => {
|
||||
});
|
||||
|
||||
it("always finalizes comment typing cleanup even when dispatch fails", async () => {
|
||||
const dispatchReplyFromConfig = vi.fn(async () => {
|
||||
throw new Error("dispatch failed");
|
||||
});
|
||||
const runtime = createTestRuntime({ dispatchReplyFromConfig });
|
||||
dispatchInboundMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
|
||||
const runtime = createTestRuntime();
|
||||
setFeishuRuntime(runtime);
|
||||
const markRunComplete = vi.fn();
|
||||
const markDispatchIdle = vi.fn();
|
||||
@@ -669,10 +639,6 @@ describe("handleFeishuCommentEvent", () => {
|
||||
});
|
||||
|
||||
expect(startTypingReaction).not.toHaveBeenCalled();
|
||||
const runtime = (await import("./runtime.js")).getFeishuRuntime();
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Feishu plugin module implements comment handler behavior.
|
||||
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
@@ -214,36 +216,36 @@ export async function handleFeishuCommentEvent(
|
||||
fileToken: turn.fileToken,
|
||||
});
|
||||
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: bodyForAgent,
|
||||
BodyForAgent: bodyForAgent,
|
||||
RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
|
||||
CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
|
||||
From: `feishu:${turn.senderId}`,
|
||||
To: commentTarget,
|
||||
SessionKey: commentSessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: "direct",
|
||||
ConversationLabel: turn.documentTitle
|
||||
? `Feishu comment · ${turn.documentTitle}`
|
||||
: "Feishu comment",
|
||||
SenderName: turn.senderId,
|
||||
SenderId: turn.senderId,
|
||||
Provider: "feishu",
|
||||
Surface: "feishu-comment",
|
||||
MessageSid: turn.messageId,
|
||||
// For Feishu comment turns, MessageThreadId carries the inbound reply_id so
|
||||
// comment-aware tools can clean typing reaction before sending visible output.
|
||||
MessageThreadId: turn.replyId,
|
||||
Timestamp: parseTimestampMs(turn.timestamp),
|
||||
WasMentioned: turn.isMentioned,
|
||||
CommandAuthorized: false,
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: commentTarget,
|
||||
});
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
const rawBody = turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt;
|
||||
const conversationLabel = turn.documentTitle
|
||||
? `Feishu comment · ${turn.documentTitle}`
|
||||
: "Feishu comment";
|
||||
const ctxPayload = buildChannelInboundEventContext({
|
||||
channel: "feishu",
|
||||
accountId: route.accountId,
|
||||
surface: "feishu-comment",
|
||||
messageId: turn.messageId,
|
||||
timestamp: parseTimestampMs(turn.timestamp),
|
||||
from: `feishu:${turn.senderId}`,
|
||||
sender: { id: turn.senderId, name: turn.senderId },
|
||||
conversation: { kind: "direct", id: commentTarget, label: conversationLabel },
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: commentSessionKey,
|
||||
dispatchSessionKey: commentSessionKey,
|
||||
},
|
||||
reply: {
|
||||
to: commentTarget,
|
||||
originatingTo: commentTarget,
|
||||
// Comment-aware tools use the inbound reply id as the native thread id.
|
||||
messageThreadId: turn.replyId,
|
||||
},
|
||||
message: { body: bodyForAgent, bodyForAgent, rawBody, commandBody: rawBody },
|
||||
access: {
|
||||
commands: { authorized: false },
|
||||
mentions: { canDetectMention: true, wasMentioned: turn.isMentioned ?? false },
|
||||
},
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
|
||||
@@ -279,12 +281,11 @@ export async function handleFeishuCommentEvent(
|
||||
raw: turn,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: effectiveCfg,
|
||||
channel: "feishu",
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: commentSessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: commentSessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
error(
|
||||
@@ -303,15 +304,11 @@ export async function handleFeishuCommentEvent(
|
||||
});
|
||||
},
|
||||
runDispatch: () =>
|
||||
core.channel.reply.withReplyDispatcher({
|
||||
dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg: effectiveCfg,
|
||||
dispatcher,
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg: effectiveCfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
}),
|
||||
replyOptions,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import {
|
||||
interactiveReplyToPresentation,
|
||||
normalizeInteractiveReply,
|
||||
legacyInteractiveReplyToPresentation,
|
||||
normalizeLegacyInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
renderMessagePresentationFallbackText,
|
||||
resolveInteractiveTextFallback,
|
||||
resolveLegacyInteractiveTextFallback,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
||||
@@ -179,10 +179,10 @@ function buildFeishuPayloadCard(params: {
|
||||
|
||||
const rawText = params.text ?? params.payload.text;
|
||||
const textCard = readNativeFeishuCardJson(rawText);
|
||||
const interactive = normalizeInteractiveReply(params.payload.interactive);
|
||||
const interactive = normalizeLegacyInteractiveReply(params.payload.interactive);
|
||||
const presentation =
|
||||
normalizeMessagePresentation(params.payload.presentation) ??
|
||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
||||
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||
if (!presentation && !interactive) {
|
||||
if (!textCard) {
|
||||
return undefined;
|
||||
@@ -193,7 +193,7 @@ function buildFeishuPayloadCard(params: {
|
||||
|
||||
const text = textCard
|
||||
? undefined
|
||||
: resolveInteractiveTextFallback({
|
||||
: resolveLegacyInteractiveTextFallback({
|
||||
text: rawText,
|
||||
interactive,
|
||||
});
|
||||
@@ -600,10 +600,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload);
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||
if (parseFeishuCommentTarget(ctx.to)) {
|
||||
const interactive = normalizeInteractiveReply(payload.interactive);
|
||||
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
|
||||
const normalizedPresentation =
|
||||
normalizeMessagePresentation(payload.presentation) ??
|
||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
||||
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||
// Document comments cannot render cards. Resolve the text path before
|
||||
// validating card limits so unused native card data cannot block delivery.
|
||||
const textCard = readNativeFeishuCardJson(payload.text);
|
||||
@@ -652,10 +652,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
if (ttsSupplement) {
|
||||
return await sendFeishuTtsSupplementPayload({ ctx, payload, supplement: ttsSupplement });
|
||||
}
|
||||
const interactive = normalizeInteractiveReply(payload.interactive);
|
||||
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
|
||||
const presentation =
|
||||
normalizeMessagePresentation(payload.presentation) ??
|
||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
||||
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||
const fallbackPayload = presentation
|
||||
? {
|
||||
...payload,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Feishu plugin module implements reply dispatcher behavior.
|
||||
import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { formatReasoningMessage, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveTextChunksWithFallback,
|
||||
sendMediaWithLeadingCaption,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js";
|
||||
@@ -637,245 +638,240 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
return nextIdleSideEffects;
|
||||
};
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
||||
silentReplyContext: {
|
||||
cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
surface: "feishu",
|
||||
conversationType: chatId.startsWith("oc_") ? "group" : "direct",
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
skippedFinalReason = info.reason;
|
||||
}
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
if (!replyLifecycleStateInitialized) {
|
||||
replyLifecycleStateInitialized = true;
|
||||
deliveredFinalTexts.clear();
|
||||
sentIndependentBlockText = false;
|
||||
streamingClosedForReply = false;
|
||||
streamingCloseErroredForReply = false;
|
||||
visibleReplySent = false;
|
||||
skippedFinalReason = null;
|
||||
}
|
||||
if (streamingEnabled && renderMode === "card") {
|
||||
startStreaming();
|
||||
}
|
||||
await Promise.resolve(typingCallbacks?.onReplyStart?.());
|
||||
},
|
||||
deliver: async (payload: ReplyPayload, info) => {
|
||||
if (info?.kind === "final") {
|
||||
skippedFinalReason = null;
|
||||
}
|
||||
const payloadText =
|
||||
payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text;
|
||||
const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText });
|
||||
const text =
|
||||
info?.kind === "final"
|
||||
? mergeStreamingFinalText(
|
||||
streamText,
|
||||
reply.text,
|
||||
payload.isError === true && hasStreamingFinalText,
|
||||
)
|
||||
: reply.text;
|
||||
const hasText = reply.hasText;
|
||||
const hasMedia = reply.hasMedia;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||
const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true;
|
||||
const hasVoiceMedia =
|
||||
hasMedia &&
|
||||
reply.mediaUrls.some((mediaUrl) =>
|
||||
shouldSuppressFeishuTextForVoiceMedia({
|
||||
mediaUrl,
|
||||
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
ttsSupplement,
|
||||
}),
|
||||
);
|
||||
const finalTextExceedsStreamingLimit =
|
||||
info?.kind === "final" && hasText && text.length > textChunkLimit;
|
||||
const useStaticCard =
|
||||
hasText &&
|
||||
(renderMode === "card" ||
|
||||
(info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") ||
|
||||
(renderMode === "auto" && shouldUseCard(text)));
|
||||
const useStreamingCard =
|
||||
hasText &&
|
||||
streamingEnabled &&
|
||||
!finalTextExceedsStreamingLimit &&
|
||||
(info?.kind === "final" || useStaticCard);
|
||||
const finalTextWouldUseStreamingCard =
|
||||
info?.kind === "final" && hasText && streamingEnabled;
|
||||
const useCard = useStaticCard || useStreamingCard;
|
||||
const skipTextForDuplicateFinal =
|
||||
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
||||
const skipTextForClosedStreamingFinal =
|
||||
info?.kind === "final" &&
|
||||
hasText &&
|
||||
streamingClosedForReply &&
|
||||
!streamingCloseErroredForReply &&
|
||||
finalTextWouldUseStreamingCard;
|
||||
const shouldDeliverText =
|
||||
hasText &&
|
||||
!hasVoiceMedia &&
|
||||
!skipTextForDuplicateFinal &&
|
||||
!skipTextForClosedStreamingFinal;
|
||||
const shouldDiscardStreamingPreview =
|
||||
info?.kind === "final" &&
|
||||
(finalTextExceedsStreamingLimit ||
|
||||
(hasMedia &&
|
||||
((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) ||
|
||||
skipTextForDuplicateFinal)));
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, agentId),
|
||||
silentReplyContext: {
|
||||
cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
surface: "feishu",
|
||||
conversationType: chatId.startsWith("oc_") ? "group" : "direct",
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
skippedFinalReason = info.reason;
|
||||
}
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
if (!replyLifecycleStateInitialized) {
|
||||
replyLifecycleStateInitialized = true;
|
||||
deliveredFinalTexts.clear();
|
||||
sentIndependentBlockText = false;
|
||||
streamingClosedForReply = false;
|
||||
streamingCloseErroredForReply = false;
|
||||
visibleReplySent = false;
|
||||
skippedFinalReason = null;
|
||||
}
|
||||
if (streamingEnabled && renderMode === "card") {
|
||||
startStreaming();
|
||||
}
|
||||
await Promise.resolve(typingCallbacks?.onReplyStart?.());
|
||||
},
|
||||
deliver: async (payload: ReplyPayload, info) => {
|
||||
if (info?.kind === "final") {
|
||||
skippedFinalReason = null;
|
||||
}
|
||||
const payloadText =
|
||||
payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text;
|
||||
const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText });
|
||||
const text =
|
||||
info?.kind === "final"
|
||||
? mergeStreamingFinalText(
|
||||
streamText,
|
||||
reply.text,
|
||||
payload.isError === true && hasStreamingFinalText,
|
||||
)
|
||||
: reply.text;
|
||||
const hasText = reply.hasText;
|
||||
const hasMedia = reply.hasMedia;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||
const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true;
|
||||
const hasVoiceMedia =
|
||||
hasMedia &&
|
||||
reply.mediaUrls.some((mediaUrl) =>
|
||||
shouldSuppressFeishuTextForVoiceMedia({
|
||||
mediaUrl,
|
||||
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
ttsSupplement,
|
||||
}),
|
||||
);
|
||||
const finalTextExceedsStreamingLimit =
|
||||
info?.kind === "final" && hasText && text.length > textChunkLimit;
|
||||
const useStaticCard =
|
||||
hasText &&
|
||||
(renderMode === "card" ||
|
||||
(info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") ||
|
||||
(renderMode === "auto" && shouldUseCard(text)));
|
||||
const useStreamingCard =
|
||||
hasText &&
|
||||
streamingEnabled &&
|
||||
!finalTextExceedsStreamingLimit &&
|
||||
(info?.kind === "final" || useStaticCard);
|
||||
const finalTextWouldUseStreamingCard = info?.kind === "final" && hasText && streamingEnabled;
|
||||
const useCard = useStaticCard || useStreamingCard;
|
||||
const skipTextForDuplicateFinal =
|
||||
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
||||
const skipTextForClosedStreamingFinal =
|
||||
info?.kind === "final" &&
|
||||
hasText &&
|
||||
streamingClosedForReply &&
|
||||
!streamingCloseErroredForReply &&
|
||||
finalTextWouldUseStreamingCard;
|
||||
const shouldDeliverText =
|
||||
hasText && !hasVoiceMedia && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
|
||||
const shouldDiscardStreamingPreview =
|
||||
info?.kind === "final" &&
|
||||
(finalTextExceedsStreamingLimit ||
|
||||
(hasMedia &&
|
||||
((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) ||
|
||||
skipTextForDuplicateFinal)));
|
||||
|
||||
if (!shouldDeliverText && !hasMedia) {
|
||||
return;
|
||||
}
|
||||
if (!shouldDeliverText && !hasMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDiscardStreamingPreview) {
|
||||
await discardStreamingPreview();
|
||||
}
|
||||
if (shouldDiscardStreamingPreview) {
|
||||
await discardStreamingPreview();
|
||||
}
|
||||
|
||||
if (shouldDeliverText) {
|
||||
if (info?.kind === "block") {
|
||||
// Drop internal block chunks unless we can safely consume them as
|
||||
// streaming-card fallback content or send them as independent
|
||||
// messages for true progressive delivery.
|
||||
if (!useStreamingCard) {
|
||||
if (coreBlockStreamingEnabled) {
|
||||
// Reuse normal text chunking, but notify mentions only on the first visible chunk.
|
||||
const isFirstBlock = !sentIndependentBlockText;
|
||||
const firstChunkMentions =
|
||||
isFirstBlock && mentionTargets?.length ? mentionTargets : undefined;
|
||||
await sendChunkedTextReply({
|
||||
text,
|
||||
useCard: false,
|
||||
infoKind: "block",
|
||||
firstChunkMentions,
|
||||
sendChunk: async ({ chunk, isFirst }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: sendTarget,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
allowTopLevelReplyFallback,
|
||||
accountId,
|
||||
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
sentIndependentBlockText = true;
|
||||
if (hasMedia) {
|
||||
await sendMediaReplies(payload);
|
||||
}
|
||||
if (shouldDeliverText) {
|
||||
if (info?.kind === "block") {
|
||||
// Drop internal block chunks unless we can safely consume them as
|
||||
// streaming-card fallback content or send them as independent
|
||||
// messages for true progressive delivery.
|
||||
if (!useStreamingCard) {
|
||||
if (coreBlockStreamingEnabled) {
|
||||
// Reuse normal text chunking, but notify mentions only on the first visible chunk.
|
||||
const isFirstBlock = !sentIndependentBlockText;
|
||||
const firstChunkMentions =
|
||||
isFirstBlock && mentionTargets?.length ? mentionTargets : undefined;
|
||||
await sendChunkedTextReply({
|
||||
text,
|
||||
useCard: false,
|
||||
infoKind: "block",
|
||||
firstChunkMentions,
|
||||
sendChunk: async ({ chunk, isFirst }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: sendTarget,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
allowTopLevelReplyFallback,
|
||||
accountId,
|
||||
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
sentIndependentBlockText = true;
|
||||
if (hasMedia) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
startStreaming();
|
||||
if (streamingStartPromise) {
|
||||
await streamingStartPromise;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMedia) {
|
||||
await sendMediaReplies(
|
||||
payload,
|
||||
hasVoiceMedia && hasText ? { fallbackText: text } : undefined,
|
||||
);
|
||||
if (info?.kind === "final" && useStreamingCard) {
|
||||
startStreaming();
|
||||
if (streamingStartPromise) {
|
||||
await streamingStartPromise;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: async (error, info) => {
|
||||
streamingCloseErroredForReply = true;
|
||||
streamingClosedForReply = false;
|
||||
params.runtime.error?.(
|
||||
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(),
|
||||
onCleanup: () => {
|
||||
typingCallbacks?.onCleanup?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: async (error, info) => {
|
||||
streamingCloseErroredForReply = true;
|
||||
streamingClosedForReply = false;
|
||||
params.runtime.error?.(
|
||||
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
|
||||
);
|
||||
await queueIdleSideEffects({ markClosedForReply: false });
|
||||
},
|
||||
onIdle: () => queueIdleSideEffects(),
|
||||
onCleanup: () => {
|
||||
typingCallbacks?.onCleanup?.();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
|
||||
@@ -40,7 +40,6 @@ export type {
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export {
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
|
||||
@@ -21,6 +21,19 @@ const routingMocks = vi.hoisted(() => ({
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
const inboundMocks = vi.hoisted(() => ({
|
||||
buildEnvelope: vi.fn(({ body }: { body: string }) => body),
|
||||
resolveChannelInboundRouteEnvelope: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveChannelInboundRouteEnvelope: inboundMocks.resolveChannelInboundRouteEnvelope,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
|
||||
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
|
||||
@@ -43,34 +56,29 @@ beforeEach(() => {
|
||||
apiMocks.downloadGoogleChatMedia.mockReset();
|
||||
apiMocks.sendGoogleChatMessage.mockReset();
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
|
||||
inboundMocks.buildEnvelope.mockReset().mockImplementation(({ body }: { body: string }) => body);
|
||||
inboundMocks.resolveChannelInboundRouteEnvelope
|
||||
.mockReset()
|
||||
.mockImplementation(({ accountId }: { accountId: string }) => ({
|
||||
route: {
|
||||
agentId: "agent-1",
|
||||
accountId,
|
||||
sessionKey: "session-1",
|
||||
},
|
||||
buildEnvelope: inboundMocks.buildEnvelope,
|
||||
}));
|
||||
});
|
||||
|
||||
function createInboundClassificationHarness() {
|
||||
const resolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "agent-1",
|
||||
accountId: "work",
|
||||
sessionKey: "session-1",
|
||||
}));
|
||||
const buildContext = vi.fn((payload: unknown) => payload);
|
||||
const runTurn = vi.fn();
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
routing: { resolveAgentRoute },
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: vi.fn(),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
},
|
||||
inbound: { buildContext, run: runTurn },
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
return { buildContext, core, resolveAgentRoute, runTurn };
|
||||
return { buildContext, core, runTurn };
|
||||
}
|
||||
|
||||
async function processGoogleChatTestEvent(params: {
|
||||
@@ -177,7 +185,7 @@ describe("googlechat monitor inbound space classification", () => {
|
||||
] as const;
|
||||
|
||||
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
|
||||
const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness();
|
||||
const { buildContext, core, runTurn } = createInboundClassificationHarness();
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {},
|
||||
@@ -214,7 +222,7 @@ describe("googlechat monitor inbound space classification", () => {
|
||||
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isGroup }),
|
||||
);
|
||||
expect(resolveAgentRoute).toHaveBeenCalledWith({
|
||||
expect(inboundMocks.resolveChannelInboundRouteEnvelope).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "googlechat",
|
||||
accountId: "work",
|
||||
@@ -509,7 +517,6 @@ describe("googlechat monitor direct messages", () => {
|
||||
it("drops invalid event timestamps from inbound runtime payloads", async () => {
|
||||
const runTurn = vi.fn();
|
||||
const buildContext = vi.fn((payload: unknown) => payload);
|
||||
const formatAgentEnvelope = vi.fn(({ body }: { body: string }) => body);
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
@@ -527,7 +534,7 @@ describe("googlechat monitor direct messages", () => {
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope,
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
},
|
||||
inbound: { buildContext, run: runTurn },
|
||||
@@ -569,7 +576,7 @@ describe("googlechat monitor direct messages", () => {
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
expect(formatAgentEnvelope).toHaveBeenCalledWith(
|
||||
expect(inboundMocks.buildEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timestamp: undefined }),
|
||||
);
|
||||
expect(buildContext).toHaveBeenCalledWith(expect.objectContaining({ timestamp: undefined }));
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// Googlechat plugin module implements monitor behavior.
|
||||
import {
|
||||
recordChannelBotPairLoopAndCheckSuppression,
|
||||
resolveChannelInboundRouteEnvelope,
|
||||
type ChannelBotLoopProtectionFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
resolveWebhookPath,
|
||||
} from "../runtime-api.js";
|
||||
import { resolveWebhookPath } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
|
||||
@@ -252,7 +250,7 @@ async function processMessageWithPipeline(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
@@ -260,8 +258,6 @@ async function processMessageWithPipeline(params: {
|
||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||
id: spaceId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
@@ -279,7 +275,7 @@ async function processMessageWithPipeline(params: {
|
||||
? space.displayName || `space:${spaceId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const timestampMs = resolveGoogleChatTimestampMs(event.eventTime);
|
||||
const { storePath, body } = buildEnvelope({
|
||||
const body = buildEnvelope({
|
||||
channel: "Google Chat",
|
||||
from: fromLabel,
|
||||
timestamp: timestampMs,
|
||||
@@ -399,13 +395,8 @@ async function processMessageWithPipeline(params: {
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: (payload, info) =>
|
||||
resolveGoogleChatDurableReplyOptions({
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
||||
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
|
||||
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -1453,12 +1452,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
raw: decision,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
accountId: decision.route.accountId,
|
||||
routeSessionKey: decision.route.sessionKey,
|
||||
storePath,
|
||||
route: {
|
||||
agentId: decision.route.agentId,
|
||||
sessionKey: decision.route.sessionKey,
|
||||
},
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
record: {
|
||||
updateLastRoute:
|
||||
!decision.isGroup && updateTarget
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Irc plugin module implements inbound behavior.
|
||||
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
buildChannelInboundEventContext,
|
||||
logInboundDrop,
|
||||
resolveChannelInboundRouteEnvelope,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
createChannelIngressResolver,
|
||||
@@ -9,7 +13,6 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import {
|
||||
deliverFormattedTextWithAttachments,
|
||||
type OutboundReplyPayload,
|
||||
@@ -371,7 +374,7 @@ export async function handleIrcInbound(params: {
|
||||
? message.target
|
||||
: `#${message.target}`;
|
||||
const peerId = message.isGroup ? channelTarget : message.senderNick;
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
@@ -379,12 +382,10 @@ export async function handleIrcInbound(params: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
||||
const { storePath, body } = buildEnvelope({
|
||||
const body = buildEnvelope({
|
||||
channel: "IRC",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
@@ -394,41 +395,44 @@ export async function handleIrcInbound(params: {
|
||||
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
|
||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
|
||||
To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: message.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: message.senderNick || undefined,
|
||||
SenderId: senderDisplay,
|
||||
GroupSubject: message.isGroup ? message.target : undefined,
|
||||
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
WasMentioned: message.isGroup ? wasMentioned : undefined,
|
||||
MessageSid: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
const ctxPayload = buildChannelInboundEventContext({
|
||||
channel: CHANNEL_ID,
|
||||
accountId: route.accountId,
|
||||
messageId: message.messageId,
|
||||
timestamp: message.timestamp,
|
||||
from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
|
||||
sender: { id: senderDisplay, name: message.senderNick || undefined },
|
||||
conversation: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
label: fromLabel,
|
||||
},
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
},
|
||||
reply: {
|
||||
to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||
originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||
},
|
||||
message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody },
|
||||
access: {
|
||||
commands: { authorized: commandAuthorized },
|
||||
mentions: { canDetectMention: message.isGroup, wasMentioned },
|
||||
},
|
||||
extra: {
|
||||
GroupSubject: message.isGroup ? message.target : undefined,
|
||||
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await core.channel.inbound.dispatchReply({
|
||||
await core.channel.inbound.dispatch({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
|
||||
@@ -191,13 +191,8 @@ export async function monitorLineProvider(
|
||||
cfg: config,
|
||||
channel: "line",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: ctx.turn.storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
record: ctx.turn.record,
|
||||
replyPipeline: {},
|
||||
...(deliveryControl.abortSignal
|
||||
|
||||
@@ -161,7 +161,7 @@ async function setupMatrixTrace(recorder: WireRecorder) {
|
||||
}
|
||||
};
|
||||
|
||||
// The scripted steps stand in for the model run: dispatchReplyFromConfig
|
||||
// The scripted steps stand in for the model run: dispatchInboundMessage
|
||||
// stays pending until the script's final/cancel step settles it, so the
|
||||
// handler's post-dispatch flow (including the finally-block draft abandon
|
||||
// path) runs exactly where the real run would settle.
|
||||
@@ -188,7 +188,7 @@ async function setupMatrixTrace(recorder: WireRecorder) {
|
||||
markRunComplete: () => {},
|
||||
};
|
||||
},
|
||||
dispatchReplyFromConfig: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
|
||||
dispatchInboundMessage: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
|
||||
capturedReplyOptions = args?.replyOptions;
|
||||
notifyCaptured();
|
||||
const result = await runGate;
|
||||
|
||||
@@ -42,7 +42,6 @@ function createAudioPreflightHarness(
|
||||
matchedBy: "binding.account",
|
||||
}),
|
||||
resolveStorePath: () => "/tmp/openclaw-test-session.json",
|
||||
readSessionUpdatedAt: () => 123,
|
||||
getRoomInfo: async () => ({
|
||||
name: "Audio Room",
|
||||
canonicalAlias: "#audio:example.org",
|
||||
|
||||
@@ -110,7 +110,7 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
|
||||
groupPolicy: "open",
|
||||
isDirectMessage: false,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -119,24 +119,17 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
|
||||
}) => {
|
||||
capturedOnError = params?.onError;
|
||||
return {
|
||||
dispatcher: {},
|
||||
dispatcher: {
|
||||
markComplete: () => {},
|
||||
waitForIdle: async () => {
|
||||
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
|
||||
},
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: () => {},
|
||||
markRunComplete: () => {},
|
||||
};
|
||||
},
|
||||
withReplyDispatcher: async <T>(params: {
|
||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
||||
run: () => Promise<T>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => {
|
||||
const result = await params.run();
|
||||
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
|
||||
params.dispatcher.markComplete?.();
|
||||
await params.dispatcher.waitForIdle?.();
|
||||
await params.onSettled?.();
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,7 +166,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
groupPolicy: "open",
|
||||
isDirectMessage: false,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -200,7 +193,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
isDirectMessage: false,
|
||||
threadReplies: "off",
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -233,7 +226,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
isDirectMessage: false,
|
||||
threadReplies: "always",
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -267,7 +260,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
isDirectMessage: false,
|
||||
finalizeInboundContext,
|
||||
resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)),
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -313,7 +306,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
groupPolicy: "open",
|
||||
isDirectMessage: false,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -341,7 +334,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
groupPolicy: "open",
|
||||
isDirectMessage: false,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -371,7 +364,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
return "@bot:example.org";
|
||||
},
|
||||
},
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -394,7 +387,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
historyLimit: 20,
|
||||
isDirectMessage: true,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -428,7 +421,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
historyLimit: 20,
|
||||
isDirectMessage: true,
|
||||
getMemberDisplayName,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -458,7 +451,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
groupPolicy: "open",
|
||||
isDirectMessage: false,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -520,7 +513,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
||||
getRelations,
|
||||
},
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -560,7 +553,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
||||
let firstDispatchStarted = false;
|
||||
|
||||
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
||||
const dispatchReplyFromConfig = vi.fn(async () => {
|
||||
const dispatchInboundMessage = vi.fn(async () => {
|
||||
if (!firstDispatchStarted) {
|
||||
firstDispatchStarted = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -575,7 +568,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
||||
groupPolicy: "open",
|
||||
isDirectMessage: false,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
});
|
||||
|
||||
// Step 1: trigger msg A — don't await, let it block in dispatch
|
||||
@@ -687,7 +680,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
||||
isDirectMessage: false,
|
||||
getMemberDisplayName,
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -739,7 +732,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
||||
getEvent: async () => ({ sender: "@bot:example.org" }),
|
||||
},
|
||||
finalizeInboundContext,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
|
||||
@@ -42,7 +42,6 @@ function createMediaFailureHarness() {
|
||||
matchedBy: "binding.account",
|
||||
}),
|
||||
resolveStorePath: () => "/tmp/openclaw-test-session.json",
|
||||
readSessionUpdatedAt: () => 123,
|
||||
getRoomInfo: async () => ({
|
||||
name: "Media Room",
|
||||
canonicalAlias: "#media:example.org",
|
||||
|
||||
@@ -14,6 +14,15 @@ import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js";
|
||||
|
||||
type MatrixMonitorHandlerParams = Parameters<typeof createMatrixRoomMessageHandler>[0];
|
||||
type MatrixDispatchInboundMessage = (params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcher: unknown;
|
||||
replyOptions?: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
queuedFinal: boolean;
|
||||
counts: { final: number; block: number; tool: number };
|
||||
}>;
|
||||
|
||||
const DEFAULT_ROUTE = {
|
||||
agentId: "ops",
|
||||
@@ -68,9 +77,7 @@ type MatrixHandlerTestHarnessOptions = {
|
||||
resolveMarkdownTableMode?: () => string;
|
||||
resolveAgentRoute?: () => typeof DEFAULT_ROUTE;
|
||||
resolveStorePath?: () => string;
|
||||
readSessionUpdatedAt?: () => number | undefined;
|
||||
recordInboundSession?: (...args: unknown[]) => Promise<void>;
|
||||
resolveEnvelopeFormatOptions?: () => Record<string, never>;
|
||||
formatAgentEnvelope?: ({ body }: { body: string }) => string;
|
||||
finalizeInboundContext?: (ctx: unknown) => unknown;
|
||||
createReplyDispatcherWithTyping?: (params?: {
|
||||
@@ -82,19 +89,8 @@ type MatrixHandlerTestHarnessOptions = {
|
||||
markRunComplete: () => void;
|
||||
};
|
||||
resolveHumanDelayConfig?: () => undefined;
|
||||
dispatchReplyFromConfig?: () => Promise<{
|
||||
queuedFinal: boolean;
|
||||
counts: { final: number; block: number; tool: number };
|
||||
}>;
|
||||
dispatchInboundMessage?: MatrixDispatchInboundMessage;
|
||||
runPrepared?: MatrixRunPreparedMock;
|
||||
withReplyDispatcher?: <T>(params: {
|
||||
dispatcher: {
|
||||
markComplete?: () => void;
|
||||
waitForIdle?: () => Promise<void>;
|
||||
};
|
||||
run: () => Promise<T>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => Promise<T>;
|
||||
inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"];
|
||||
shouldAckReaction?: () => boolean;
|
||||
enqueueSystemEvent?: (...args: unknown[]) => void;
|
||||
@@ -104,10 +100,7 @@ type MatrixHandlerTestHarnessOptions = {
|
||||
};
|
||||
|
||||
type MatrixHandlerTestHarness = {
|
||||
dispatchReplyFromConfig: () => Promise<{
|
||||
queuedFinal: boolean;
|
||||
counts: { final: number; block: number; tool: number };
|
||||
}>;
|
||||
dispatchInboundMessage: MatrixDispatchInboundMessage;
|
||||
enqueueSystemEvent: (...args: unknown[]) => void;
|
||||
finalizeInboundContext: (ctx: unknown) => unknown;
|
||||
handler: ReturnType<typeof createMatrixRoomMessageHandler>;
|
||||
@@ -137,12 +130,55 @@ export function createMatrixHandlerTestHarness(
|
||||
? finalizeCoreInboundContext(ctx as Record<string, unknown>)
|
||||
: ctx,
|
||||
);
|
||||
const dispatchReplyFromConfig =
|
||||
options.dispatchReplyFromConfig ??
|
||||
const dispatchInboundMessage =
|
||||
options.dispatchInboundMessage ??
|
||||
(async () => ({
|
||||
queuedFinal: false,
|
||||
counts: { final: 0, block: 0, tool: 0 },
|
||||
}));
|
||||
const createReplyDispatcherWithTyping =
|
||||
options.createReplyDispatcherWithTyping ??
|
||||
(() => ({
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: () => {},
|
||||
markRunComplete: () => {},
|
||||
}));
|
||||
const dispatchInboundMessageWithBufferedDispatcher = (async ({
|
||||
ctx,
|
||||
cfg,
|
||||
dispatcherOptions,
|
||||
replyOptions,
|
||||
}: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcherOptions: Record<string, unknown>;
|
||||
replyOptions?: Record<string, unknown>;
|
||||
}) => {
|
||||
const prepared = createReplyDispatcherWithTyping(dispatcherOptions);
|
||||
try {
|
||||
return await dispatchInboundMessage({
|
||||
ctx,
|
||||
cfg,
|
||||
dispatcher: prepared.dispatcher,
|
||||
replyOptions: { ...replyOptions, ...prepared.replyOptions },
|
||||
} as never);
|
||||
} finally {
|
||||
const dispatcher = prepared.dispatcher as {
|
||||
markComplete?: () => void;
|
||||
waitForIdle?: () => Promise<void>;
|
||||
};
|
||||
dispatcher.markComplete?.();
|
||||
await dispatcher.waitForIdle?.();
|
||||
await (dispatcherOptions.onSettled as (() => Promise<void> | void) | undefined)?.();
|
||||
prepared.markRunComplete();
|
||||
prepared.markDispatchIdle();
|
||||
}
|
||||
}) as NonNullable<MatrixMonitorHandlerParams["dispatchInboundMessageWithBufferedDispatcher"]>;
|
||||
const createChannelInboundEnvelopeBuilder = (() => (input: { body: string }) =>
|
||||
(options.formatAgentEnvelope ?? (({ body }: { body: string }) => body))({
|
||||
body: input.body,
|
||||
})) as NonNullable<MatrixMonitorHandlerParams["createChannelInboundEnvelopeBuilder"]>;
|
||||
const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn();
|
||||
const runPrepared =
|
||||
options.runPrepared ??
|
||||
@@ -184,7 +220,16 @@ export function createMatrixHandlerTestHarness(
|
||||
: (preflightResult ?? {});
|
||||
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
||||
if ("runDispatch" in turn) {
|
||||
return await runPrepared(turn);
|
||||
const preparedTurn =
|
||||
"route" in turn
|
||||
? ({
|
||||
...turn,
|
||||
routeSessionKey: turn.route.sessionKey,
|
||||
storePath: "/tmp/matrix-sessions.json",
|
||||
recordInboundSession,
|
||||
} as PreparedInboundReply<unknown>)
|
||||
: turn;
|
||||
return await runPrepared(preparedTurn);
|
||||
}
|
||||
throw new Error("matrix test helper only supports prepared turn dispatch");
|
||||
},
|
||||
@@ -233,47 +278,19 @@ export function createMatrixHandlerTestHarness(
|
||||
buildMentionRegexes: () => options.mentionRegexes ?? [],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
|
||||
readSessionUpdatedAt: options.readSessionUpdatedAt ?? (() => undefined),
|
||||
recordInboundSession,
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})),
|
||||
formatAgentEnvelope:
|
||||
options.formatAgentEnvelope ?? (({ body }: { body: string }) => body),
|
||||
finalizeInboundContext,
|
||||
createReplyDispatcherWithTyping:
|
||||
options.createReplyDispatcherWithTyping ??
|
||||
(() => ({
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: () => {},
|
||||
markRunComplete: () => {},
|
||||
})),
|
||||
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
|
||||
dispatchReplyFromConfig,
|
||||
withReplyDispatcher:
|
||||
options.withReplyDispatcher ??
|
||||
(async <T>(params: {
|
||||
dispatcher: {
|
||||
markComplete?: () => void;
|
||||
waitForIdle?: () => Promise<void>;
|
||||
};
|
||||
run: () => Promise<T>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => {
|
||||
const { dispatcher, run: runLocal, onSettled } = params;
|
||||
try {
|
||||
return await runLocal();
|
||||
} finally {
|
||||
dispatcher.markComplete?.();
|
||||
try {
|
||||
await dispatcher.waitForIdle?.();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
}
|
||||
}),
|
||||
settleReplyDispatcher: async ({
|
||||
dispatcher,
|
||||
onSettled,
|
||||
}: Parameters<
|
||||
MatrixMonitorHandlerParams["core"]["channel"]["reply"]["settleReplyDispatcher"]
|
||||
>[0]) => {
|
||||
dispatcher.markComplete?.();
|
||||
await dispatcher.waitForIdle?.();
|
||||
await onSettled?.();
|
||||
},
|
||||
},
|
||||
inbound: {
|
||||
run,
|
||||
@@ -332,11 +349,16 @@ export function createMatrixHandlerTestHarness(
|
||||
getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"),
|
||||
needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false,
|
||||
resolveLiveUserAllowlist: options.resolveLiveUserAllowlist,
|
||||
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
|
||||
createChannelInboundEnvelopeBuilder,
|
||||
finalizeInboundContext,
|
||||
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
|
||||
dispatchInboundMessageWithBufferedDispatcher,
|
||||
historyLimit: options.historyLimit ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
enqueueSystemEvent,
|
||||
finalizeInboundContext,
|
||||
handler,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
|
||||
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
|
||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import {
|
||||
createMatrixHandlerTestHarness,
|
||||
createMatrixReactionEvent,
|
||||
@@ -527,6 +526,10 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
getMemberDisplayName: async () => "sender",
|
||||
dropPreStartupMessages: true,
|
||||
needsRoomAliasesForConfig: false,
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
@@ -580,12 +583,12 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
});
|
||||
|
||||
it("does not enqueue delivered text messages into system events", async () => {
|
||||
const dispatchReplyFromConfig = vi.fn(async () => ({
|
||||
const dispatchInboundMessage = vi.fn(async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}));
|
||||
const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
isDirectMessage: true,
|
||||
getMemberDisplayName: async () => "sender",
|
||||
});
|
||||
@@ -599,7 +602,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalled();
|
||||
expect(dispatchInboundMessage).toHaveBeenCalled();
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1275,7 +1278,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
resolveNotice = resolve;
|
||||
});
|
||||
const sendNotice = vi.fn(() => noticeSent);
|
||||
const dispatchReplyFromConfig = vi.fn(async () => ({
|
||||
const dispatchInboundMessage = vi.fn(async () => ({
|
||||
counts: { block: 0, final: 0, tool: 0 },
|
||||
queuedFinal: false,
|
||||
}));
|
||||
@@ -1289,7 +1292,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
});
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
isDirectMessage: true,
|
||||
resolveStorePath: () => storePath,
|
||||
client: {
|
||||
@@ -1308,12 +1311,12 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(sendNotice).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||
|
||||
resolveNotice?.("$notice");
|
||||
await handled;
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1613,7 +1616,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
altAliases: ["#alt:example.org"],
|
||||
}),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: false,
|
||||
counts: { final: 0, block: 0, tool: 0 },
|
||||
}),
|
||||
@@ -1755,121 +1758,13 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
|
||||
it("does not enqueue system events for delivered text replies", async () => {
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
|
||||
const handler = createMatrixRoomMessageHandler({
|
||||
client: {
|
||||
getUserId: async () => "@bot:example.org",
|
||||
} as never,
|
||||
core: {
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore: async () => [] as string[],
|
||||
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
|
||||
buildPairingReply: () => "pairing",
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: () => false,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
resolveMarkdownTableMode: () => "preserve",
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: () => ({
|
||||
agentId: "ops",
|
||||
channel: "matrix",
|
||||
accountId: "ops",
|
||||
sessionKey: "agent:ops:main",
|
||||
mainSessionKey: "agent:ops:main",
|
||||
matchedBy: "binding.account",
|
||||
}),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: () => [],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/session-store",
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: vi.fn(async () => {}),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
finalizeInboundContext: (ctx: unknown) => ctx,
|
||||
createReplyDispatcherWithTyping: () => ({
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: () => {},
|
||||
markRunComplete: () => {},
|
||||
}),
|
||||
resolveHumanDelayConfig: () => undefined,
|
||||
dispatchReplyFromConfig: async () => ({
|
||||
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,
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
enqueueSystemEvent,
|
||||
isDirectMessage: false,
|
||||
dispatchInboundMessage: async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
@@ -2177,7 +2072,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
describe("matrix monitor handler live allowlist reload", () => {
|
||||
type MatrixHandler = ReturnType<typeof createMatrixHandlerTestHarness>["handler"];
|
||||
|
||||
const createDispatchReplyFromConfig = () =>
|
||||
const createDispatchInboundMessage = () =>
|
||||
vi.fn(async () => ({
|
||||
queuedFinal: false,
|
||||
counts: { final: 0, block: 0, tool: 0 },
|
||||
@@ -2222,7 +2117,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
).length;
|
||||
|
||||
it("accepts a DM sender added to live dm.allowFrom", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -2236,7 +2131,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: [],
|
||||
allowFromResolvedEntries: [],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
});
|
||||
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2244,7 +2139,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||
|
||||
cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"];
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2253,11 +2148,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
body: "hello again",
|
||||
});
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks a DM sender removed from live dm.allowFrom", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -2271,7 +2166,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: ["@alice:example.org"],
|
||||
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
});
|
||||
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2279,7 +2174,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
cfg.channels.matrix.dm.allowFrom = [];
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2288,11 +2183,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
body: "hello again",
|
||||
});
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks a DM sender after live wildcard removal", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -2306,7 +2201,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: ["*"],
|
||||
allowFromResolvedEntries: [],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
});
|
||||
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2314,7 +2209,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
cfg.channels.matrix.dm.allowFrom = [];
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2323,11 +2218,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
body: "hello again",
|
||||
});
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses account-scoped live dm.allowFrom overrides", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -2347,7 +2242,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: ["@alice:example.org"],
|
||||
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
});
|
||||
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2355,7 +2250,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
cfg.channels.matrix.accounts.ops.dm.allowFrom = [];
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2364,11 +2259,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
body: "hello again",
|
||||
});
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps startup-resolved display names only while the raw input remains configured", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -2383,7 +2278,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: ["@alice:example.org"],
|
||||
allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
});
|
||||
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2391,7 +2286,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
cfg.channels.matrix.dm.allowFrom = [];
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2400,11 +2295,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
body: "hello again",
|
||||
});
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts a DM sender added as a live-resolved display name", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const resolveLiveUserAllowlist = vi.fn(
|
||||
async (params: { entries?: ReadonlyArray<string | number> }) => {
|
||||
const entries = (params.entries ?? []).map(String);
|
||||
@@ -2425,7 +2320,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: [],
|
||||
allowFromResolvedEntries: [],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
resolveLiveUserAllowlist,
|
||||
});
|
||||
|
||||
@@ -2434,7 +2329,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||
|
||||
cfg.channels.matrix.dm.allowFrom = ["Alice"];
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2449,11 +2344,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
);
|
||||
expect(liveAllowlistRequest.accountId).toBe("ops");
|
||||
expect(liveAllowlistRequest.entries).toEqual(["Alice"]);
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes cached live display-name allowlists when name matching is disabled", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
|
||||
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
|
||||
);
|
||||
@@ -2471,7 +2366,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: [],
|
||||
allowFromResolvedEntries: [],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
resolveLiveUserAllowlist,
|
||||
});
|
||||
|
||||
@@ -2480,7 +2375,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
cfg.channels.matrix.dangerouslyAllowNameMatching = false;
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2492,11 +2387,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
|
||||
2,
|
||||
);
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes cached live display-name allowlists when name matching is enabled", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
|
||||
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
|
||||
);
|
||||
@@ -2514,7 +2409,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
isDirectMessage: true,
|
||||
allowFrom: [],
|
||||
allowFromResolvedEntries: [],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
resolveLiveUserAllowlist,
|
||||
});
|
||||
|
||||
@@ -2523,7 +2418,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
sender: "@alice:example.org",
|
||||
body: "hello",
|
||||
});
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||
|
||||
cfg.channels.matrix.dangerouslyAllowNameMatching = true;
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2535,11 +2430,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
|
||||
2,
|
||||
);
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => {
|
||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
||||
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -2557,7 +2452,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
{ input: "@alice:example.org", id: "@alice:example.org" },
|
||||
{ input: "@bob:example.org", id: "@bob:example.org" },
|
||||
],
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
});
|
||||
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2567,7 +2462,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
body: "@room hello",
|
||||
mentions: { room: true },
|
||||
});
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"];
|
||||
await sendLiveAllowlistMessage(handler, {
|
||||
@@ -2578,7 +2473,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
||||
mentions: { room: true },
|
||||
});
|
||||
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2589,7 +2484,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
};
|
||||
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
|
||||
inboundDeduper,
|
||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
||||
dispatchInboundMessage: vi.fn(async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
})),
|
||||
@@ -2631,7 +2526,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
const recordInboundSession = vi.fn(async () => {
|
||||
callOrder.push("record");
|
||||
});
|
||||
const dispatchReplyFromConfig = vi.fn(async () => {
|
||||
const dispatchInboundMessage = vi.fn(async () => {
|
||||
callOrder.push("dispatch");
|
||||
return {
|
||||
queuedFinal: true,
|
||||
@@ -2641,7 +2536,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
inboundDeduper,
|
||||
recordInboundSession,
|
||||
dispatchReplyFromConfig,
|
||||
dispatchInboundMessage,
|
||||
createReplyDispatcherWithTyping: () => ({
|
||||
dispatcher: {
|
||||
markComplete: () => {
|
||||
@@ -2673,9 +2568,9 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
"claim",
|
||||
"record",
|
||||
"dispatch",
|
||||
"run-complete",
|
||||
"mark-complete",
|
||||
"wait-for-idle",
|
||||
"run-complete",
|
||||
"dispatch-idle",
|
||||
"commit",
|
||||
]);
|
||||
@@ -2742,7 +2637,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
recordInboundSession: vi.fn(async () => {
|
||||
throw new Error("disk failed");
|
||||
}),
|
||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
||||
dispatchInboundMessage: vi.fn(async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
})),
|
||||
@@ -2776,7 +2671,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
inboundDeduper,
|
||||
runtime: runtime as never,
|
||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
||||
dispatchInboundMessage: vi.fn(async () => ({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1, block: 0, tool: 0 },
|
||||
})),
|
||||
@@ -2823,7 +2718,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
inboundDeduper,
|
||||
runtime: runtime as never,
|
||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
||||
dispatchInboundMessage: vi.fn(async () => ({
|
||||
queuedFinal: false,
|
||||
counts: {
|
||||
final: 0,
|
||||
@@ -2881,7 +2776,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
||||
recordInboundSession: vi.fn(async () => {
|
||||
callOrder.push("record");
|
||||
}),
|
||||
dispatchReplyFromConfig: vi.fn(async () => {
|
||||
dispatchInboundMessage: vi.fn(async () => {
|
||||
callOrder.push("dispatch");
|
||||
return {
|
||||
queuedFinal: false,
|
||||
@@ -3031,22 +2926,13 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
markRunComplete: () => {},
|
||||
};
|
||||
},
|
||||
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||
capturedReplyOpts = args?.replyOptions;
|
||||
notifyCaptured();
|
||||
// Block until the test is done exercising callbacks.
|
||||
await runGate;
|
||||
return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
|
||||
}) as never,
|
||||
withReplyDispatcher: async <T>(params: {
|
||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
||||
run: () => Promise<T>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => {
|
||||
const result = await params.run();
|
||||
await params.onSettled?.();
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const dispatch = async () => {
|
||||
@@ -4098,7 +3984,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
markDispatchIdle: () => {},
|
||||
markRunComplete: () => {},
|
||||
}),
|
||||
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||
capturedReplyOpts = args?.replyOptions;
|
||||
// Simulate streaming then model error.
|
||||
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
|
||||
@@ -4107,15 +3993,6 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
});
|
||||
throw new Error("model timeout");
|
||||
}) as never,
|
||||
withReplyDispatcher: async <T>(params: {
|
||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
||||
run: () => Promise<T>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => {
|
||||
const result = await params.run();
|
||||
await params.onSettled?.();
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
// Handler should not throw (outer catch absorbs it).
|
||||
@@ -4156,7 +4033,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
markDispatchIdle: () => {},
|
||||
markRunComplete: () => {},
|
||||
}),
|
||||
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||
capturedReplyOpts = args?.replyOptions;
|
||||
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
|
||||
await vi.waitFor(() => {
|
||||
@@ -4164,15 +4041,6 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
});
|
||||
throw new Error("model timeout");
|
||||
}) as never,
|
||||
withReplyDispatcher: async <T>(params: {
|
||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
||||
run: () => Promise<T>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => {
|
||||
const result = await params.run();
|
||||
await params.onSettled?.();
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
await handler(
|
||||
@@ -4448,7 +4316,7 @@ describe("matrix monitor handler block streaming config", () => {
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
streaming: "off",
|
||||
dispatchReplyFromConfig: vi.fn(
|
||||
dispatchInboundMessage: vi.fn(
|
||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||
@@ -4469,7 +4337,7 @@ describe("matrix monitor handler block streaming config", () => {
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
streaming: "partial",
|
||||
dispatchReplyFromConfig: vi.fn(
|
||||
dispatchInboundMessage: vi.fn(
|
||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||
@@ -4490,7 +4358,7 @@ describe("matrix monitor handler block streaming config", () => {
|
||||
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
streaming: "quiet",
|
||||
dispatchReplyFromConfig: vi.fn(
|
||||
dispatchInboundMessage: vi.fn(
|
||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||
@@ -4512,7 +4380,7 @@ describe("matrix monitor handler block streaming config", () => {
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
streaming: "partial",
|
||||
blockStreamingEnabled: true,
|
||||
dispatchReplyFromConfig: vi.fn(
|
||||
dispatchInboundMessage: vi.fn(
|
||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||
@@ -4534,7 +4402,7 @@ describe("matrix monitor handler block streaming config", () => {
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
streaming: "off",
|
||||
blockStreamingEnabled: true,
|
||||
dispatchReplyFromConfig: vi.fn(
|
||||
dispatchInboundMessage: vi.fn(
|
||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
// Matrix plugin module implements handler behavior.
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
buildChannelInboundEventContext,
|
||||
createChannelInboundEnvelopeBuilder,
|
||||
hasFinalInboundReplyDispatch,
|
||||
resolveInboundMentionDecision,
|
||||
toInboundMediaFacts,
|
||||
type ChannelBotLoopProtectionFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
createPreviewMessageReceipt,
|
||||
defineFinalizableLivePreviewAdapter,
|
||||
deliverWithFinalizableLivePreviewAdapter,
|
||||
type MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
type AgentPlanStep,
|
||||
buildChannelProgressDraftLineForEntry,
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
createChannelProgressDraftGate,
|
||||
createPreviewMessageReceipt,
|
||||
defineFinalizableLivePreviewAdapter,
|
||||
deliverWithFinalizableLivePreviewAdapter,
|
||||
formatChannelProgressDraftLine,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
type MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
evaluateSupplementalContextVisibility,
|
||||
@@ -41,10 +40,13 @@ import {
|
||||
buildTtsSupplementMediaPayload,
|
||||
getReplyPayloadTtsSupplement,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
dispatchInboundMessageWithBufferedDispatcher,
|
||||
type GetReplyOptions,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type {
|
||||
@@ -228,6 +230,11 @@ type MatrixMonitorHandlerParams = {
|
||||
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||
needsRoomAliasesForConfig: boolean;
|
||||
resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist;
|
||||
resolveStorePath?: typeof resolveStorePath;
|
||||
createChannelInboundEnvelopeBuilder?: typeof createChannelInboundEnvelopeBuilder;
|
||||
finalizeInboundContext?: (ctx: Record<string, unknown>) => unknown;
|
||||
resolveHumanDelayConfig?: typeof resolveHumanDelayConfig;
|
||||
dispatchInboundMessageWithBufferedDispatcher?: typeof dispatchInboundMessageWithBufferedDispatcher;
|
||||
};
|
||||
|
||||
function resolveMatrixMentionPrecheckText(params: {
|
||||
@@ -472,6 +479,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
getMemberDisplayName,
|
||||
needsRoomAliasesForConfig,
|
||||
resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist,
|
||||
resolveStorePath: resolveStorePathImpl = resolveStorePath,
|
||||
createChannelInboundEnvelopeBuilder:
|
||||
createChannelInboundEnvelopeBuilderImpl = createChannelInboundEnvelopeBuilder,
|
||||
finalizeInboundContext,
|
||||
resolveHumanDelayConfig: resolveHumanDelayConfigImpl = resolveHumanDelayConfig,
|
||||
dispatchInboundMessageWithBufferedDispatcher:
|
||||
dispatchInboundMessageWithBufferedDispatcherImpl = dispatchInboundMessageWithBufferedDispatcher,
|
||||
} = params;
|
||||
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
||||
cfg,
|
||||
@@ -1536,14 +1550,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const roomName = roomInfo?.name;
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
const storePath = resolveStorePathImpl(cfg.session?.store, {
|
||||
agentId: _route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: _route.sessionKey,
|
||||
});
|
||||
const buildEnvelope = createChannelInboundEnvelopeBuilderImpl({ cfg, route: _route });
|
||||
const sharedDmNoticeSessionKey = threadTarget
|
||||
? _route.mainSessionKey || _route.sessionKey
|
||||
: _route.sessionKey;
|
||||
@@ -1560,12 +1570,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
logVerboseMessage,
|
||||
})
|
||||
: null;
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const body = buildEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: eventTs ?? undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: textWithId,
|
||||
});
|
||||
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
||||
@@ -1579,8 +1587,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
);
|
||||
const ctxPayload = buildChannelInboundEventContext({
|
||||
channel: "matrix",
|
||||
finalize: core.channel.reply.finalizeInboundContext,
|
||||
contextVisibility: contextVisibilityMode,
|
||||
finalize: finalizeInboundContext,
|
||||
supplemental: {
|
||||
quote: replyContext
|
||||
? {
|
||||
@@ -2098,261 +2106,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
resetPreviewToolProgress();
|
||||
};
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, _route.agentId),
|
||||
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
|
||||
if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||
const fallbackPayload =
|
||||
ttsSupplement &&
|
||||
ttsSupplement.visibleTextAlreadyDelivered !== true &&
|
||||
!payload.text?.trim()
|
||||
? { ...payload, text: ttsSupplement.spokenText }
|
||||
: payload;
|
||||
const dispatcherOptions = {
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfigImpl(cfg, _route.agentId),
|
||||
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
|
||||
if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||
const fallbackPayload =
|
||||
ttsSupplement &&
|
||||
ttsSupplement.visibleTextAlreadyDelivered !== true &&
|
||||
!payload.text?.trim()
|
||||
? { ...payload, text: ttsSupplement.spokenText }
|
||||
: payload;
|
||||
|
||||
if (draftConsumed) {
|
||||
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 {
|
||||
if (draftConsumed) {
|
||||
await draftStream.discardPending();
|
||||
await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
replies: [fallbackPayload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
@@ -2364,22 +2136,255 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
|
||||
if (info.kind === "final") {
|
||||
finalReplyDeliveryFailed = true;
|
||||
|
||||
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 {
|
||||
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") {
|
||||
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(() => {});
|
||||
}
|
||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: typingCallbacks.onReplyStart,
|
||||
onIdle: typingCallbacks.onIdle,
|
||||
});
|
||||
} else {
|
||||
await deliverMatrixReplies({
|
||||
cfg,
|
||||
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
|
||||
? await (async () => {
|
||||
const livePinnedCfg = core.config.current() as CoreConfig;
|
||||
@@ -2422,12 +2427,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
raw: event,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
accountId: _route.accountId,
|
||||
routeSessionKey: _route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: _route.agentId, sessionKey: _route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
botLoopProtection,
|
||||
record: {
|
||||
updateLastRoute: isDirectMessage
|
||||
@@ -2464,14 +2468,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
});
|
||||
},
|
||||
},
|
||||
onPreDispatchFailure: () =>
|
||||
core.channel.reply.settleReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markRunComplete();
|
||||
markDispatchIdle();
|
||||
},
|
||||
}),
|
||||
runDispatch: async () => {
|
||||
if (
|
||||
sharedDmContextNotice &&
|
||||
@@ -2489,61 +2485,50 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
}
|
||||
|
||||
return await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
return await dispatchInboundMessageWithBufferedDispatcherImpl({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...dispatcherOptions,
|
||||
onSettled: () => progressDraftGate.cancel(),
|
||||
},
|
||||
run: async () => {
|
||||
try {
|
||||
return await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: roomConfig?.skills,
|
||||
// Keep block streaming enabled when explicitly requested, even
|
||||
// with draft previews on. The draft remains the live preview
|
||||
// for the current assistant block, while block deliveries
|
||||
// finalize completed blocks into their own preserved events.
|
||||
disableBlockStreaming: !blockStreamingEnabled,
|
||||
onPartialReply: draftStream
|
||||
? (payload) => {
|
||||
if (progressDraftStreaming) {
|
||||
return;
|
||||
}
|
||||
latestDraftFullText = payload.text ?? "";
|
||||
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
|
||||
updateDraftFromLatestFullText();
|
||||
}
|
||||
: undefined,
|
||||
onBlockReplyQueued: draftStream
|
||||
? (payload, context) => {
|
||||
if (payload.isCompactionNotice === true) {
|
||||
return;
|
||||
}
|
||||
queueDraftBlockBoundary(payload, context);
|
||||
}
|
||||
: undefined,
|
||||
// Reset draft boundary bookkeeping on assistant message
|
||||
// boundaries so post-tool blocks stream from a fresh
|
||||
// cumulative payload (payload.text resets upstream).
|
||||
onAssistantMessageStart: draftStream
|
||||
? () => {
|
||||
resetDraftBlockOffsets();
|
||||
resetPreviewToolProgress();
|
||||
}
|
||||
: undefined,
|
||||
onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined,
|
||||
...buildPreviewToolProgressReplyOptions(),
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
progressDraftGate.cancel();
|
||||
markRunComplete();
|
||||
}
|
||||
replyOptions: {
|
||||
skillFilter: roomConfig?.skills,
|
||||
// Keep block streaming enabled when explicitly requested, even
|
||||
// with draft previews on. The draft remains the live preview
|
||||
// for the current assistant block, while block deliveries
|
||||
// finalize completed blocks into their own preserved events.
|
||||
disableBlockStreaming: !blockStreamingEnabled,
|
||||
onPartialReply: draftStream
|
||||
? (payload) => {
|
||||
if (progressDraftStreaming) {
|
||||
return;
|
||||
}
|
||||
latestDraftFullText = payload.text ?? "";
|
||||
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
|
||||
updateDraftFromLatestFullText();
|
||||
}
|
||||
: undefined,
|
||||
onBlockReplyQueued: draftStream
|
||||
? (payload, context) => {
|
||||
if (payload.isCompactionNotice === true) {
|
||||
return;
|
||||
}
|
||||
queueDraftBlockBoundary(payload, context);
|
||||
}
|
||||
: undefined,
|
||||
// Reset draft boundary bookkeeping on assistant message
|
||||
// boundaries so post-tool blocks stream from a fresh
|
||||
// cumulative payload (payload.text resets upstream).
|
||||
onAssistantMessageStart: draftStream
|
||||
? () => {
|
||||
resetDraftBlockOffsets();
|
||||
resetPreviewToolProgress();
|
||||
}
|
||||
: undefined,
|
||||
onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined,
|
||||
...buildPreviewToolProgressReplyOptions(),
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installMatrixTestRuntime } from "../test-runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { withAuthorizedMatrixReadTarget } from "./read-policy.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
@@ -32,6 +33,10 @@ function createClient(
|
||||
}
|
||||
|
||||
describe("Matrix read policy", () => {
|
||||
beforeEach(() => {
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
it("allows configured rooms and rejects other rooms before the read", async () => {
|
||||
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
|
||||
const cfg = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Matrix helper module supports formatting behavior.
|
||||
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import {
|
||||
markdownToMatrixHtml,
|
||||
@@ -187,7 +188,7 @@ export function resolveMatrixVoiceDecision(opts: {
|
||||
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
|
||||
// Matrix currently shares the core voice compatibility policy.
|
||||
// Keep this wrapper as the seam if Matrix policy diverges later.
|
||||
return getCore().media.isVoiceCompatibleAudio({
|
||||
return isVoiceMessageCompatibleAudio({
|
||||
contentType: opts.contentType,
|
||||
fileName: opts.fileName,
|
||||
});
|
||||
|
||||
@@ -80,9 +80,10 @@ class FakeWebSocket {
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
abortController: undefined as AbortController | undefined,
|
||||
createReplyDispatcherWithTyping: vi.fn(),
|
||||
createMattermostClient: vi.fn(),
|
||||
createMattermostDraftStream: vi.fn(),
|
||||
dispatchReplyFromConfig: vi.fn(),
|
||||
dispatchInboundMessage: vi.fn(),
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
fetchMattermostMe: vi.fn(),
|
||||
registerMattermostMonitorSlashCommands: vi.fn(),
|
||||
@@ -96,6 +97,22 @@ const mockState = vi.hoisted(() => ({
|
||||
updateMattermostPost: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
createReplyDispatcherWithTyping: (...args: unknown[]) =>
|
||||
mockState.createReplyDispatcherWithTyping(...args),
|
||||
dispatchInboundMessage: async (params: Parameters<typeof actual.dispatchInboundMessage>[0]) => {
|
||||
try {
|
||||
return await mockState.dispatchInboundMessage(params);
|
||||
} finally {
|
||||
await params.onSettled?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
||||
return {
|
||||
@@ -193,16 +210,43 @@ function createRuntimeCore(
|
||||
type ReplyDispatcherOptions = {
|
||||
deliver: (payload: ReplyPayload, info: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
||||
};
|
||||
mockState.createReplyDispatcherWithTyping.mockImplementation(
|
||||
(options: ReplyDispatcherOptions) => ({
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
markRunComplete: vi.fn(),
|
||||
options,
|
||||
}),
|
||||
);
|
||||
type RecordInboundSessionInput = {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
ctx: unknown;
|
||||
createIfMissing?: boolean;
|
||||
groupResolution?: unknown;
|
||||
onRecordError?: (error: unknown) => void;
|
||||
updateLastRoute?: {
|
||||
accountId?: string;
|
||||
channel?: string;
|
||||
mainDmOwnerPin?: {
|
||||
onSkip?: () => void;
|
||||
ownerRecipient?: string;
|
||||
senderRecipient?: string;
|
||||
};
|
||||
sessionKey?: string;
|
||||
to?: string;
|
||||
};
|
||||
};
|
||||
const recordInboundSession = vi.fn(async (_params: RecordInboundSessionInput) => {});
|
||||
const dispatchPreparedForTest = vi.fn(
|
||||
async (turn: {
|
||||
storePath: string;
|
||||
routeSessionKey: string;
|
||||
route: { agentId: string; sessionKey: string };
|
||||
ctxPayload: { SessionKey?: string };
|
||||
recordInboundSession: (params: unknown) => Promise<void>;
|
||||
record?: {
|
||||
groupResolution?: unknown;
|
||||
createIfMissing?: boolean;
|
||||
updateLastRoute?: unknown;
|
||||
updateLastRoute?: RecordInboundSessionInput["updateLastRoute"];
|
||||
onRecordError?: (err: unknown) => void;
|
||||
};
|
||||
runDispatch: () => Promise<{
|
||||
@@ -210,9 +254,9 @@ function createRuntimeCore(
|
||||
counts: { tool: number; block: number; final: number };
|
||||
}>;
|
||||
}) => {
|
||||
await turn.recordInboundSession({
|
||||
storePath: turn.storePath,
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
||||
await recordInboundSession({
|
||||
storePath: "/tmp/openclaw-test-sessions.json",
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
|
||||
ctx: turn.ctxPayload,
|
||||
groupResolution: turn.record?.groupResolution,
|
||||
createIfMissing: turn.record?.createIfMissing,
|
||||
@@ -224,7 +268,7 @@ function createRuntimeCore(
|
||||
admission: { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: turn.ctxPayload,
|
||||
routeSessionKey: turn.routeSessionKey,
|
||||
routeSessionKey: turn.route.sessionKey,
|
||||
dispatchResult,
|
||||
};
|
||||
},
|
||||
@@ -304,25 +348,7 @@ function createRuntimeCore(
|
||||
buildPairingReply: () => "pairing required",
|
||||
},
|
||||
reply: {
|
||||
createReplyDispatcherWithTyping: vi.fn((options: ReplyDispatcherOptions) => ({
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
markRunComplete: vi.fn(),
|
||||
options,
|
||||
})),
|
||||
dispatchReplyFromConfig: mockState.dispatchReplyFromConfig,
|
||||
finalizeInboundContext: (context: unknown) => context,
|
||||
formatInboundEnvelope: (params: { channel: string; from: string; body: string }) =>
|
||||
`${params.channel} ${params.from}\n${params.body}`,
|
||||
resolveHumanDelayConfig: () => ({}),
|
||||
withReplyDispatcher: async (params: { run: () => unknown; onSettled?: () => void }) => {
|
||||
try {
|
||||
return await params.run();
|
||||
} finally {
|
||||
params.onSettled?.();
|
||||
}
|
||||
},
|
||||
settleReplyDispatcher: vi.fn(async ({ onSettled }) => onSettled?.()),
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: () => ({
|
||||
@@ -335,26 +361,7 @@ function createRuntimeCore(
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
|
||||
recordInboundSession: vi.fn(
|
||||
async (_params: {
|
||||
createIfMissing?: unknown;
|
||||
groupResolution?: unknown;
|
||||
onRecordError?: unknown;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
updateLastRoute?: {
|
||||
accountId?: string;
|
||||
channel?: string;
|
||||
mainDmOwnerPin?: {
|
||||
onSkip?: unknown;
|
||||
ownerRecipient?: string;
|
||||
senderRecipient?: string;
|
||||
};
|
||||
sessionKey?: string;
|
||||
to?: string;
|
||||
};
|
||||
}) => {},
|
||||
),
|
||||
recordInboundSession,
|
||||
updateLastRoute: vi.fn(async () => {}),
|
||||
},
|
||||
inbound: {
|
||||
@@ -457,7 +464,7 @@ describe("mattermost inbound user posts", () => {
|
||||
mockState.resolveMattermostMedia.mockResolvedValue([]);
|
||||
mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
|
||||
mockState.sendMessageMattermost.mockResolvedValue({});
|
||||
mockState.dispatchReplyFromConfig.mockImplementation(async () => {
|
||||
mockState.dispatchInboundMessage.mockImplementation(async () => {
|
||||
mockState.abortController?.abort();
|
||||
});
|
||||
});
|
||||
@@ -503,8 +510,8 @@ describe("mattermost inbound user posts", () => {
|
||||
await monitor;
|
||||
|
||||
expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("hello from mattermost");
|
||||
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
|
||||
expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular");
|
||||
@@ -599,8 +606,8 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("@openclaw");
|
||||
expect(ctx?.MessageSid).toBe("post-bare-mention");
|
||||
expect(ctx?.OriginatingChannel).toBe("mattermost");
|
||||
@@ -638,7 +645,7 @@ describe("mattermost inbound user posts", () => {
|
||||
},
|
||||
};
|
||||
mockState.runtimeCore = createRuntimeCore(progressConfig);
|
||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
||||
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||
await params.replyOptions?.onToolStart?.({
|
||||
toolCallId: "read-1",
|
||||
name: "read",
|
||||
@@ -707,7 +714,7 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
|
||||
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
|
||||
expect(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||||
@@ -779,8 +786,8 @@ describe("mattermost inbound user posts", () => {
|
||||
await monitor;
|
||||
|
||||
expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig);
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("hello /status");
|
||||
expect(ctx?.CommandAuthorized).toBe(false);
|
||||
// Inline non-control text must not be tagged as an explicit text-slash command turn —
|
||||
@@ -861,8 +868,8 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("/reset");
|
||||
expect(ctx?.CommandBody).toBe("/reset");
|
||||
expect(ctx?.CommandAuthorized).toBe(true);
|
||||
@@ -913,8 +920,8 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("hello with websocket kind");
|
||||
expect(ctx?.ChatType).toBe("channel");
|
||||
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
|
||||
@@ -976,7 +983,7 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
|
||||
expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1041,7 +1048,7 @@ describe("mattermost inbound user posts", () => {
|
||||
user_id: "user-1",
|
||||
},
|
||||
});
|
||||
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
|
||||
|
||||
await socket.emitMessage({
|
||||
event: "posted",
|
||||
@@ -1066,8 +1073,8 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("abort");
|
||||
expect(ctx?.CommandAuthorized).toBe(true);
|
||||
});
|
||||
@@ -1266,9 +1273,9 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDraftStream).not.toHaveBeenCalled();
|
||||
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
|
||||
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
|
||||
expect(replyOptions?.disableBlockStreaming).toBe(false);
|
||||
expect(replyOptions?.preserveProgressCallbackStartOrder).toBeUndefined();
|
||||
});
|
||||
@@ -1354,7 +1361,7 @@ describe("mattermost inbound user posts", () => {
|
||||
let finalToolDraft = "";
|
||||
let secondPartialArrivedBeforeBoundarySettled = false;
|
||||
let finalDeliveryWaitedForBoundary = false;
|
||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
||||
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||
await params.replyOptions?.onAssistantMessageStart?.();
|
||||
params.replyOptions?.onPartialReply?.({ text: "A much longer first block" });
|
||||
const firstToolStart = params.replyOptions?.onToolStart?.({
|
||||
@@ -1427,8 +1434,7 @@ describe("mattermost inbound user posts", () => {
|
||||
toolBeforeFinalBoundaryCount = forceNewMessage.mock.calls.length;
|
||||
finalToolDraft = String(draftUpdate.mock.calls.at(-1)?.[0] ?? "");
|
||||
const dispatcherOptions =
|
||||
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
|
||||
?.options;
|
||||
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
|
||||
const finalDelivery = dispatcherOptions?.deliver(
|
||||
{ text: "Final without a partial" },
|
||||
{ kind: "final" },
|
||||
@@ -1462,14 +1468,14 @@ describe("mattermost inbound user posts", () => {
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
const draftStreamOptions = mockState.createMattermostDraftStream.mock.calls.at(0)?.[0] as
|
||||
| { chunkText?: (text: string) => string[] }
|
||||
| undefined;
|
||||
chunkMarkdownTextWithMode.mockClear();
|
||||
expect(draftStreamOptions?.chunkText?.("first\n\nsecond")).toEqual(["first\n\nsecond"]);
|
||||
expect(chunkMarkdownTextWithMode).toHaveBeenCalledWith("first\n\nsecond", 1234, "newline");
|
||||
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
|
||||
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
|
||||
expect(replyOptions?.disableBlockStreaming).toBe(true);
|
||||
expect(replyOptions?.preserveProgressCallbackStartOrder).toBe(true);
|
||||
expect(sameToolUpdateBoundaryCount).toBe(1);
|
||||
@@ -1540,14 +1546,13 @@ describe("mattermost inbound user posts", () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const abortController = new AbortController();
|
||||
mockState.abortController = abortController;
|
||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
||||
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||
await params.replyOptions?.onAssistantMessageStart?.();
|
||||
await params.replyOptions?.onPartialReply?.({ text: "First block" });
|
||||
await params.replyOptions?.onAssistantMessageStart?.();
|
||||
await params.replyOptions?.onPartialReply?.({ text: "Second block" });
|
||||
const dispatcherOptions =
|
||||
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
|
||||
?.options;
|
||||
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
|
||||
await dispatcherOptions?.deliver(
|
||||
{ text: "[bot] First block\n\nSecond block" },
|
||||
{ kind: "final" },
|
||||
@@ -1621,13 +1626,12 @@ describe("mattermost inbound user posts", () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const abortController = new AbortController();
|
||||
mockState.abortController = abortController;
|
||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
||||
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||
await params.replyOptions?.onAssistantMessageStart?.();
|
||||
await params.replyOptions?.onPartialReply?.({ text: "Only block" });
|
||||
await params.replyOptions?.onAssistantMessageStart?.();
|
||||
const dispatcherOptions =
|
||||
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
|
||||
?.options;
|
||||
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
|
||||
await dispatcherOptions?.deliver({ text: "Only block" }, { kind: "final" });
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound";
|
||||
// Mattermost plugin module implements monitor behavior.
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
buildChannelProgressDraftLineForEntry,
|
||||
createChannelProgressDraftCompositor,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import {
|
||||
createReplyDispatcherWithTyping,
|
||||
dispatchInboundMessage,
|
||||
finalizeInboundContext,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
@@ -430,7 +437,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
const to =
|
||||
kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`;
|
||||
const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: bodyText,
|
||||
BodyForAgent: bodyText,
|
||||
RawBody: bodyText,
|
||||
@@ -497,55 +504,48 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
isDirect: kind === "direct",
|
||||
dmRetryOptions: account.config.dmChannelRetry,
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
await deliverMattermostReplyPayload({
|
||||
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,
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
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 dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
onSettled: () => markDispatchIdle(),
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
},
|
||||
log: (msg) => runtime.log?.(msg),
|
||||
@@ -632,7 +632,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
params.kind === "direct"
|
||||
? `Mattermost DM from ${params.senderName}`
|
||||
: `Mattermost message in ${params.roomLabel} from ${params.senderName}`;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: params.commandText,
|
||||
BodyForAgent: params.commandText,
|
||||
RawBody: params.commandText,
|
||||
@@ -707,67 +707,60 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
isDirect: params.kind === "direct",
|
||||
dmRetryOptions: account.config.dmChannelRetry,
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
// Picker-triggered confirmations should stay immediate.
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const trimmedPayload = {
|
||||
...payload,
|
||||
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
|
||||
};
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
// Picker-triggered confirmations should stay immediate.
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const trimmedPayload = {
|
||||
...payload,
|
||||
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
|
||||
};
|
||||
|
||||
if (!shouldDeliverReplies) {
|
||||
if (trimmedPayload.text) {
|
||||
capturedTexts.push(trimmedPayload.text);
|
||||
}
|
||||
return;
|
||||
if (!shouldDeliverReplies) {
|
||||
if (trimmedPayload.text) {
|
||||
capturedTexts.push(trimmedPayload.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await deliverMattermostReplyPayload({
|
||||
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,
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
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 dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
onSettled: () => markDispatchIdle(),
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
|
||||
return capturedTexts.join("\n\n").trim();
|
||||
@@ -1296,7 +1289,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
});
|
||||
|
||||
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
||||
const body = core.channel.reply.formatInboundEnvelope({
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
@@ -1312,7 +1305,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatInboundEnvelope({
|
||||
formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp,
|
||||
@@ -1335,7 +1328,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
limit: historyLimit,
|
||||
})
|
||||
: undefined;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: bodyForAgent,
|
||||
InboundHistory: inboundHistory,
|
||||
@@ -1389,10 +1382,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
})
|
||||
: null;
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
|
||||
const previewLine = truncateUtf16Safe(bodyText, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(
|
||||
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
||||
@@ -1591,11 +1580,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
dmRetryOptions: account.config.dmChannelRetry,
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
typingCallbacks,
|
||||
deliver: async (payloadEntry: ReplyPayload, info) => {
|
||||
if (info.kind === "final") {
|
||||
@@ -1715,12 +1704,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
raw: post,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
record: {
|
||||
updateLastRoute:
|
||||
kind === "direct"
|
||||
@@ -1772,131 +1760,124 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
});
|
||||
},
|
||||
runDispatch: () =>
|
||||
core.channel.reply.withReplyDispatcher({
|
||||
dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
||||
draftToolProgressEnabled ? true : undefined,
|
||||
preserveProgressCallbackStartOrder: draftPreviewEnabled
|
||||
? true
|
||||
: undefined,
|
||||
onObservedReplyDelivery: draftToolProgressEnabled
|
||||
? () => draftStream.clear()
|
||||
: undefined,
|
||||
disableBlockStreaming: draftPreviewEnabled
|
||||
? true
|
||||
: typeof account.blockStreaming === "boolean"
|
||||
? !account.blockStreaming
|
||||
onSettled: () => markDispatchIdle(),
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed: draftToolProgressEnabled
|
||||
? true
|
||||
: undefined,
|
||||
preserveProgressCallbackStartOrder: draftPreviewEnabled ? true : undefined,
|
||||
onObservedReplyDelivery: draftToolProgressEnabled
|
||||
? () => draftStream.clear()
|
||||
: undefined,
|
||||
disableBlockStreaming: draftPreviewEnabled
|
||||
? true
|
||||
: typeof account.blockStreaming === "boolean"
|
||||
? !account.blockStreaming
|
||||
: undefined,
|
||||
...(suppressDefaultToolProgressMessages
|
||||
? { suppressDefaultToolProgressMessages: true }
|
||||
: {}),
|
||||
onModelSelected,
|
||||
onPartialReply: (payloadResult) => {
|
||||
if (account.streamingMode !== "progress") {
|
||||
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,
|
||||
...(suppressDefaultToolProgressMessages
|
||||
? { suppressDefaultToolProgressMessages: true }
|
||||
: {}),
|
||||
onModelSelected,
|
||||
onPartialReply: (payloadResult) => {
|
||||
if (account.streamingMode !== "progress") {
|
||||
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,
|
||||
),
|
||||
{ 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]);
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ 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 { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
createReplyDispatcherWithTyping,
|
||||
dispatchInboundMessage,
|
||||
finalizeInboundContext,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
@@ -830,7 +836,7 @@ async function handleSlashCommandAsync(params: {
|
||||
}
|
||||
|
||||
// Build inbound context — the command text is the body
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: commandText,
|
||||
BodyForAgent: commandText,
|
||||
RawBody: commandText,
|
||||
@@ -886,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({
|
||||
isDirect: kind === "direct",
|
||||
dmRetryOptions: account.config.dmChannelRetry,
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
await deliverMattermostReplyPayload({
|
||||
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,
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
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 dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
onSettled: () => markDispatchIdle(),
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
|
||||
import { asRecord } from "./dreaming-shared.js";
|
||||
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
|
||||
|
||||
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
|
||||
function resolveDreamingPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
|
||||
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
|
||||
return asRecord(entry?.config) ?? {};
|
||||
}
|
||||
@@ -49,7 +49,7 @@ function formatPhaseGuide(): string {
|
||||
}
|
||||
|
||||
function formatStatus(cfg: OpenClawConfig): string {
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
||||
const pluginConfig = resolveDreamingPluginConfig(cfg);
|
||||
const dreaming = resolveMemoryDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
|
||||
@@ -5,8 +5,10 @@ import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { resolveMemoryCorePluginConfig } from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import {
|
||||
resolveMemoryDreamingPluginConfig,
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
@@ -236,7 +238,7 @@ function createHarness(
|
||||
},
|
||||
},
|
||||
};
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
|
||||
const pluginConfig = resolveMemoryDreamingPluginConfig(resolvedConfig) ?? {};
|
||||
const beforeAgentReply = async (
|
||||
event: { cleanedBody: string },
|
||||
ctx: { trigger?: string; workspaceDir?: string },
|
||||
@@ -434,7 +436,7 @@ describe("memory-core dreaming phases", () => {
|
||||
await runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: testConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
||||
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
|
||||
logger,
|
||||
subagent,
|
||||
nowMs,
|
||||
@@ -501,7 +503,7 @@ describe("memory-core dreaming phases", () => {
|
||||
runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: testConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
||||
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
|
||||
logger,
|
||||
subagent,
|
||||
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
|
||||
@@ -737,7 +739,7 @@ describe("memory-core dreaming phases", () => {
|
||||
await runDreamingSweepPhases({
|
||||
workspaceDir,
|
||||
cfg: testConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
||||
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
|
||||
logger,
|
||||
subagent,
|
||||
nowMs,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
// Memory Core plugin module implements dreaming behavior.
|
||||
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
||||
import { resolveMemoryDreamingPluginConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import {
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS as DEFAULT_MEMORY_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS,
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS as DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
MANAGED_MEMORY_DREAMING_CRON_NAME as MANAGED_DREAMING_CRON_NAME,
|
||||
MANAGED_MEMORY_DREAMING_CRON_TAG as MANAGED_DREAMING_CRON_TAG,
|
||||
MEMORY_DREAMING_SYSTEM_EVENT_TEXT as DREAMING_SYSTEM_EVENT_TEXT,
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDeepDreamingConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
@@ -550,7 +550,7 @@ async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
let totalCandidates = 0;
|
||||
let totalApplied = 0;
|
||||
let failedWorkspaces = 0;
|
||||
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
|
||||
const pluginConfig = params.cfg ? resolveMemoryDreamingPluginConfig(params.cfg) : undefined;
|
||||
const detachNarratives = params.trigger === "cron";
|
||||
const [
|
||||
{ writeDeepDreamingReport },
|
||||
@@ -793,10 +793,10 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
|
||||
const pluginConfig =
|
||||
params.reason === "startup"
|
||||
? (resolveMemoryCorePluginConfig(startupCfg) ??
|
||||
resolveMemoryCorePluginConfig(api.config) ??
|
||||
? (resolveMemoryDreamingPluginConfig(startupCfg) ??
|
||||
resolveMemoryDreamingPluginConfig(api.config) ??
|
||||
api.pluginConfig)
|
||||
: resolveMemoryCorePluginConfig(startupCfg);
|
||||
: resolveMemoryDreamingPluginConfig(startupCfg);
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg: startupCfg,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
readFiniteNumberParam,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
resolveMemoryDreamingPluginConfig,
|
||||
type MemoryCorpusSearchResult,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
@@ -18,7 +19,6 @@ import type {
|
||||
MemorySearchRuntimeDebug,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import {
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDeepDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
@@ -551,7 +551,7 @@ export function createMemorySearchTool(options: {
|
||||
mode: citationsMode,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
||||
const pluginConfig = resolveMemoryDreamingPluginConfig(cfg);
|
||||
const dreamingEnabled = resolveMemoryDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TRUSTED_CLIENT_TOKEN,
|
||||
generateSecMsGecToken,
|
||||
} from "node-edge-tts/dist/drm.js";
|
||||
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
@@ -288,7 +288,7 @@ export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin {
|
||||
audioBuffer,
|
||||
outputFormat: format,
|
||||
fileExtension,
|
||||
voiceCompatible: isVoiceCompatibleAudio({ fileName: outputPath }),
|
||||
voiceCompatible: isVoiceMessageCompatibleAudio({ fileName: outputPath }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -24,12 +24,22 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { chunkMarkdownTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, ReplyPayload } from "../runtime-api.js";
|
||||
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
|
||||
};
|
||||
});
|
||||
|
||||
/** Options msteams passes into core createReplyDispatcherWithTyping (capture seam). */
|
||||
type CapturedDispatcherOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
@@ -230,11 +240,18 @@ const MSTEAMS_TRACE_CASES: readonly MSTeamsTraceCase[] = [
|
||||
|
||||
function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) {
|
||||
let captured: CapturedDispatcherOptions | undefined;
|
||||
setMSTeamsRuntime(
|
||||
createTraceRuntimeStub(recorder, (options) => {
|
||||
captured = options;
|
||||
}),
|
||||
);
|
||||
setMSTeamsRuntime(createTraceRuntimeStub(recorder, () => undefined));
|
||||
createReplyDispatcherWithTypingMock.mockImplementation((options: CapturedDispatcherOptions) => {
|
||||
captured = options;
|
||||
return {
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: () => {
|
||||
options.typingCallbacks?.onIdle?.();
|
||||
},
|
||||
markRunComplete: () => {},
|
||||
};
|
||||
});
|
||||
const stream = createRecordingStream(recorder, traceCase.streamWriteFault);
|
||||
const context = createRecordingTurnContext({
|
||||
recorder,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Msteams plugin module implements feedback invoke behavior.
|
||||
import path from "node:path";
|
||||
import { recordChannelFeedbackEvent } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
|
||||
@@ -131,19 +130,12 @@ export async function runMSTeamsFeedbackInvokeHandler(
|
||||
hasComment: Boolean(userComment),
|
||||
});
|
||||
|
||||
// Write feedback event to session transcript
|
||||
try {
|
||||
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
|
||||
await recordChannelFeedbackEvent({
|
||||
cfg: deps.cfg,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const transcriptFile = path.join(storePath, `${safeKey}.jsonl`);
|
||||
await appendRegularFile({
|
||||
filePath: transcriptFile,
|
||||
content: `${JSON.stringify(feedbackEvent)}\n`,
|
||||
rejectSymlinkParents: true,
|
||||
}).catch(() => {
|
||||
// Best effort — transcript dir may not exist yet
|
||||
sessionKey: route.sessionKey,
|
||||
event: feedbackEvent,
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
@@ -181,12 +173,11 @@ export async function runMSTeamsFeedbackInvokeHandler(
|
||||
runFeedbackReflection({
|
||||
cfg: deps.cfg,
|
||||
app: deps.app,
|
||||
appId: deps.appId,
|
||||
conversationRef,
|
||||
sessionKey: route.sessionKey,
|
||||
agentId: route.agentId,
|
||||
conversationId,
|
||||
feedbackMessageId: messageId,
|
||||
conversationKind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
||||
userComment,
|
||||
log: deps.log,
|
||||
}).catch((err: unknown) => {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Msteams plugin module implements feedback reflection prompt behavior.
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
/** Max chars of the thumbed-down response to include in the reflection prompt. */
|
||||
const MAX_RESPONSE_CHARS = 500;
|
||||
|
||||
type ParsedReflectionResponse = {
|
||||
learning: string;
|
||||
followUp: boolean;
|
||||
userMessage?: string;
|
||||
};
|
||||
|
||||
export function buildReflectionPrompt(params: {
|
||||
thumbedDownResponse?: string;
|
||||
userComment?: string;
|
||||
}): string {
|
||||
const parts: string[] = ["A user indicated your previous response wasn't helpful."];
|
||||
|
||||
if (params.thumbedDownResponse) {
|
||||
const truncated =
|
||||
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
|
||||
? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...`
|
||||
: params.thumbedDownResponse;
|
||||
parts.push(`\nYour response was:\n> ${truncated}`);
|
||||
}
|
||||
|
||||
if (params.userComment) {
|
||||
parts.push(`\nUser's comment: "${params.userComment}"`);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"\nBriefly reflect: what could you improve? Consider tone, length, " +
|
||||
"accuracy, relevance, and specificity. Reply with a single JSON object " +
|
||||
'only, no markdown or prose, using this exact shape:\n{"learning":"...",' +
|
||||
'"followUp":false,"userMessage":""}\n' +
|
||||
"- learning: a short internal adjustment note (1-2 sentences) for your " +
|
||||
"future behavior in this conversation.\n" +
|
||||
"- followUp: true only if the user needs a direct follow-up message.\n" +
|
||||
"- userMessage: only the exact user-facing message to send; empty string " +
|
||||
"when followUp is false.",
|
||||
);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function parseBooleanLike(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = normalizeOptionalLowercaseString(value);
|
||||
if (normalized === "true" || normalized === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "no") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseStructuredReflectionValue(value: unknown): ParsedReflectionResponse | null {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as {
|
||||
learning?: unknown;
|
||||
followUp?: unknown;
|
||||
userMessage?: unknown;
|
||||
};
|
||||
const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : undefined;
|
||||
if (!learning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
learning,
|
||||
followUp: parseBooleanLike(candidate.followUp) ?? false,
|
||||
userMessage:
|
||||
typeof candidate.userMessage === "string" && candidate.userMessage.trim()
|
||||
? candidate.userMessage.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseReflectionResponse(text: string): ParsedReflectionResponse | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
trimmed,
|
||||
...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []),
|
||||
];
|
||||
|
||||
for (const candidateText of candidates) {
|
||||
const candidate = candidateText.trim();
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = parseStructuredReflectionValue(JSON.parse(candidate));
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the next parse strategy.
|
||||
}
|
||||
}
|
||||
|
||||
// Safe fallback: keep the internal learning, but never auto-message the user.
|
||||
return {
|
||||
learning: trimmed,
|
||||
followUp: false,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
// Msteams plugin module implements feedback reflection store behavior.
|
||||
import crypto from "node:crypto";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
/** Default cooldown between reflections per session (5 minutes). */
|
||||
export const DEFAULT_COOLDOWN_MS = 300_000;
|
||||
|
||||
/** Tracks last reflection time per session to enforce cooldown. */
|
||||
const lastReflectionBySession = new Map<string, number>();
|
||||
|
||||
/** Maximum cooldown entries before pruning expired ones. */
|
||||
const MAX_COOLDOWN_ENTRIES = 500;
|
||||
const LEARNINGS_NAMESPACE = "feedback-learnings";
|
||||
const MAX_LEARNING_ENTRIES = 10_000;
|
||||
|
||||
@@ -23,59 +14,22 @@ function learningStoreKey(storePath: string, sessionKey: string): string {
|
||||
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function openLearningStore() {
|
||||
return getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
|
||||
namespace: LEARNINGS_NAMESPACE,
|
||||
maxEntries: MAX_LEARNING_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
/** Prune expired cooldown entries to prevent unbounded memory growth. */
|
||||
function pruneExpiredCooldowns(cooldownMs: number): void {
|
||||
if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [key, time] of lastReflectionBySession) {
|
||||
if (now - time >= cooldownMs) {
|
||||
lastReflectionBySession.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a reflection is allowed (cooldown not active). */
|
||||
export function isReflectionAllowed(sessionKey: string, cooldownMs?: number): boolean {
|
||||
const cooldown = cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
||||
const lastTime = lastReflectionBySession.get(sessionKey);
|
||||
if (lastTime == null) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - lastTime >= cooldown;
|
||||
}
|
||||
|
||||
/** Record that a reflection was run for a session. */
|
||||
export function recordReflectionTime(sessionKey: string, cooldownMs?: number): void {
|
||||
lastReflectionBySession.set(sessionKey, Date.now());
|
||||
pruneExpiredCooldowns(cooldownMs ?? DEFAULT_COOLDOWN_MS);
|
||||
}
|
||||
|
||||
/** Store a learning derived from feedback reflection. */
|
||||
export async function storeSessionLearning(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
learning: string;
|
||||
}): Promise<void> {
|
||||
const store = openLearningStore();
|
||||
const key = learningStoreKey(params.storePath, params.sessionKey);
|
||||
const existing = await store.lookup(key);
|
||||
let learnings = existing?.learnings ?? [];
|
||||
learnings.push(params.learning);
|
||||
if (learnings.length > 10) {
|
||||
learnings = learnings.slice(-10);
|
||||
}
|
||||
await store.register(key, {
|
||||
sessionKey: params.sessionKey,
|
||||
learnings,
|
||||
updatedAt: Date.now(),
|
||||
const store = getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
|
||||
namespace: LEARNINGS_NAMESPACE,
|
||||
maxEntries: MAX_LEARNING_ENTRIES,
|
||||
});
|
||||
const key = learningStoreKey(params.storePath, params.sessionKey);
|
||||
if (!store.update) {
|
||||
throw new Error("plugin state atomic update is unavailable");
|
||||
}
|
||||
await store.update(key, (existing) => ({
|
||||
sessionKey: params.sessionKey,
|
||||
learnings: [...(existing?.learnings ?? []), params.learning].slice(-10),
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
// Msteams tests cover feedback reflection plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
|
||||
import { isReflectionAllowed, recordReflectionTime } from "./feedback-reflection-store.js";
|
||||
import { buildFeedbackEvent } from "./feedback-reflection.js";
|
||||
|
||||
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
|
||||
// on the ES2024 String.prototype.isWellFormed() runtime API.
|
||||
const UNPAIRED_SURROGATE_RE =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
|
||||
describe("buildFeedbackEvent", () => {
|
||||
it("builds a well-formed custom event", () => {
|
||||
const event = buildFeedbackEvent({
|
||||
messageId: "msg-123",
|
||||
value: "negative",
|
||||
comment: "too verbose",
|
||||
sessionKey: "msteams:user1",
|
||||
agentId: "default",
|
||||
conversationId: "19:abc",
|
||||
});
|
||||
|
||||
expect(event.type).toBe("custom");
|
||||
expect(event.event).toBe("feedback");
|
||||
expect(event.value).toBe("negative");
|
||||
expect(event.comment).toBe("too verbose");
|
||||
expect(event.messageId).toBe("msg-123");
|
||||
expect(event.ts).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("omits comment when not provided", () => {
|
||||
const event = buildFeedbackEvent({
|
||||
messageId: "msg-123",
|
||||
value: "positive",
|
||||
sessionKey: "msteams:user1",
|
||||
agentId: "default",
|
||||
conversationId: "19:abc",
|
||||
});
|
||||
|
||||
expect(event.comment).toBeUndefined();
|
||||
expect(event.value).toBe("positive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReflectionPrompt", () => {
|
||||
it("includes the thumbed-down response", () => {
|
||||
const prompt = buildReflectionPrompt({
|
||||
thumbedDownResponse: "Here is a long explanation...",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("previous response wasn't helpful");
|
||||
expect(prompt).toContain("Here is a long explanation...");
|
||||
expect(prompt).toContain("reflect");
|
||||
});
|
||||
|
||||
it("truncates long responses", () => {
|
||||
const longResponse = "x".repeat(600);
|
||||
const prompt = buildReflectionPrompt({
|
||||
thumbedDownResponse: longResponse,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("...");
|
||||
expect(prompt.length).toBeLessThan(longResponse.length + 500);
|
||||
});
|
||||
|
||||
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
|
||||
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
|
||||
|
||||
const prompt = buildReflectionPrompt({ thumbedDownResponse });
|
||||
|
||||
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
|
||||
expect(prompt).toContain(`${"a".repeat(499)}...`);
|
||||
expect(prompt).not.toContain("\ud83e");
|
||||
expect(prompt).not.toContain("\udd9e");
|
||||
});
|
||||
|
||||
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
|
||||
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
|
||||
|
||||
const prompt = buildReflectionPrompt({ thumbedDownResponse });
|
||||
|
||||
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
|
||||
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
|
||||
});
|
||||
|
||||
it("includes user comment when provided", () => {
|
||||
const prompt = buildReflectionPrompt({
|
||||
thumbedDownResponse: "Some response",
|
||||
userComment: "Too wordy",
|
||||
});
|
||||
|
||||
expect(prompt).toContain('User\'s comment: "Too wordy"');
|
||||
});
|
||||
|
||||
it("works without optional params", () => {
|
||||
const prompt = buildReflectionPrompt({});
|
||||
expect(prompt).toContain("previous response wasn't helpful");
|
||||
expect(prompt).toContain('"followUp":false');
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReflectionResponse", () => {
|
||||
it("parses strict JSON output", () => {
|
||||
expect(
|
||||
parseReflectionResponse(
|
||||
'{"learning":"Be more direct next time.","followUp":true,"userMessage":"Sorry about that. I will keep it tighter."}',
|
||||
),
|
||||
).toEqual({
|
||||
learning: "Be more direct next time.",
|
||||
followUp: true,
|
||||
userMessage: "Sorry about that. I will keep it tighter.",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses JSON inside markdown fences", () => {
|
||||
expect(
|
||||
parseReflectionResponse(
|
||||
'```json\n{"learning":"Ask a clarifying question first.","followUp":false,"userMessage":""}\n```',
|
||||
),
|
||||
).toEqual({
|
||||
learning: "Ask a clarifying question first.",
|
||||
followUp: false,
|
||||
userMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to internal-only learning when parsing fails", () => {
|
||||
expect(parseReflectionResponse("Be more concise.\nFollow up: yes.")).toEqual({
|
||||
learning: "Be more concise.\nFollow up: yes.",
|
||||
followUp: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reflection cooldown", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows first reflection", () => {
|
||||
expect(isReflectionAllowed("session-first")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reflection within cooldown", () => {
|
||||
recordReflectionTime("session-blocked");
|
||||
expect(isReflectionAllowed("session-blocked", 60_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows reflection after cooldown expires", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
recordReflectionTime("session-expired");
|
||||
vi.spyOn(Date, "now").mockReturnValue(2);
|
||||
expect(isReflectionAllowed("session-expired", 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks sessions independently", () => {
|
||||
recordReflectionTime("session-tracked-1");
|
||||
expect(isReflectionAllowed("session-tracked-1", 60_000)).toBe(false);
|
||||
expect(isReflectionAllowed("session-tracked-2", 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps longer custom cooldown entries during pruning", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
recordReflectionTime("prune-target", 600_000);
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(301_000);
|
||||
for (let index = 0; index <= 500; index += 1) {
|
||||
recordReflectionTime(`prune-session-${index}`, 600_000);
|
||||
}
|
||||
|
||||
expect(isReflectionAllowed("prune-target", 600_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,16 @@
|
||||
// Msteams plugin module implements feedback reflection behavior.
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
dispatchReplyFromConfigWithSettledDispatcher,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS,
|
||||
runChannelFeedbackReflection,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
|
||||
import {
|
||||
DEFAULT_COOLDOWN_MS,
|
||||
isReflectionAllowed,
|
||||
recordReflectionTime,
|
||||
storeSessionLearning,
|
||||
} from "./feedback-reflection-store.js";
|
||||
import { storeSessionLearning } from "./feedback-reflection-store.js";
|
||||
import { buildConversationReference } from "./messenger.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
|
||||
@@ -30,7 +24,6 @@ type FeedbackEvent = {
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
reflectionLearning?: string;
|
||||
};
|
||||
|
||||
export function buildFeedbackEvent(params: {
|
||||
@@ -57,173 +50,65 @@ export function buildFeedbackEvent(params: {
|
||||
type RunFeedbackReflectionParams = {
|
||||
cfg: OpenClawConfig;
|
||||
app: MSTeamsApp;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
feedbackMessageId: string;
|
||||
conversationKind: "direct" | "group" | "channel";
|
||||
thumbedDownResponse?: string;
|
||||
userComment?: string;
|
||||
log: MSTeamsMonitorLogger;
|
||||
};
|
||||
|
||||
function buildReflectionContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
conversationId: string;
|
||||
sessionKey: string;
|
||||
reflectionPrompt: string;
|
||||
}) {
|
||||
const core = getMSTeamsRuntime();
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: "system",
|
||||
body: params.reflectionPrompt,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload: core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: params.reflectionPrompt,
|
||||
RawBody: params.reflectionPrompt,
|
||||
CommandBody: params.reflectionPrompt,
|
||||
From: `msteams:system:${params.conversationId}`,
|
||||
To: `conversation:${params.conversationId}`,
|
||||
SessionKey: params.sessionKey,
|
||||
ChatType: "direct" as const,
|
||||
SenderName: "system",
|
||||
SenderId: "system",
|
||||
Provider: "msteams" as const,
|
||||
Surface: "msteams" as const,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: true,
|
||||
CommandAuthorized: false,
|
||||
OriginatingChannel: "msteams" as const,
|
||||
OriginatingTo: `conversation:${params.conversationId}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createReflectionCaptureDispatcher(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
log: MSTeamsMonitorLogger;
|
||||
}) {
|
||||
const core = getMSTeamsRuntime();
|
||||
let response = "";
|
||||
const noopTypingCallbacks = {
|
||||
onReplyStart: async () => {},
|
||||
onIdle: () => {},
|
||||
onCleanup: () => {},
|
||||
};
|
||||
|
||||
const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||
deliver: async (payload) => {
|
||||
if (payload.text) {
|
||||
response += (response ? "\n" : "") + payload.text;
|
||||
}
|
||||
},
|
||||
typingCallbacks: noopTypingCallbacks,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
onError: (err) => {
|
||||
params.log.debug?.("reflection reply error", { error: formatUnknownError(err) });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
readResponse: () => response,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendReflectionFollowUp(params: {
|
||||
cfg: OpenClawConfig;
|
||||
app: MSTeamsApp;
|
||||
conversationRef: StoredConversationReference;
|
||||
userMessage: string;
|
||||
}): Promise<void> {
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
await sendMSTeamsActivityWithReference(
|
||||
params.app,
|
||||
baseRef,
|
||||
{ type: "message", text: params.userMessage },
|
||||
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(params.cfg.channels?.msteams) },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a background reflection after negative feedback.
|
||||
* This is designed to be called fire-and-forget (don't await in the invoke handler).
|
||||
*/
|
||||
export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> {
|
||||
const { cfg, log, sessionKey } = params;
|
||||
const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS;
|
||||
if (!isReflectionAllowed(sessionKey, cooldownMs)) {
|
||||
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const reflectionPrompt = buildReflectionPrompt({
|
||||
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,
|
||||
});
|
||||
|
||||
const cooldownMs =
|
||||
cfg.channels?.msteams?.feedbackReflectionCooldownMs ??
|
||||
DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS;
|
||||
let reflection;
|
||||
try {
|
||||
await dispatchReplyFromConfigWithSettledDispatcher({
|
||||
ctxPayload,
|
||||
reflection = await runChannelFeedbackReflection({
|
||||
cfg,
|
||||
dispatcher: capture.dispatcher,
|
||||
onSettled: () => {},
|
||||
replyOptions: capture.replyOptions,
|
||||
channel: "msteams",
|
||||
channelLabel: "Teams",
|
||||
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) {
|
||||
log.error("reflection dispatch failed", { error: formatUnknownError(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
const reflectionResponse = capture.readResponse().trim();
|
||||
if (!reflectionResponse) {
|
||||
if (reflection.status === "cooldown") {
|
||||
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
|
||||
return;
|
||||
}
|
||||
if (reflection.status === "empty") {
|
||||
log.debug?.("reflection produced no output");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedReflection = parseReflectionResponse(reflectionResponse);
|
||||
if (!parsedReflection) {
|
||||
log.debug?.("reflection produced no structured output");
|
||||
return;
|
||||
}
|
||||
|
||||
recordReflectionTime(sessionKey, cooldownMs);
|
||||
log.info("reflection complete", {
|
||||
sessionKey,
|
||||
responseLength: reflectionResponse.length,
|
||||
followUp: parsedReflection.followUp,
|
||||
responseLength: reflection.responseLength,
|
||||
followUp: reflection.followUp,
|
||||
});
|
||||
|
||||
try {
|
||||
await storeSessionLearning({
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
learning: parsedReflection.learning,
|
||||
storePath: reflection.storePath,
|
||||
sessionKey,
|
||||
learning: reflection.learning,
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) });
|
||||
@@ -233,12 +118,10 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
|
||||
params.conversationRef.conversation?.conversationType,
|
||||
);
|
||||
const shouldNotify =
|
||||
conversationType === "personal" &&
|
||||
parsedReflection.followUp &&
|
||||
Boolean(parsedReflection.userMessage);
|
||||
conversationType === "personal" && reflection.followUp && Boolean(reflection.userMessage);
|
||||
|
||||
if (!shouldNotify) {
|
||||
if (parsedReflection.followUp && conversationType !== "personal") {
|
||||
if (reflection.followUp && conversationType !== "personal") {
|
||||
log.debug?.("skipping reflection follow-up outside direct message", {
|
||||
sessionKey,
|
||||
conversationType,
|
||||
@@ -248,12 +131,12 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
|
||||
}
|
||||
|
||||
try {
|
||||
await sendReflectionFollowUp({
|
||||
cfg,
|
||||
app: params.app,
|
||||
conversationRef: params.conversationRef,
|
||||
userMessage: parsedReflection.userMessage!,
|
||||
});
|
||||
await sendMSTeamsActivityWithReference(
|
||||
params.app,
|
||||
buildConversationReference(params.conversationRef),
|
||||
{ type: "message", text: reflection.userMessage! },
|
||||
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(cfg.channels?.msteams) },
|
||||
);
|
||||
log.info("sent reflection follow-up", { sessionKey });
|
||||
} catch (err) {
|
||||
log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) });
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// Msteams tests cover monitor handler.feedback authz plugin behavior.
|
||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js";
|
||||
@@ -13,6 +10,14 @@ import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
const feedbackReflectionMockState = vi.hoisted(() => ({
|
||||
runFeedbackReflection: vi.fn(),
|
||||
}));
|
||||
const channelInboundMockState = vi.hoisted(() => ({
|
||||
recordChannelFeedbackEvent: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>()),
|
||||
recordChannelFeedbackEvent: channelInboundMockState.recordChannelFeedbackEvent,
|
||||
}));
|
||||
|
||||
vi.mock("./monitor-handler/message-handler.js", () => ({
|
||||
createMSTeamsMessageHandler: () => async () => {},
|
||||
@@ -57,7 +62,7 @@ function createRuntimeStub(readAllowFromStore: ReturnType<typeof vi.fn>): Plugin
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: (storePath?: string) => storePath ?? tmpdir(),
|
||||
resolveStorePath: (storePath?: string) => storePath ?? "/tmp",
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
@@ -126,41 +131,21 @@ function createFeedbackInvokeContext(params: {
|
||||
} as unknown as MSTeamsTurnContext;
|
||||
}
|
||||
|
||||
async function expectFileMissing(filePath: string) {
|
||||
let error: unknown;
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
}
|
||||
|
||||
async function withFeedbackHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
context: Parameters<typeof createFeedbackInvokeContext>[0];
|
||||
assertResult: (args: { tmpDir: string }) => Promise<void>;
|
||||
assertResult: () => Promise<void>;
|
||||
}) {
|
||||
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
|
||||
try {
|
||||
const deps = createDeps({
|
||||
cfg: {
|
||||
...params.cfg,
|
||||
session: { store: tmpDir },
|
||||
},
|
||||
});
|
||||
await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
|
||||
await params.assertResult({ tmpDir });
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
const deps = createDeps({ cfg: params.cfg });
|
||||
await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
|
||||
await params.assertResult();
|
||||
}
|
||||
|
||||
describe("msteams feedback invoke authz", () => {
|
||||
beforeEach(() => {
|
||||
feedbackReflectionMockState.runFeedbackReflection.mockReset();
|
||||
feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined);
|
||||
channelInboundMockState.recordChannelFeedbackEvent.mockClear();
|
||||
});
|
||||
|
||||
it("records feedback for an allowlisted DM sender", async () => {
|
||||
@@ -181,34 +166,22 @@ describe("msteams feedback invoke authz", () => {
|
||||
senderName: "Owner",
|
||||
comment: "allowed feedback",
|
||||
},
|
||||
assertResult: async ({ tmpDir }) => {
|
||||
const transcript = await readFile(
|
||||
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
|
||||
"utf-8",
|
||||
);
|
||||
const event = JSON.parse(transcript.trim()) as Record<string, unknown>;
|
||||
expect(Object.keys(event).toSorted()).toEqual([
|
||||
"agentId",
|
||||
"comment",
|
||||
"conversationId",
|
||||
"event",
|
||||
"messageId",
|
||||
"sessionKey",
|
||||
"ts",
|
||||
"type",
|
||||
"value",
|
||||
]);
|
||||
expect(typeof event.ts).toBe("number");
|
||||
expect({ ...event, ts: 0 }).toEqual({
|
||||
type: "custom",
|
||||
event: "feedback",
|
||||
ts: 0,
|
||||
messageId: "bot-msg-1",
|
||||
value: "positive",
|
||||
comment: "allowed feedback",
|
||||
sessionKey: "msteams:direct:owner-aad",
|
||||
assertResult: async () => {
|
||||
expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith({
|
||||
cfg: expect.any(Object),
|
||||
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",
|
||||
comment: "allowed dm feedback",
|
||||
},
|
||||
assertResult: async ({ tmpDir }) => {
|
||||
const transcript = await readFile(
|
||||
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
|
||||
"utf-8",
|
||||
assertResult: async () => {
|
||||
expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
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",
|
||||
comment: "blocked feedback",
|
||||
},
|
||||
assertResult: async ({ tmpDir }) => {
|
||||
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl"));
|
||||
assertResult: async () => {
|
||||
expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
|
||||
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {
|
||||
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
|
||||
try {
|
||||
const deps = createDeps({
|
||||
cfg: {
|
||||
session: { store: tmpDir },
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["owner-aad"],
|
||||
feedbackReflection: true,
|
||||
},
|
||||
const deps = createDeps({
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["owner-aad"],
|
||||
feedbackReflection: true,
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
await runMSTeamsFeedbackInvokeHandler(
|
||||
createFeedbackInvokeContext({
|
||||
reaction: "dislike",
|
||||
conversationId: "19:group@thread.tacv2;messageid=bot-msg-1",
|
||||
conversationType: "groupChat",
|
||||
senderId: "attacker-aad",
|
||||
senderName: "Attacker",
|
||||
teamId: "team-1",
|
||||
channelName: "General",
|
||||
comment: "blocked reflection",
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
await runMSTeamsFeedbackInvokeHandler(
|
||||
createFeedbackInvokeContext({
|
||||
reaction: "dislike",
|
||||
conversationId: "19:group@thread.tacv2;messageid=bot-msg-1",
|
||||
conversationType: "groupChat",
|
||||
senderId: "attacker-aad",
|
||||
senderName: "Attacker",
|
||||
teamId: "team-1",
|
||||
channelName: "General",
|
||||
comment: "blocked reflection",
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
await expectFileMissing(path.join(tmpDir, "msteams_group_19_group_thread_tacv2.jsonl"));
|
||||
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
|
||||
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ type MSTeamsTestRuntimeOptions = {
|
||||
};
|
||||
|
||||
export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void {
|
||||
const recordInboundSession = options.recordInboundSession ?? vi.fn(async () => undefined);
|
||||
const resolveStorePath = options.resolveStorePath ?? (() => "/tmp/msteams-sessions.json");
|
||||
const runPrepared = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
|
||||
await turn.recordInboundSession({
|
||||
storePath: turn.storePath,
|
||||
@@ -63,7 +65,16 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
|
||||
: (preflightResult ?? {});
|
||||
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
||||
if ("runDispatch" in turn) {
|
||||
return await runPrepared(turn);
|
||||
const preparedTurn =
|
||||
"route" in turn
|
||||
? ({
|
||||
...turn,
|
||||
routeSessionKey: turn.route.sessionKey,
|
||||
storePath: resolveStorePath(),
|
||||
recordInboundSession,
|
||||
} as PreparedInboundReply<unknown>)
|
||||
: turn;
|
||||
return await runPrepared(preparedTurn);
|
||||
}
|
||||
throw new Error("msteams test runtime only supports prepared turn dispatch");
|
||||
});
|
||||
@@ -124,8 +135,8 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
|
||||
resolveHumanDelayConfig: () => undefined,
|
||||
},
|
||||
session: {
|
||||
recordInboundSession: options.recordInboundSession ?? vi.fn(async () => undefined),
|
||||
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
|
||||
recordInboundSession,
|
||||
resolveStorePath,
|
||||
},
|
||||
inbound: {
|
||||
run: run as unknown as PluginRuntime["channel"]["inbound"]["run"],
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
buildChannelInboundEventContext,
|
||||
createChannelInboundEnvelopeBuilder,
|
||||
logInboundDrop,
|
||||
resolveInboundMentionDecision,
|
||||
resolveInboundSessionEnvelopeContext,
|
||||
resolveInboundSupplementalSenderAllowed,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
@@ -788,17 +788,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
quoteSenderName ??= quoteInfo?.sender;
|
||||
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const buildEnvelope = createChannelInboundEnvelopeBuilder({ cfg, route });
|
||||
const body = buildEnvelope({
|
||||
channel: "Teams",
|
||||
from: envelopeFrom,
|
||||
timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: agentBody,
|
||||
});
|
||||
let combinedBody = body;
|
||||
@@ -811,12 +805,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
buildEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
previousTimestamp: null,
|
||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -858,7 +852,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined;
|
||||
const ctxPayload = buildChannelInboundEventContext({
|
||||
channel: "msteams",
|
||||
finalize: core.channel.reply.finalizeInboundContext,
|
||||
contextVisibility: contextVisibilityMode,
|
||||
supplemental: {
|
||||
quote: quoteInfo
|
||||
@@ -975,12 +968,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
raw: activity,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
logVerboseMessage(
|
||||
|
||||
@@ -14,6 +14,14 @@ vi.mock("../runtime-api.js", () => ({
|
||||
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getMSTeamsRuntime: getMSTeamsRuntimeMock,
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
// Msteams plugin module implements reply dispatcher behavior.
|
||||
import {
|
||||
buildChannelProgressDraftLine,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
createChannelMessageReplyPipeline,
|
||||
@@ -295,9 +297,9 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
markDispatchIdle: baseMarkDispatchIdle,
|
||||
} = core.channel.reply.createReplyDispatcherWithTyping({
|
||||
} = createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
onReplyStart: async () => {
|
||||
await streamController.onReplyStart();
|
||||
// Always start the typing keepalive loop when typing is enabled and
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
buildChannelInboundEventContext,
|
||||
resolveChannelInboundRouteEnvelope,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
// Nextcloud Talk plugin module implements inbound behavior.
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
resolveStableChannelMessageIngress,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
@@ -304,7 +307,7 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
|
||||
return;
|
||||
}
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
@@ -312,14 +315,10 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? roomToken : senderId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: (config.session as Record<string, unknown> | undefined)?.store as
|
||||
| string
|
||||
| undefined,
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
|
||||
const { storePath, body } = buildEnvelope({
|
||||
const body = buildEnvelope({
|
||||
channel: "Nextcloud Talk",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
@@ -329,42 +328,37 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: rawBody,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
|
||||
To: `nextcloud-talk:${roomToken}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
GroupSubject: isGroup ? roomName || roomToken : undefined,
|
||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
MessageSid: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: `nextcloud-talk:${roomToken}`,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
const ctxPayload = buildChannelInboundEventContext({
|
||||
channel: CHANNEL_ID,
|
||||
accountId: route.accountId,
|
||||
messageId: message.messageId,
|
||||
timestamp: message.timestamp,
|
||||
from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
|
||||
sender: { id: senderId, name: senderName || undefined },
|
||||
conversation: { kind: isGroup ? "group" : "direct", id: roomToken, label: fromLabel },
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
},
|
||||
reply: { to: `nextcloud-talk:${roomToken}`, originatingTo: `nextcloud-talk:${roomToken}` },
|
||||
message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody },
|
||||
access: {
|
||||
commands: { authorized: commandAuthorized },
|
||||
mentions: { canDetectMention: isGroup, wasMentioned: isGroup && wasMentioned },
|
||||
},
|
||||
extra: {
|
||||
GroupSubject: isGroup ? roomName || roomToken : undefined,
|
||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await core.channel.inbound.dispatchReply({
|
||||
await core.channel.inbound.dispatch({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
preparePayload: (payload) =>
|
||||
payload.text === undefined
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { dispatchInboundDirectDm as DispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound";
|
||||
// Nostr tests cover channel.inbound plugin behavior.
|
||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -7,6 +8,7 @@ import { setNostrRuntime } from "./runtime.js";
|
||||
import { buildResolvedNostrAccount } from "./test-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatchInboundDirectDm: vi.fn(),
|
||||
normalizePubkey: vi.fn((value: string) =>
|
||||
value
|
||||
.trim()
|
||||
@@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({
|
||||
startNostrBus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>()),
|
||||
dispatchInboundDirectDm: mocks.dispatchInboundDirectDm,
|
||||
}));
|
||||
vi.mock("./nostr-bus.js", () => ({
|
||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
@@ -127,6 +133,7 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
|
||||
|
||||
describe("nostr inbound gateway path", () => {
|
||||
afterEach(() => {
|
||||
mocks.dispatchInboundDirectDm.mockReset();
|
||||
mocks.normalizePubkey.mockClear();
|
||||
mocks.startNostrBus.mockReset();
|
||||
});
|
||||
@@ -159,15 +166,19 @@ describe("nostr inbound gateway path", () => {
|
||||
});
|
||||
|
||||
it("routes allowed DMs through the standard reply pipeline", async () => {
|
||||
const { harness, cleanup } = await startGatewayHarness({
|
||||
mocks.dispatchInboundDirectDm.mockImplementationOnce(
|
||||
async (params: Parameters<typeof DispatchInboundDirectDm>[0]) => {
|
||||
await params.deliver({ text: "|a|b|" });
|
||||
},
|
||||
);
|
||||
const { cleanup } = await startGatewayHarness({
|
||||
account: buildResolvedNostrAccount({
|
||||
publicKey: "bot-pubkey",
|
||||
config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
|
||||
}),
|
||||
cfg: {
|
||||
session: { store: { type: "jsonl" } },
|
||||
commands: { useAccessGroups: true },
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
|
||||
const options = mockCallArg(mocks.startNostrBus) as {
|
||||
@@ -185,17 +196,18 @@ describe("nostr inbound gateway path", () => {
|
||||
createdAt: 1_710_000_000,
|
||||
});
|
||||
|
||||
expect(harness.recordInboundSession).toHaveBeenCalledTimes(1);
|
||||
expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const ctx = (
|
||||
mockCallArg(harness.dispatchReplyWithBufferedBlockDispatcher) as {
|
||||
ctx?: Record<string, unknown>;
|
||||
}
|
||||
).ctx;
|
||||
expect(ctx?.BodyForAgent).toBe("hello from nostr");
|
||||
expect(ctx?.SenderId).toBe("sender-pubkey");
|
||||
expect(ctx?.MessageSid).toBe("event-123");
|
||||
expect(ctx?.CommandAuthorized).toBe(true);
|
||||
expect(mocks.dispatchInboundDirectDm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "nostr",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "sender-pubkey" },
|
||||
senderId: "sender-pubkey",
|
||||
rawBody: "hello from nostr",
|
||||
messageId: "event-123",
|
||||
timestamp: 1_710_000_000_000,
|
||||
commandAuthorized: true,
|
||||
}),
|
||||
);
|
||||
expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
|
||||
|
||||
await cleanup.stop();
|
||||
|
||||
@@ -163,11 +163,9 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatchInboundDirectDmWithRuntime } =
|
||||
await import("./inbound-direct-dm-runtime.js");
|
||||
await dispatchInboundDirectDmWithRuntime({
|
||||
const { dispatchInboundDirectDm } = await import("./inbound-direct-dm-runtime.js");
|
||||
await dispatchInboundDirectDm({
|
||||
cfg: ctx.cfg,
|
||||
runtime,
|
||||
channel: "nostr",
|
||||
channelLabel: "Nostr",
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Nostr plugin module implements inbound direct dm runtime behavior.
|
||||
export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { dispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound";
|
||||
|
||||
@@ -18,7 +18,7 @@ import { qaChannelPlugin, setQaChannelRuntime } from "../api.js";
|
||||
import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
|
||||
import type { ChannelMessageActionName } from "./runtime-api.js";
|
||||
|
||||
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0];
|
||||
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatch"]>[0];
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
@@ -66,7 +66,6 @@ function createMockQaRuntime(params?: {
|
||||
onDispatch?: (ctx: Record<string, unknown>) => void;
|
||||
toolStarts?: Array<{ name?: string; phase?: string; args?: Record<string, unknown> }>;
|
||||
}): PluginRuntime {
|
||||
const sessionUpdatedAt = new Map<string, number>();
|
||||
return createPluginRuntimeMock({
|
||||
channel: {
|
||||
mentions: {
|
||||
@@ -77,104 +76,24 @@ function createMockQaRuntime(params?: {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute({
|
||||
accountId,
|
||||
peer,
|
||||
}: {
|
||||
accountId?: string | null;
|
||||
peer?: { kind?: string; id?: string } | null;
|
||||
}) {
|
||||
return {
|
||||
agentId: "qa-agent",
|
||||
channel: "qa-channel",
|
||||
accountId: accountId ?? "default",
|
||||
sessionKey: `qa-agent:${peer?.kind ?? "direct"}:${peer?.id ?? "default"}`,
|
||||
mainSessionKey: "qa-agent:main",
|
||||
lastRoutePolicy: "session",
|
||||
matchedBy: "default",
|
||||
};
|
||||
},
|
||||
},
|
||||
session: {
|
||||
resolveStorePath(_store: string | undefined, { agentId }: { agentId: string }) {
|
||||
return agentId;
|
||||
},
|
||||
readSessionUpdatedAt({ sessionKey }: { sessionKey: string }) {
|
||||
return sessionUpdatedAt.get(sessionKey);
|
||||
},
|
||||
recordInboundSession({ sessionKey }: { sessionKey: string }) {
|
||||
sessionUpdatedAt.set(sessionKey, Date.now());
|
||||
},
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions() {
|
||||
return {};
|
||||
},
|
||||
formatAgentEnvelope({ body }: { body: string }) {
|
||||
return body;
|
||||
},
|
||||
finalizeInboundContext(ctx: Record<string, unknown>) {
|
||||
return ctx as typeof ctx & { CommandAuthorized: boolean };
|
||||
},
|
||||
async dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx,
|
||||
dispatcherOptions,
|
||||
replyOptions,
|
||||
}: {
|
||||
ctx: { BodyForAgent?: string; Body?: string };
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text: string }, info: { kind: string }) => Promise<void>;
|
||||
};
|
||||
replyOptions?: {
|
||||
onToolStart?: (payload: {
|
||||
name?: string;
|
||||
phase?: string;
|
||||
args?: Record<string, unknown>;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
}) {
|
||||
inbound: {
|
||||
async dispatch(turn: QaDispatchTurn) {
|
||||
for (const toolStart of params?.toolStarts ?? []) {
|
||||
await replyOptions?.onToolStart?.(toolStart);
|
||||
await turn.replyOptions?.onToolStart?.(toolStart);
|
||||
}
|
||||
params?.onDispatch?.(ctx as Record<string, unknown>);
|
||||
await dispatcherOptions.deliver(
|
||||
params?.onDispatch?.(turn.ctxPayload as Record<string, unknown>);
|
||||
await turn.delivery.deliver(
|
||||
{
|
||||
text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`,
|
||||
text: `qa-echo: ${turn.ctxPayload.BodyForAgent ?? turn.ctxPayload.Body ?? ""}`,
|
||||
},
|
||||
{ kind: "final" },
|
||||
);
|
||||
},
|
||||
},
|
||||
inbound: {
|
||||
async dispatchReply(turn: QaDispatchTurn) {
|
||||
await turn.recordInboundSession({
|
||||
storePath: turn.storePath,
|
||||
sessionKey:
|
||||
typeof turn.ctxPayload.SessionKey === "string"
|
||||
? turn.ctxPayload.SessionKey
|
||||
: turn.routeSessionKey,
|
||||
ctx: turn.ctxPayload,
|
||||
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
||||
});
|
||||
return {
|
||||
admission: turn.admission ?? { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: turn.ctxPayload,
|
||||
routeSessionKey: turn.routeSessionKey,
|
||||
dispatchResult: await turn.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: turn.ctxPayload,
|
||||
cfg: turn.cfg,
|
||||
dispatcherOptions: {
|
||||
...turn.dispatcherOptions,
|
||||
deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
|
||||
await turn.delivery.deliver(...args);
|
||||
},
|
||||
onError: turn.delivery.onError,
|
||||
},
|
||||
replyOptions: turn.replyOptions,
|
||||
replyResolver: turn.replyResolver,
|
||||
}),
|
||||
routeSessionKey: turn.route.sessionKey,
|
||||
dispatchResult: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -505,7 +424,7 @@ describe("qa-channel plugin", () => {
|
||||
expect(ctx.ChatType).toBe("group");
|
||||
expect(ctx.From).toBe("group:qa-room");
|
||||
expect(ctx.To).toBe("group:qa-room");
|
||||
expect(ctx.SessionKey).toBe("qa-agent:group:group:qa-room");
|
||||
expect(ctx.SessionKey).toBe("agent:main:qa-channel:group:group:qa-room");
|
||||
expect(ctx.SenderId).toBe("alice");
|
||||
expect(ctx.GroupSubject).toBe("QA Room");
|
||||
expect("conversation" in outbound).toBe(true);
|
||||
|
||||
@@ -59,7 +59,7 @@ function createQaInboundParams(
|
||||
}
|
||||
|
||||
function firstRunAssembledParams(runtime: ReturnType<typeof createPluginRuntimeMock>) {
|
||||
const call = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0];
|
||||
const call = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("expected assembled turn call");
|
||||
}
|
||||
@@ -262,7 +262,7 @@ describe("handleQaInbound", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||
const assembled = firstRunAssembledParams(runtime);
|
||||
expect(assembled.replyPipeline).toEqual({});
|
||||
expect(assembled.ctxPayload.WasMentioned).toBe(true);
|
||||
@@ -280,7 +280,7 @@ describe("handleQaInbound", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
||||
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows direct messages from configured senders", async () => {
|
||||
@@ -295,7 +295,7 @@ describe("handleQaInbound", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
||||
expect(ctxPayload?.CommandAuthorized).toBe(true);
|
||||
expect(ctxPayload?.SenderId).toBe("alice");
|
||||
@@ -318,14 +318,14 @@ describe("handleQaInbound", () => {
|
||||
expect(assembled.ctxPayload).toMatchObject({
|
||||
CommandAuthorized: true,
|
||||
CommandSource: "native",
|
||||
CommandTargetSessionKey: assembled.routeSessionKey,
|
||||
CommandTargetSessionKey: assembled.route.sessionKey,
|
||||
CommandTurn: {
|
||||
body: "/stop",
|
||||
source: "native",
|
||||
},
|
||||
});
|
||||
expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice");
|
||||
expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.routeSessionKey);
|
||||
expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.route.sessionKey);
|
||||
});
|
||||
|
||||
it("skips malformed inline attachment base64 without dropping the message", async () => {
|
||||
@@ -347,7 +347,7 @@ describe("handleQaInbound", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
||||
expect(ctxPayload.MediaPath).toBeUndefined();
|
||||
expect(ctxPayload.MediaPaths).toBeUndefined();
|
||||
@@ -380,7 +380,7 @@ describe("handleQaInbound", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
||||
expect(ctxPayload.MediaPath).toBeUndefined();
|
||||
expect(ctxPayload.MediaPaths).toBeUndefined();
|
||||
@@ -410,7 +410,7 @@ describe("handleQaInbound", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips configured group messages that miss mention activation", async () => {
|
||||
@@ -438,6 +438,6 @@ describe("handleQaInbound", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
||||
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
buildChannelInboundEventContext,
|
||||
resolveChannelInboundRouteEnvelope,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
// Qa Channel plugin module implements inbound behavior.
|
||||
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import {
|
||||
buildAgentMediaPayload,
|
||||
saveMediaBuffer,
|
||||
@@ -219,7 +222,7 @@ export async function handleQaInbound(params: {
|
||||
target,
|
||||
toolCalls,
|
||||
});
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||
cfg: params.config as OpenClawConfig,
|
||||
channel: params.channelId,
|
||||
accountId: params.account.accountId,
|
||||
@@ -232,8 +235,6 @@ export async function handleQaInbound(params: {
|
||||
: "channel",
|
||||
id: target,
|
||||
},
|
||||
runtime: runtime.channel,
|
||||
sessionStore: params.config.session?.store,
|
||||
});
|
||||
const isGroup = inbound.conversation.kind !== "direct";
|
||||
const wasMentioned = isGroup
|
||||
@@ -286,7 +287,7 @@ export async function handleQaInbound(params: {
|
||||
if (access.ingress.admission !== "dispatch") {
|
||||
return;
|
||||
}
|
||||
const { storePath, body } = buildEnvelope({
|
||||
const body = buildEnvelope({
|
||||
channel: params.channelLabel,
|
||||
from: inbound.senderName || inbound.senderId,
|
||||
timestamp: inbound.timestamp,
|
||||
@@ -304,65 +305,64 @@ export async function handleQaInbound(params: {
|
||||
: undefined;
|
||||
const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text;
|
||||
|
||||
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: inbound.text,
|
||||
RawBody: inbound.text,
|
||||
CommandBody: commandBody,
|
||||
From: target,
|
||||
To: target,
|
||||
SessionKey: commandTargets?.sessionKey ?? route.sessionKey,
|
||||
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
|
||||
AccountId: route.accountId ?? params.account.accountId,
|
||||
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group",
|
||||
WasMentioned: wasMentioned,
|
||||
ConversationLabel:
|
||||
inbound.threadTitle ||
|
||||
inbound.conversation.title ||
|
||||
inbound.senderName ||
|
||||
inbound.conversation.id,
|
||||
GroupSubject: isGroup
|
||||
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
|
||||
const sessionKey = commandTargets?.sessionKey ?? route.sessionKey;
|
||||
const ctxPayload = buildChannelInboundEventContext({
|
||||
channel: params.channelId,
|
||||
accountId: route.accountId ?? params.account.accountId,
|
||||
messageId: inbound.id,
|
||||
messageIdFull: inbound.id,
|
||||
timestamp: inbound.timestamp,
|
||||
from: target,
|
||||
sender: { id: inbound.senderId, name: inbound.senderName },
|
||||
conversation: {
|
||||
kind: inbound.conversation.kind === "direct" ? "direct" : "group",
|
||||
id: inbound.conversation.id,
|
||||
label:
|
||||
inbound.threadTitle ||
|
||||
inbound.conversation.title ||
|
||||
inbound.senderName ||
|
||||
inbound.conversation.id,
|
||||
threadId: inbound.threadId,
|
||||
nativeChannelId: inbound.conversation.id,
|
||||
},
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: sessionKey,
|
||||
dispatchSessionKey: sessionKey,
|
||||
},
|
||||
reply: {
|
||||
to: target,
|
||||
originatingTo: target,
|
||||
replyToId: inbound.replyToId,
|
||||
messageThreadId: inbound.threadId,
|
||||
threadParentId: inbound.threadId ? inbound.conversation.id : undefined,
|
||||
},
|
||||
message: { body, bodyForAgent: inbound.text, rawBody: inbound.text, commandBody },
|
||||
access: {
|
||||
commands: { authorized: true },
|
||||
mentions: { canDetectMention: isGroup, wasMentioned: Boolean(wasMentioned) },
|
||||
},
|
||||
command: nativeCommand
|
||||
? { kind: "native", name: nativeCommand.name, body: commandBody, authorized: true }
|
||||
: undefined,
|
||||
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
|
||||
NativeChannelId: inbound.conversation.id,
|
||||
MessageThreadId: inbound.threadId,
|
||||
ThreadLabel: inbound.threadTitle,
|
||||
ThreadParentId: inbound.threadId ? inbound.conversation.id : undefined,
|
||||
SenderName: inbound.senderName,
|
||||
SenderId: inbound.senderId,
|
||||
Provider: params.channelId,
|
||||
Surface: params.channelId,
|
||||
MessageSid: inbound.id,
|
||||
MessageSidFull: inbound.id,
|
||||
ReplyToId: inbound.replyToId,
|
||||
Timestamp: inbound.timestamp,
|
||||
OriginatingChannel: params.channelId,
|
||||
OriginatingTo: target,
|
||||
CommandAuthorized: true,
|
||||
CommandSource: nativeCommand ? "native" : undefined,
|
||||
CommandTurn: nativeCommand
|
||||
? {
|
||||
kind: "native",
|
||||
source: "native",
|
||||
authorized: true,
|
||||
body: commandBody,
|
||||
}
|
||||
: undefined,
|
||||
...mediaPayload,
|
||||
extra: {
|
||||
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
|
||||
GroupSubject: isGroup
|
||||
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
|
||||
: undefined,
|
||||
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
|
||||
ThreadLabel: inbound.threadTitle,
|
||||
...mediaPayload,
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.channel.inbound.dispatchReply({
|
||||
await runtime.channel.inbound.dispatch({
|
||||
cfg: params.config as OpenClawConfig,
|
||||
channel: params.channelId,
|
||||
accountId: params.account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver: async (payload, info) => {
|
||||
const text =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Qqbot plugin module implements inbound context behavior.
|
||||
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { EngineAdapters } from "../adapter/index.js";
|
||||
import type { QQBotGroupCommandLevel } from "../config/group.js";
|
||||
import type { GroupActivationMode } from "../group/activation.js";
|
||||
@@ -69,7 +70,7 @@ export interface InboundContext {
|
||||
|
||||
export interface InboundPipelineDeps {
|
||||
account: GatewayAccount;
|
||||
cfg: unknown;
|
||||
cfg: OpenClawConfig;
|
||||
log?: EngineLogger;
|
||||
runtime: GatewayPluginRuntime;
|
||||
startTyping: (event: QueuedMessage) => Promise<{
|
||||
|
||||
@@ -421,10 +421,6 @@ export async function dispatchOutbound(
|
||||
});
|
||||
}
|
||||
|
||||
const cfgWithSession = cfg as { session?: { store?: unknown } };
|
||||
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
|
||||
agentId: routeAgentId,
|
||||
});
|
||||
const dispatchPromise = runtime.channel.inbound.run({
|
||||
channel: "qqbot",
|
||||
accountId: inbound.route.accountId,
|
||||
@@ -438,12 +434,11 @@ export async function dispatchOutbound(
|
||||
raw: inbound,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: openClawCfg,
|
||||
channel: "qqbot",
|
||||
accountId: inbound.route.accountId,
|
||||
routeSessionKey: inbound.route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: routeAgentId, sessionKey: inbound.route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||
record: {
|
||||
onRecordError: (err: unknown) => {
|
||||
log?.error(
|
||||
@@ -773,7 +768,6 @@ async function buildCtxPayload(
|
||||
const commandSource = resolveCommandSource(inbound, runtime, cfg);
|
||||
const hasImageMedia = inbound.localMediaPaths.length > 0 || inbound.remoteMediaUrls.length > 0;
|
||||
return buildChannelInboundEventContext({
|
||||
finalize: runtime.channel.reply.finalizeInboundContext,
|
||||
channel: "qqbot",
|
||||
accountId: inbound.route.accountId,
|
||||
messageId: event.messageId,
|
||||
|
||||
@@ -80,13 +80,13 @@ function buildAllowAccess(): QQBotInboundAccess {
|
||||
}
|
||||
|
||||
function buildDeps(
|
||||
cfg: unknown,
|
||||
cfg: StubCfg,
|
||||
runtime: GatewayPluginRuntime,
|
||||
account: GatewayAccount,
|
||||
): InboundPipelineDeps {
|
||||
return {
|
||||
account,
|
||||
cfg,
|
||||
cfg: cfg as InboundPipelineDeps["cfg"],
|
||||
runtime,
|
||||
startTyping: vi.fn(),
|
||||
adapters: {
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
* sees directly.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions,
|
||||
type EnvelopeFormatOptions,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
||||
import {
|
||||
buildMergedMessageContext,
|
||||
@@ -103,13 +108,13 @@ export function buildAgentBody(input: BuildAgentBodyInput): string {
|
||||
return base;
|
||||
}
|
||||
|
||||
const envelopeOpts = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
|
||||
const envelopeOpts = resolveEnvelopeFormatOptions(deps.cfg);
|
||||
return deps.adapters.history.buildPendingHistoryContext({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey: event.groupOpenid,
|
||||
limit: groupInfo.historyLimit,
|
||||
currentMessage: base,
|
||||
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, deps, envelopeOpts),
|
||||
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, envelopeOpts),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,19 +144,15 @@ function formatSenderLabelFrom(name: string | undefined, id: string): string {
|
||||
return name.includes(id) ? name : `${name} (${id})`;
|
||||
}
|
||||
|
||||
function formatHistoryEntry(
|
||||
entry: HistoryEntry,
|
||||
deps: InboundPipelineDeps,
|
||||
envelopeOpts: unknown,
|
||||
): string {
|
||||
function formatHistoryEntry(entry: HistoryEntry, envelopeOpts: unknown): string {
|
||||
const attachmentDesc = formatAttachmentTags(entry.attachments);
|
||||
const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body;
|
||||
return deps.runtime.channel.reply.formatInboundEnvelope({
|
||||
return formatInboundEnvelope({
|
||||
channel: "qqbot",
|
||||
from: entry.sender,
|
||||
timestamp: entry.timestamp,
|
||||
body: bodyWithAttachments,
|
||||
chatType: "group",
|
||||
envelope: envelopeOpts,
|
||||
envelope: envelopeOpts as EnvelopeFormatOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
* dispatcher needs. No decisions / gating.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ProcessedAttachments } from "../inbound-attachments.js";
|
||||
import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js";
|
||||
@@ -25,16 +30,16 @@ interface BuildBodyInput {
|
||||
/** Format the inbound envelope (Web UI body). */
|
||||
export function buildBody(input: BuildBodyInput): string {
|
||||
const { event, deps, userContent, isGroupChat, imageUrls } = input;
|
||||
const envelopeOptions = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
|
||||
return deps.runtime.channel.reply.formatInboundEnvelope({
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg as OpenClawConfig);
|
||||
return formatInboundEnvelope({
|
||||
channel: "qqbot",
|
||||
from: event.senderName ?? event.senderId,
|
||||
timestamp: new Date(event.timestamp).getTime(),
|
||||
body: userContent,
|
||||
...(imageUrls.length > 0 ? { imageUrls } : {}),
|
||||
chatType: isGroupChat ? "group" : "direct",
|
||||
sender: { id: event.senderId, name: event.senderName },
|
||||
envelope: envelopeOptions,
|
||||
...(imageUrls.length > 0 ? { imageUrls } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,20 +87,12 @@ export async function dispatchRaftWake(params: {
|
||||
bodyForAgent: input.textForAgent,
|
||||
},
|
||||
});
|
||||
const storePath = channelRuntime.session.resolveStorePath(ctx.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
return {
|
||||
cfg: ctx.cfg,
|
||||
channel: RAFT_CHANNEL_ID,
|
||||
accountId: ctx.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: channelRuntime.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
// Raft's bridge only transports wake hints. The agent owns CLI delivery
|
||||
// after it reads the pending Raft messages, so OpenClaw must not emit a
|
||||
// duplicate synthetic reply through the channel dispatcher.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
dispatchInboundDirectDmWithRuntime,
|
||||
dispatchInboundDirectDm,
|
||||
recordChannelBotPairLoopAndCheckSuppression,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
@@ -235,9 +235,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await dispatchInboundDirectDmWithRuntime({
|
||||
await dispatchInboundDirectDm({
|
||||
cfg: ctx.cfg,
|
||||
runtime,
|
||||
channel: "reef",
|
||||
channelLabel: "Reef",
|
||||
accountId: "default",
|
||||
@@ -293,9 +292,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
|
||||
async (notice) => {
|
||||
let resendText = "";
|
||||
let dispatchFailure: Error | undefined;
|
||||
await dispatchInboundDirectDmWithRuntime({
|
||||
await dispatchInboundDirectDm({
|
||||
cfg: ctx.cfg,
|
||||
runtime,
|
||||
channel: "reef",
|
||||
channelLabel: "Reef",
|
||||
accountId: "default",
|
||||
|
||||
@@ -79,6 +79,38 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
|
||||
"openclaw/plugin-sdk/channel-inbound",
|
||||
);
|
||||
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
|
||||
return {
|
||||
...actual,
|
||||
runChannelInboundEvent: (params: RunParams) => {
|
||||
const resolveTurn = params.adapter.resolveTurn;
|
||||
return actual.runChannelInboundEvent({
|
||||
...params,
|
||||
adapter: {
|
||||
...params.adapter,
|
||||
resolveTurn: async (input, eventClass, preflight) => {
|
||||
const resolved = await resolveTurn(input, eventClass, preflight);
|
||||
if (!("route" in resolved) || !("runDispatch" in resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
const { route, ...turn } = resolved;
|
||||
return {
|
||||
...turn,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: "/tmp/openclaw/signal-sessions.json",
|
||||
recordInboundSession: recordInboundSessionMock,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
@@ -536,12 +535,11 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
raw: entry,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: deps.cfg,
|
||||
channel: "signal",
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
record: {
|
||||
updateLastRoute: !entry.isGroup
|
||||
? {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Slack-private authored text placement after block compilation.
|
||||
import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import type { LegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export type SlackAuthoredTextPlacement = "none" | "blocks" | "outside-blocks";
|
||||
@@ -10,7 +10,7 @@ function normalizeComparableSlackText(text: string): string {
|
||||
|
||||
function isSlackAuthoredTextRepresentedInInteractive(
|
||||
text: string,
|
||||
interactive?: InteractiveReply,
|
||||
interactive?: LegacyInteractiveReply,
|
||||
): boolean {
|
||||
return isSlackAuthoredTextRepresentedInFragments(
|
||||
text,
|
||||
@@ -43,7 +43,7 @@ function isSlackAuthoredTextRepresentedInFragments(
|
||||
/** Resolve placement from producer facts, before accessibility text changes the payload text. */
|
||||
export function resolveSlackAuthoredTextPlacement(params: {
|
||||
text?: string;
|
||||
interactive?: InteractiveReply;
|
||||
interactive?: LegacyInteractiveReply;
|
||||
renderedInBlocks?: boolean;
|
||||
renderedTextFragments?: readonly string[];
|
||||
}): SlackAuthoredTextPlacement {
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import type { Block, KnownBlock } from "@slack/web-api";
|
||||
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import {
|
||||
reduceInteractiveReply,
|
||||
reduceLegacyInteractiveReply,
|
||||
resolveMessagePresentationButtonAction,
|
||||
resolveMessagePresentationOptionAction,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import type {
|
||||
InteractiveReply,
|
||||
LegacyInteractiveReply,
|
||||
MessagePresentation,
|
||||
MessagePresentationAction,
|
||||
MessagePresentationButtonsBlock,
|
||||
@@ -228,7 +228,7 @@ export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackB
|
||||
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
|
||||
*/
|
||||
export function buildSlackInteractiveBlocks(
|
||||
interactive?: InteractiveReply,
|
||||
interactive?: LegacyInteractiveReply,
|
||||
options: SlackBlockRenderOptions = {},
|
||||
): SlackBlock[] {
|
||||
const initialState = {
|
||||
@@ -236,7 +236,7 @@ export function buildSlackInteractiveBlocks(
|
||||
buttonIndex: options.buttonIndexOffset ?? 0,
|
||||
selectIndex: options.selectIndexOffset ?? 0,
|
||||
};
|
||||
return reduceInteractiveReply(interactive, initialState, (state, block) => {
|
||||
return reduceLegacyInteractiveReply(interactive, initialState, (state, block) => {
|
||||
if (block.type === "text") {
|
||||
const trimmed = block.text.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Drives the real dispatch wiring (dispatchPreparedSlackMessage → deliverSlackPayload
|
||||
// → native stream / draft preview / preview finalize / deliverReplies → sendMessageSlack)
|
||||
// with the core agent turn mocked at the dispatchReplyWithBufferedBlockDispatcher seam:
|
||||
// with the core agent turn mocked at the channel-inbound dispatch seam:
|
||||
// the scripted steps stand in for the reply dispatcher callbacks (typing, partials,
|
||||
// tool progress, per-payload deliver). OUT events are the Slack Web API calls observed
|
||||
// at a recording WebClient stand-in. Native streaming runs through the REAL
|
||||
@@ -90,33 +90,35 @@ const traceState = vi.hoisted(
|
||||
// deliver/typing/replyOptions wiring (dedupe, thread plan, native stream ladder,
|
||||
// draft preview, preview finalize, deliverReplies chunking, sendMessageSlack)
|
||||
// stays the real production code.
|
||||
vi.mock("./monitor/reply.runtime.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./monitor/reply.runtime.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithBufferedBlockDispatcher: async (params: {
|
||||
dispatcherOptions: unknown;
|
||||
replyOptions?: unknown;
|
||||
}) => {
|
||||
dispatchChannelInboundTurn: async (params: DispatchParams) => {
|
||||
traceState.turn = {
|
||||
options: params.dispatcherOptions as CapturedDispatcherOptions,
|
||||
options: {
|
||||
...params.dispatcherOptions,
|
||||
deliver: params.delivery.deliver,
|
||||
onError: params.delivery.onError,
|
||||
} as CapturedDispatcherOptions,
|
||||
replyOptions: (params.replyOptions ?? {}) as CapturedReplyOptions,
|
||||
};
|
||||
traceState.turnStarted?.resolve();
|
||||
if (!traceState.turnOutcome) {
|
||||
throw new Error("trace turn outcome gate not initialized");
|
||||
}
|
||||
return await traceState.turnOutcome.promise;
|
||||
return {
|
||||
admission: { kind: "dispatch" },
|
||||
dispatched: true,
|
||||
ctxPayload: params.ctxPayload,
|
||||
routeSessionKey: params.route.sessionKey,
|
||||
dispatchResult: await traceState.turnOutcome.promise,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Session-store recording is not wire behavior; keep the turn hermetic.
|
||||
vi.mock("./monitor/conversation.runtime.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./monitor/conversation.runtime.js")>();
|
||||
return { ...actual, recordInboundSession: async () => {} };
|
||||
});
|
||||
|
||||
// send.ts/actions.ts build their own WebClient from tokens; route every client
|
||||
// resolution to the scenario's recording client so all wire calls are captured.
|
||||
vi.mock("./client.js", async (importOriginal) => {
|
||||
@@ -138,8 +140,7 @@ vi.mock("./client.js", async (importOriginal) => {
|
||||
import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js";
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./monitor/reply.runtime.js");
|
||||
vi.doUnmock("./monitor/conversation.runtime.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/channel-inbound");
|
||||
vi.doUnmock("./client.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
normalizeInteractiveReply,
|
||||
normalizeLegacyInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
||||
@@ -88,7 +88,7 @@ export async function handleSlackMessageAction(params: {
|
||||
});
|
||||
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
|
||||
const presentation = normalizeMessagePresentation(actionParams.presentation);
|
||||
const interactive = normalizeInteractiveReply(actionParams.interactive);
|
||||
const interactive = normalizeLegacyInteractiveReply(actionParams.interactive);
|
||||
const hasStructuredContent = Boolean(presentation || interactive?.blocks.length);
|
||||
const resolution = resolveSlackReplyBlockResolution(
|
||||
{
|
||||
|
||||
@@ -295,23 +295,16 @@ vi.mock("./monitor/config.runtime.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./monitor/reply.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor/reply.runtime.js")>(
|
||||
"./monitor/reply.runtime.js",
|
||||
);
|
||||
type BufferedDispatchParams = Parameters<
|
||||
typeof actual.dispatchReplyWithBufferedBlockDispatcher
|
||||
>[0];
|
||||
type ReplyResolver = NonNullable<BufferedDispatchParams["replyResolver"]>;
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
|
||||
type ReplyResolver = NonNullable<DispatchParams["replyResolver"]>;
|
||||
const replyResolver: ReplyResolver = (...args) =>
|
||||
slackTestState.replyMock(...args) as ReturnType<ReplyResolver>;
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithBufferedBlockDispatcher: (params: BufferedDispatchParams) =>
|
||||
actual.dispatchReplyWithBufferedBlockDispatcher({
|
||||
...params,
|
||||
replyResolver,
|
||||
}),
|
||||
dispatchChannelInboundTurn: (params: DispatchParams) =>
|
||||
actual.dispatchChannelInboundTurn({ ...params, replyResolver }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -349,7 +342,6 @@ vi.mock("./monitor/conversation.runtime.js", async () => {
|
||||
...actual,
|
||||
readChannelAllowFromStore: (...args: unknown[]) =>
|
||||
slackTestState.readAllowFromStoreMock(...args),
|
||||
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||
slackTestState.upsertPairingRequestMock(...args),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
export {
|
||||
buildPluginBindingResolvedText,
|
||||
parsePluginBindingApprovalCustomId,
|
||||
recordInboundSession,
|
||||
resolveConversationLabel,
|
||||
resolvePluginConversationBindingApproval,
|
||||
upsertChannelPairingRequest,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Slack tests cover dispatch.preview fallback plugin behavior.
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const FINAL_REPLY_TEXT = "final answer";
|
||||
@@ -13,7 +14,6 @@ const finalizeSlackPreviewEditMock = vi.fn(async () => {});
|
||||
const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim());
|
||||
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||||
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||||
const recordInboundSessionMock = vi.fn(async () => undefined);
|
||||
const recordSlackThreadParticipationMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn(async () => {});
|
||||
const appendSlackStreamMock = vi.fn(async () => {});
|
||||
@@ -518,10 +518,6 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
|
||||
removeAckReactionAfterReply: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../conversation.runtime.js", () => ({
|
||||
recordInboundSession: recordInboundSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
|
||||
return {
|
||||
@@ -1018,361 +1014,134 @@ vi.mock("../replies.js", () => ({
|
||||
resolveSlackThreadTs: () => mockedReplyThreadTs,
|
||||
}));
|
||||
|
||||
vi.mock("../reply.runtime.js", () => ({
|
||||
createReplyDispatcherWithTyping: (params: {
|
||||
transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null;
|
||||
beforeDeliver?: (
|
||||
payload: TestReplyPayload,
|
||||
info: { kind: TestReplyDispatchKind },
|
||||
) => Promise<TestReplyPayload | null> | TestReplyPayload | null;
|
||||
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>;
|
||||
}) => ({
|
||||
dispatcher: {
|
||||
deliver: async (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => {
|
||||
const transformed = params.transformReplyPayload
|
||||
? params.transformReplyPayload(payload)
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
|
||||
return {
|
||||
...actual,
|
||||
dispatchChannelInboundTurn: async (params: DispatchParams) => {
|
||||
capturedReplyOptions = params.replyOptions as typeof capturedReplyOptions;
|
||||
if (mockedReplyOptionEvents.length > 0) {
|
||||
for (const entry of mockedReplyOptionEvents) {
|
||||
if (entry.kind === "item") {
|
||||
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 payload = entry.payload as ReplyPayload;
|
||||
const transformed = params.dispatcherOptions?.transformReplyPayload
|
||||
? params.dispatcherOptions.transformReplyPayload(payload)
|
||||
: payload;
|
||||
if (!transformed) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
const deliverPayload = params.beforeDeliver
|
||||
? await params.beforeDeliver(transformed, info)
|
||||
const deliverPayload = params.dispatcherOptions?.beforeDeliver
|
||||
? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind })
|
||||
: transformed;
|
||||
if (!deliverPayload) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
mockedQueuedDispatchCounts[info.kind] += 1;
|
||||
await params.deliver(deliverPayload, info);
|
||||
},
|
||||
mockedQueuedDispatchCounts[entry.kind] += 1;
|
||||
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", () => ({
|
||||
finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock,
|
||||
@@ -1392,7 +1161,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
normalizeSlackOutboundTextMock.mockClear();
|
||||
postMessageMock.mockClear();
|
||||
chatUpdateMock.mockClear();
|
||||
recordInboundSessionMock.mockReset();
|
||||
recordSlackThreadParticipationMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
appendSlackStreamMock.mockReset();
|
||||
@@ -1490,168 +1258,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS });
|
||||
});
|
||||
|
||||
it("passes accepted Slack bot messages through the shared bot loop guard", async () => {
|
||||
const base = {
|
||||
cfg: {
|
||||
channels: {
|
||||
defaults: {
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 1,
|
||||
windowSeconds: 60,
|
||||
cooldownSeconds: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountConfig: { allowBots: true },
|
||||
message: {
|
||||
channel: "C_LOOP_SLACK",
|
||||
bot_id: "B_OTHER",
|
||||
user: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
...base,
|
||||
message: {
|
||||
...base.message,
|
||||
ts: "900.001",
|
||||
event_ts: "900.001",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
...base,
|
||||
message: {
|
||||
...base.message,
|
||||
ts: "900.002",
|
||||
event_ts: "900.002",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restores Slack status reactions when bot loop protection drops a turn", async () => {
|
||||
const base = {
|
||||
cfg: {
|
||||
messages: {
|
||||
statusReactions: { enabled: true },
|
||||
},
|
||||
channels: {
|
||||
defaults: {
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 1,
|
||||
windowSeconds: 60,
|
||||
cooldownSeconds: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountConfig: { allowBots: true },
|
||||
message: {
|
||||
channel: "C_LOOP_SLACK_STATUS",
|
||||
bot_id: "B_OTHER",
|
||||
user: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
...base,
|
||||
message: {
|
||||
...base.message,
|
||||
ts: "910.001",
|
||||
event_ts: "910.001",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (const value of Object.values(statusReactionControllerMock)) {
|
||||
value.mockClear();
|
||||
}
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
...base,
|
||||
ackReactionMessageTs: "910.002",
|
||||
ackReactionPromise: Promise.resolve(true),
|
||||
message: {
|
||||
...base.message,
|
||||
ts: "910.002",
|
||||
event_ts: "910.002",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expect(statusReactionControllerMock.setQueued).toHaveBeenCalledTimes(1);
|
||||
expect(statusReactionControllerMock.restoreInitial).toHaveBeenCalledTimes(1);
|
||||
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("layers Slack channel bot loop overrides over account settings field-by-field", async () => {
|
||||
const base = {
|
||||
cfg: {
|
||||
channels: {
|
||||
defaults: {
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 20,
|
||||
windowSeconds: 1,
|
||||
cooldownSeconds: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountConfig: {
|
||||
allowBots: true,
|
||||
botLoopProtection: {
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
},
|
||||
},
|
||||
channelConfig: {
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 1,
|
||||
},
|
||||
},
|
||||
message: {
|
||||
channel: "C_LOOP_SLACK_LAYERED",
|
||||
bot_id: "B_OTHER_LAYERED",
|
||||
user: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
...base,
|
||||
message: {
|
||||
...base.message,
|
||||
ts: "900.001",
|
||||
event_ts: "900.001",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await dispatchPreparedSlackMessage(
|
||||
createPreparedSlackMessage({
|
||||
...base,
|
||||
message: {
|
||||
...base.message,
|
||||
ts: "961.001",
|
||||
event_ts: "961.001",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates non-main DM last-route metadata on the prepared direct session", async () => {
|
||||
mockedPinnedMainDmOwner = "U2";
|
||||
await dispatchPreparedSlackMessage(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type StatusReactionAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import {
|
||||
dispatchChannelInboundReply,
|
||||
dispatchChannelInboundTurn,
|
||||
type InboundReplyRecordOptions,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
@@ -89,7 +89,6 @@ import { resolveSlackThreadTargets } from "../../threading.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
|
||||
import { recordInboundSession } from "../conversation.runtime.js";
|
||||
import { escapeSlackMrkdwn } from "../mrkdwn.js";
|
||||
import {
|
||||
createSlackReplyDeliveryPlan,
|
||||
@@ -98,7 +97,6 @@ import {
|
||||
resolveDeliveredSlackReplyThreadTs,
|
||||
resolveSlackThreadTs,
|
||||
} from "../replies.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../reply.runtime.js";
|
||||
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
|
||||
import { resolveSlackTimestampMs } from "./timestamp.js";
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
@@ -2074,16 +2072,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
let queuedFinal = false;
|
||||
let counts: Partial<Record<ReplyDispatchKind, number>> = {};
|
||||
try {
|
||||
const turnResult = await dispatchChannelInboundReply({
|
||||
const turnResult = await dispatchChannelInboundTurn({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: prepared.turn.storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload: prepared.ctxPayload,
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Slack plugin module implements reply behavior.
|
||||
export { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import {
|
||||
normalizeMessagePresentation,
|
||||
resolveInteractiveTextFallback,
|
||||
resolveLegacyInteractiveTextFallback,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
@@ -299,7 +299,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
const payload = {
|
||||
...ctx.payload,
|
||||
text:
|
||||
resolveInteractiveTextFallback({
|
||||
resolveLegacyInteractiveTextFallback({
|
||||
text: ctx.payload.text,
|
||||
interactive: ctx.payload.interactive,
|
||||
}) ?? "",
|
||||
|
||||
@@ -46,7 +46,9 @@ function createRuntime() {
|
||||
messageSid: string;
|
||||
accountSid: string;
|
||||
}) => unknown;
|
||||
resolveTurn: (ingested: unknown) => Promise<{ routeSessionKey: string }>;
|
||||
resolveTurn: (
|
||||
ingested: unknown,
|
||||
) => Promise<{ route: { agentId: string; sessionKey: string } }>;
|
||||
};
|
||||
}) => void
|
||||
>();
|
||||
@@ -172,6 +174,6 @@ describe("dispatchSmsInboundEvent", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(turn.routeSessionKey).toBe("agent:main:sms:direct:+15551234567");
|
||||
expect(turn.route.sessionKey).toBe("agent:main:sms:direct:+15551234567");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,23 +170,12 @@ export async function dispatchSmsInboundEvent(params: {
|
||||
To: params.msg.to,
|
||||
},
|
||||
});
|
||||
const storePath = params.channelRuntime.session.resolveStorePath(
|
||||
params.cfg.session?.store,
|
||||
{
|
||||
agentId: route.agentId,
|
||||
},
|
||||
);
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: params.channelRuntime.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: () => ({
|
||||
to: from,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () =
|
||||
);
|
||||
|
||||
export const dispatchReplyWithBufferedBlockDispatcher: Mock<
|
||||
() => Promise<{ counts: Record<string, number> }>
|
||||
(_params: unknown) => Promise<{ counts: Record<string, number> }>
|
||||
> = vi.fn().mockResolvedValue({ counts: {} });
|
||||
export const finalizeInboundContextMock: Mock<
|
||||
(ctx: Record<string, unknown>) => Record<string, unknown>
|
||||
@@ -152,7 +152,7 @@ vi.mock("./runtime.js", () => ({
|
||||
kind: "message",
|
||||
canStartAgentTurn: true,
|
||||
});
|
||||
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
|
||||
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: resolved.ctxPayload,
|
||||
cfg: mockRuntimeConfig,
|
||||
dispatcherOptions: {
|
||||
@@ -166,7 +166,7 @@ vi.mock("./runtime.js", () => ({
|
||||
dispatched: true,
|
||||
dispatchResult,
|
||||
ctxPayload: resolved.ctxPayload,
|
||||
routeSessionKey: resolved.routeSessionKey,
|
||||
routeSessionKey: resolved.route.sessionKey,
|
||||
};
|
||||
}),
|
||||
buildContext: buildChannelInboundEventContextMock,
|
||||
|
||||
@@ -125,20 +125,15 @@ export async function dispatchSynologyChatInboundEvent(params: {
|
||||
CommandAuthorized: params.msg.commandAuthorized,
|
||||
},
|
||||
});
|
||||
const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, {
|
||||
agentId: resolved.route.agentId,
|
||||
});
|
||||
return {
|
||||
cfg: currentCfg,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: params.account.accountId,
|
||||
agentId: resolved.route.agentId,
|
||||
routeSessionKey: resolved.route.sessionKey,
|
||||
storePath,
|
||||
route: {
|
||||
agentId: resolved.route.agentId,
|
||||
sessionKey: resolved.route.sessionKey,
|
||||
},
|
||||
ctxPayload: msgCtx,
|
||||
recordInboundSession: resolved.rt.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: () => ({
|
||||
to: sendUserId,
|
||||
|
||||
@@ -95,12 +95,14 @@ export async function runTelegramDispatchTurn(params: {
|
||||
raw: context,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
accountId: context.route.accountId,
|
||||
routeSessionKey: context.route.sessionKey,
|
||||
storePath: context.turn.storePath,
|
||||
route: {
|
||||
agentId: context.route.agentId,
|
||||
sessionKey: context.route.sessionKey,
|
||||
},
|
||||
ctxPayload: context.ctxPayload,
|
||||
recordInboundSession: context.turn.recordInboundSession,
|
||||
record: context.turn.record,
|
||||
runDispatch: () =>
|
||||
params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
||||
|
||||
@@ -165,6 +165,43 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
|
||||
type TestTurn = {
|
||||
storePath: string;
|
||||
recordInboundSession: Parameters<
|
||||
typeof actual.runPreparedInboundReply
|
||||
>[0]["recordInboundSession"];
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
runChannelInboundEvent: (params: RunParams) => {
|
||||
const resolveTurn = params.adapter.resolveTurn;
|
||||
return actual.runChannelInboundEvent({
|
||||
...params,
|
||||
adapter: {
|
||||
...params.adapter,
|
||||
resolveTurn: async (input, eventClass, preflight) => {
|
||||
const resolved = await resolveTurn(input, eventClass, preflight);
|
||||
if (!("route" in resolved) || !("runDispatch" in resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
const { route, ...turn } = resolved;
|
||||
const testTurn = (params.raw as { turn: TestTurn }).turn;
|
||||
return {
|
||||
...turn,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: testTurn.storePath,
|
||||
recordInboundSession: testTurn.recordInboundSession,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Telegram plugin module implements button types behavior.
|
||||
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { reduceLegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import {
|
||||
isMessagePresentationInteractiveBlock,
|
||||
normalizeMessagePresentation,
|
||||
normalizeInteractiveReply,
|
||||
normalizeLegacyInteractiveReply,
|
||||
resolveMessagePresentationButtonAction,
|
||||
type InteractiveReply,
|
||||
type LegacyInteractiveReply,
|
||||
type MessagePresentation,
|
||||
type MessagePresentationButton,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
@@ -100,9 +100,9 @@ function chunkInteractiveButtons(
|
||||
* @deprecated Use buildTelegramPresentationButtons with MessagePresentation.
|
||||
*/
|
||||
function buildTelegramInteractiveButtons(
|
||||
interactive?: InteractiveReply,
|
||||
interactive?: LegacyInteractiveReply,
|
||||
): TelegramInlineButtons | undefined {
|
||||
const rows = reduceInteractiveReply(
|
||||
const rows = reduceLegacyInteractiveReply(
|
||||
interactive,
|
||||
[] as TelegramInlineButton[][],
|
||||
(state, block) => {
|
||||
@@ -159,7 +159,7 @@ export function resolveTelegramInlineButtons(params: {
|
||||
}): TelegramInlineButtons | undefined {
|
||||
return (
|
||||
params.buttons ??
|
||||
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) ??
|
||||
buildTelegramInteractiveButtons(normalizeLegacyInteractiveReply(params.interactive)) ??
|
||||
buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Telegram plugin module implements interactive fallback behavior.
|
||||
import {
|
||||
adaptMessagePresentationForChannel,
|
||||
interactiveReplyToPresentation,
|
||||
legacyInteractiveReplyToPresentation,
|
||||
isMessagePresentationInteractiveBlock,
|
||||
normalizeMessagePresentation,
|
||||
normalizeInteractiveReply,
|
||||
normalizeLegacyInteractiveReply,
|
||||
renderMessagePresentationFallbackText,
|
||||
resolveInteractiveTextFallback,
|
||||
resolveLegacyInteractiveTextFallback,
|
||||
type MessagePresentation,
|
||||
type MessagePresentationInteractiveBlock,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
@@ -122,7 +122,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
|
||||
capabilities: TELEGRAM_PRESENTATION_CAPABILITIES,
|
||||
});
|
||||
|
||||
const interactive = normalizeInteractiveReply(payload.interactive);
|
||||
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
|
||||
const existingButtons = resolveTelegramInlineButtons({
|
||||
buttons: telegramData?.buttons,
|
||||
interactive,
|
||||
@@ -141,7 +141,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
|
||||
presentation: { ...presentation, blocks: fallbackBlocks },
|
||||
});
|
||||
const currentText =
|
||||
resolveInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
|
||||
resolveLegacyInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
|
||||
const hasFallback =
|
||||
fallbackText.length > 0 &&
|
||||
(currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`));
|
||||
@@ -168,8 +168,8 @@ export function resolveTelegramInteractiveTextFallback(params: {
|
||||
interactive?: unknown;
|
||||
presentation?: unknown;
|
||||
}): string | undefined {
|
||||
const interactive = normalizeInteractiveReply(params.interactive);
|
||||
const text = resolveInteractiveTextFallback({
|
||||
const interactive = normalizeLegacyInteractiveReply(params.interactive);
|
||||
const text = resolveLegacyInteractiveTextFallback({
|
||||
text: params.text ?? undefined,
|
||||
interactive,
|
||||
});
|
||||
@@ -189,7 +189,7 @@ export function resolveTelegramInteractiveTextFallback(params: {
|
||||
if (!interactive) {
|
||||
return text;
|
||||
}
|
||||
const interactivePresentation = interactiveReplyToPresentation(interactive);
|
||||
const interactivePresentation = legacyInteractiveReplyToPresentation(interactive);
|
||||
if (!interactivePresentation) {
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Telegram plugin module implements voice behavior.
|
||||
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||
|
||||
function resolveTelegramVoiceDecision(opts: {
|
||||
wantsVoice: boolean;
|
||||
@@ -9,7 +9,7 @@ function resolveTelegramVoiceDecision(opts: {
|
||||
if (!opts.wantsVoice) {
|
||||
return { useVoice: false };
|
||||
}
|
||||
if (isVoiceCompatibleAudio(opts)) {
|
||||
if (isVoiceMessageCompatibleAudio(opts)) {
|
||||
return { useVoice: true };
|
||||
}
|
||||
const contentType = opts.contentType ?? "unknown";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Tlon plugin entrypoint registers its OpenClaw integration.
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -502,7 +503,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
bodyWithAttachments = mediaLines + "\n" + messageText;
|
||||
}
|
||||
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
|
||||
channel: "Tlon",
|
||||
from: fromLabel,
|
||||
timestamp,
|
||||
@@ -559,10 +560,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
cfg,
|
||||
route.agentId,
|
||||
).responsePrefix;
|
||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const humanDelay = resolveHumanDelayConfig(cfg, route.agentId);
|
||||
const deliveryTarget = isGroup ? groupChannel : senderShip;
|
||||
|
||||
const prepareReplyPayload = (payload: ReplyPayload): ReplyPayload => {
|
||||
@@ -596,17 +594,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
|
||||
};
|
||||
|
||||
await core.channel.inbound.dispatchReply({
|
||||
await core.channel.inbound.dispatch({
|
||||
channel: "tlon",
|
||||
accountId: route.accountId,
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
preparePayload: prepareReplyPayload,
|
||||
durable: deliveryTarget
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* resolves agent routes, and handles replies.
|
||||
*/
|
||||
|
||||
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
@@ -75,11 +76,10 @@ async function processTwitchMessage(params: {
|
||||
});
|
||||
const senderId = message.userId ?? message.username;
|
||||
const fromLabel = message.displayName ?? message.username;
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
|
||||
channel: "Twitch",
|
||||
from: fromLabel,
|
||||
timestamp: input.timestamp,
|
||||
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
||||
body: input.rawText,
|
||||
});
|
||||
const ctxPayload = core.channel.inbound.buildContext({
|
||||
@@ -113,9 +113,6 @@ async function processTwitchMessage(params: {
|
||||
commandBody: input.textForCommands,
|
||||
},
|
||||
});
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "twitch",
|
||||
@@ -125,13 +122,8 @@ async function processTwitchMessage(params: {
|
||||
cfg,
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: () => ({
|
||||
to: `twitch:channel:${message.channel}`,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AckReactionHandle,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
deriveInboundMessageHookContext,
|
||||
@@ -544,12 +543,11 @@ export async function processMessage(params: {
|
||||
};
|
||||
},
|
||||
resolveTurn: () => ({
|
||||
cfg: params.cfg,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
routeSessionKey: params.route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
params.replyLogger.warn(
|
||||
|
||||
@@ -53,7 +53,6 @@ export {
|
||||
type ReplyPayload,
|
||||
resolveClientIp,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveWebhookPath,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
|
||||
@@ -106,14 +106,6 @@ function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean):
|
||||
describe("Zalo polling media replies", () => {
|
||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const recordInboundSessionMock = vi.fn(async () => undefined);
|
||||
const resolveAgentRouteMock = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "zalo",
|
||||
accountId: "acct-zalo-polling-media",
|
||||
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -143,10 +135,6 @@ describe("Zalo polling media replies", () => {
|
||||
);
|
||||
setLifecycleRuntimeCore(
|
||||
{
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
reply: {
|
||||
finalizeInboundContext:
|
||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
|
||||
@@ -21,14 +21,6 @@ describe("Zalo reply-once lifecycle", () => {
|
||||
const recordInboundSessionMock = vi.fn(
|
||||
async (_input: { sessionKey?: string; ctx?: Record<string, unknown> }) => undefined,
|
||||
);
|
||||
const resolveAgentRouteMock = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "zalo",
|
||||
accountId: "acct-zalo-lifecycle",
|
||||
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -38,10 +30,6 @@ describe("Zalo reply-once lifecycle", () => {
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
setLifecycleRuntimeCore({
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
reply: {
|
||||
finalizeInboundContext:
|
||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
@@ -60,10 +48,17 @@ describe("Zalo reply-once lifecycle", () => {
|
||||
});
|
||||
|
||||
function createReplyOnceMonitorSetup() {
|
||||
return createLifecycleMonitorSetup({
|
||||
const setup = createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-lifecycle",
|
||||
dmPolicy: "open",
|
||||
});
|
||||
return {
|
||||
...setup,
|
||||
config: {
|
||||
...setup.config,
|
||||
session: { dmScope: "per-channel-peer" as const },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecordInboundSessionArgs() {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// Zalo plugin module implements monitor behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
formatInboundMediaUnavailableText,
|
||||
resolveChannelInboundRouteEnvelope,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
type OutboundReplyPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { sleepWithAbort, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -568,7 +570,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
||||
const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
|
||||
const agentBody = agentBodyOverride ?? rawBody;
|
||||
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||
cfg: config,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
@@ -576,8 +578,6 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||
id: chatId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -591,7 +591,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const timestamp = resolveZaloTimestampMs(date);
|
||||
const { storePath, body } = buildEnvelope({
|
||||
const body = buildEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp,
|
||||
@@ -673,17 +673,12 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
||||
},
|
||||
};
|
||||
|
||||
await core.channel.inbound.dispatchReply({
|
||||
await core.channel.inbound.dispatch({
|
||||
cfg: config,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
preparePayload: (payload) =>
|
||||
prepareZaloDurableReplyPayload({
|
||||
|
||||
@@ -63,7 +63,6 @@ export {
|
||||
isNumericTargetId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "./runtime-support.js";
|
||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./runtime-support.js";
|
||||
export { waitForAbortSignal } from "./runtime-support.js";
|
||||
export {
|
||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user