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
|
7e2ff4dedb7b220a133cb419ee91e67585b9b9f2799706f336cdea973be80bfb plugin-sdk-api-baseline.json
|
||||||
660dbaa276792415bf7eb87c19240b19decd400b7e58745e87fe0f1f1cc33353 plugin-sdk-api-baseline.jsonl
|
0a85effc98fb17a65d463956704bcb2dab31076cf0fa0d85b463a765ec9dc439 plugin-sdk-api-baseline.jsonl
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ Matrix inherits global defaults from `session.threadBindings` and supports per-c
|
|||||||
- `threadBindings.idleHours`
|
- `threadBindings.idleHours`
|
||||||
- `threadBindings.maxAgeHours`
|
- `threadBindings.maxAgeHours`
|
||||||
- `threadBindings.spawnSessions`: gates both subagent and ACP thread spawns.
|
- `threadBindings.spawnSessions`: gates both subagent and ACP thread spawns.
|
||||||
- `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions`: narrower overrides for subagent-only or ACP-only spawns.
|
- Deprecated `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions` keys are migrated to `spawnSessions` by `openclaw doctor --fix`.
|
||||||
- `threadBindings.defaultSpawnContext`
|
- `threadBindings.defaultSpawnContext`
|
||||||
|
|
||||||
Matrix thread-bound session spawns default on. Set `threadBindings.spawnSessions: false` to block top-level `/focus` and `/acp spawn --thread auto|here` from creating/binding Matrix threads. Set `threadBindings.defaultSpawnContext: "isolated"` when native subagent thread spawns should not fork the parent transcript.
|
Matrix thread-bound session spawns default on. Set `threadBindings.spawnSessions: false` to block top-level `/focus` and `/acp spawn --thread auto|here` from creating/binding Matrix threads. Set `threadBindings.defaultSpawnContext: "isolated"` when native subagent thread spawns should not fork the parent transcript.
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ describe("handleClickClackInbound", () => {
|
|||||||
correlationId: "fakeco.case_1",
|
correlationId: "fakeco.case_1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
|
||||||
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
|
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
|
||||||
const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0];
|
const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0];
|
||||||
expect(completionRequest?.agentId).toBe("service-bot");
|
expect(completionRequest?.agentId).toBe("service-bot");
|
||||||
@@ -270,9 +270,9 @@ describe("handleClickClackInbound", () => {
|
|||||||
message: createMessage(),
|
message: createMessage(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||||
expect(dispatchReply).toHaveBeenCalledTimes(1);
|
expect(dispatchTurn).toHaveBeenCalledTimes(1);
|
||||||
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("propagates account toolsAllow into agent reply dispatch", async () => {
|
it("propagates account toolsAllow into agent reply dispatch", async () => {
|
||||||
@@ -297,9 +297,9 @@ describe("handleClickClackInbound", () => {
|
|||||||
message: createMessage(),
|
message: createMessage(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||||
expect(dispatchReply).toHaveBeenCalledTimes(1);
|
expect(dispatchTurn).toHaveBeenCalledTimes(1);
|
||||||
const dispatchParams = dispatchReply.mock.calls[0]?.[0] as
|
const dispatchParams = dispatchTurn.mock.calls[0]?.[0] as
|
||||||
| (Record<string, unknown> & {
|
| (Record<string, unknown> & {
|
||||||
toolsAllow?: unknown;
|
toolsAllow?: unknown;
|
||||||
})
|
})
|
||||||
@@ -335,12 +335,12 @@ describe("handleClickClackInbound", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||||
expect(dispatchReply).toHaveBeenCalledTimes(2);
|
expect(dispatchTurn).toHaveBeenCalledTimes(2);
|
||||||
const withoutOptIn = dispatchReply.mock.calls[0]?.[0] as {
|
const withoutOptIn = dispatchTurn.mock.calls[0]?.[0] as {
|
||||||
replyOptions?: { runId?: unknown; onItemEvent?: unknown; onModelSelected?: unknown };
|
replyOptions?: { runId?: unknown; onItemEvent?: unknown; onModelSelected?: unknown };
|
||||||
};
|
};
|
||||||
const withOptIn = dispatchReply.mock.calls[1]?.[0] as {
|
const withOptIn = dispatchTurn.mock.calls[1]?.[0] as {
|
||||||
replyOptions?: {
|
replyOptions?: {
|
||||||
onItemEvent?: unknown;
|
onItemEvent?: unknown;
|
||||||
onModelSelected?: unknown;
|
onModelSelected?: unknown;
|
||||||
@@ -377,7 +377,7 @@ describe("handleClickClackInbound", () => {
|
|||||||
correlationId: "fakeco.case_2",
|
correlationId: "fakeco.case_2",
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchParams = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0];
|
const dispatchParams = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0];
|
||||||
expect(dispatchParams?.replyOptions?.runId).toBe(`clickclack:${VALID_MESSAGE_ID}`);
|
expect(dispatchParams?.replyOptions?.runId).toBe(`clickclack:${VALID_MESSAGE_ID}`);
|
||||||
|
|
||||||
await dispatchParams?.delivery.deliver({ text: "correlated reply" }, {} as never);
|
await dispatchParams?.delivery.deliver({ text: "correlated reply" }, {} as never);
|
||||||
@@ -404,7 +404,7 @@ describe("handleClickClackInbound", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const delivery = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].delivery;
|
const delivery = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].delivery;
|
||||||
if (typeof delivery?.durable !== "function") {
|
if (typeof delivery?.durable !== "function") {
|
||||||
throw new Error("expected ClickClack media durable delivery resolver");
|
throw new Error("expected ClickClack media durable delivery resolver");
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ describe("handleClickClackInbound", () => {
|
|||||||
message: createMessage({ id: "msg_invalid" }),
|
message: createMessage({ id: "msg_invalid" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].replyOptions).toBe(
|
expect(vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].replyOptions).toBe(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -461,15 +461,15 @@ describe("handleClickClackInbound", () => {
|
|||||||
}),
|
}),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
message: createMessage({
|
message: createMessage({
|
||||||
channel_id: undefined,
|
channel_id: "",
|
||||||
direct_conversation_id: "dcn_1",
|
direct_conversation_id: "dcn_1",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||||
expect(dispatchReply).toHaveBeenCalledTimes(1);
|
expect(dispatchTurn).toHaveBeenCalledTimes(1);
|
||||||
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
|
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
|
||||||
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves session policy when an account overrides the routed agent", async () => {
|
it("preserves session policy when an account overrides the routed agent", async () => {
|
||||||
@@ -503,8 +503,8 @@ describe("handleClickClackInbound", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||||
expect(dispatchReply.mock.calls[0]?.[0].routeSessionKey).toBe(
|
expect(dispatchTurn.mock.calls[0]?.[0].route.sessionKey).toBe(
|
||||||
"agent:service-bot:clickclack:direct:alice",
|
"agent:service-bot:clickclack:direct:alice",
|
||||||
);
|
);
|
||||||
expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({
|
expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({
|
||||||
@@ -546,10 +546,12 @@ describe("handleClickClackInbound", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
|
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
|
||||||
expect(dispatchReply.mock.calls[0]?.[0]).toMatchObject({
|
expect(dispatchTurn.mock.calls[0]?.[0]).toMatchObject({
|
||||||
agentId: "service-bot",
|
route: {
|
||||||
routeSessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner",
|
agentId: "service-bot",
|
||||||
|
sessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -584,7 +586,7 @@ describe("handleClickClackInbound", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
|
||||||
expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
|
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
|
||||||
/**
|
/**
|
||||||
* Converts authorized ClickClack messages into OpenClaw agent/model replies and
|
* Converts authorized ClickClack messages into OpenClaw agent/model replies and
|
||||||
@@ -150,6 +151,10 @@ export async function handleClickClackInbound(params: {
|
|||||||
if (!access.shouldDispatch) {
|
if (!access.shouldDispatch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const conversationId = message.channel_id || message.direct_conversation_id;
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isDirect = Boolean(message.direct_conversation_id);
|
const isDirect = Boolean(message.direct_conversation_id);
|
||||||
const target = buildClickClackTarget(
|
const target = buildClickClackTarget(
|
||||||
isDirect
|
isDirect
|
||||||
@@ -200,52 +205,53 @@ export async function handleClickClackInbound(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const senderName = message.author?.display_name || message.author_id;
|
const senderName = message.author?.display_name || message.author_id;
|
||||||
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
|
||||||
storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
}),
|
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
});
|
|
||||||
// Preserve both normalized channel fields and ClickClack-native ids so reply
|
// Preserve both normalized channel fields and ClickClack-native ids so reply
|
||||||
// routing, session recovery, and command authorization see the same message.
|
// routing, session recovery, and command authorization see the same message.
|
||||||
const body = runtime.channel.reply.formatAgentEnvelope({
|
const body = createChannelInboundEnvelopeBuilder({
|
||||||
|
cfg: params.config as OpenClawConfig,
|
||||||
|
route,
|
||||||
|
})({
|
||||||
channel: "ClickClack",
|
channel: "ClickClack",
|
||||||
from: senderName,
|
from: senderName,
|
||||||
timestamp: new Date(message.created_at),
|
timestamp: new Date(message.created_at),
|
||||||
previousTimestamp,
|
|
||||||
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
|
|
||||||
body: message.body,
|
body: message.body,
|
||||||
});
|
});
|
||||||
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
|
const ctxPayload = runtime.channel.inbound.buildContext({
|
||||||
agentId: route.agentId,
|
channel: CHANNEL_ID,
|
||||||
});
|
accountId: route.accountId ?? params.account.accountId,
|
||||||
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
messageId: message.id,
|
||||||
Body: body,
|
messageIdFull: message.id,
|
||||||
BodyForAgent: message.body,
|
timestamp: new Date(message.created_at).getTime(),
|
||||||
RawBody: message.body,
|
from: target,
|
||||||
CommandBody: message.body,
|
sender: { id: message.author_id, name: senderName },
|
||||||
From: target,
|
conversation: {
|
||||||
To: target,
|
kind: isDirect ? "direct" : "group",
|
||||||
SessionKey: route.sessionKey,
|
id: conversationId,
|
||||||
AccountId: route.accountId ?? params.account.accountId,
|
label: isDirect ? senderName : message.channel_id,
|
||||||
ChatType: isDirect ? "direct" : "group",
|
threadId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||||
WasMentioned: isDirect ? undefined : true,
|
nativeChannelId: conversationId,
|
||||||
ConversationLabel: isDirect ? senderName : message.channel_id,
|
},
|
||||||
GroupChannel: message.channel_id,
|
route: {
|
||||||
NativeChannelId: message.channel_id || message.direct_conversation_id,
|
agentId: route.agentId,
|
||||||
MessageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
|
accountId: route.accountId,
|
||||||
ThreadParentId: message.parent_message_id ? message.thread_root_id : undefined,
|
routeSessionKey: route.sessionKey,
|
||||||
SenderName: senderName,
|
},
|
||||||
SenderId: message.author_id,
|
reply: {
|
||||||
Provider: CHANNEL_ID,
|
to: target,
|
||||||
Surface: CHANNEL_ID,
|
originatingTo: target,
|
||||||
MessageSid: message.id,
|
replyToId: message.id,
|
||||||
MessageSidFull: message.id,
|
messageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||||
ReplyToId: message.id,
|
threadParentId: message.parent_message_id ? message.thread_root_id : undefined,
|
||||||
Timestamp: message.created_at,
|
},
|
||||||
OriginatingChannel: CHANNEL_ID,
|
message: { body, bodyForAgent: message.body, rawBody: message.body, commandBody: message.body },
|
||||||
OriginatingTo: target,
|
access: {
|
||||||
CommandAuthorized: access.commandAuthorized,
|
commands: { authorized: access.commandAuthorized },
|
||||||
|
mentions: {
|
||||||
|
canDetectMention: !isDirect,
|
||||||
|
wasMentioned: !isDirect,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extra: { GroupChannel: message.channel_id },
|
||||||
});
|
});
|
||||||
const runId = resolveClickClackAgentRunId(message.id);
|
const runId = resolveClickClackAgentRunId(message.id);
|
||||||
const activityReplyOptions = activity
|
const activityReplyOptions = activity
|
||||||
@@ -266,17 +272,12 @@ export async function handleClickClackInbound(params: {
|
|||||||
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const dispatchPromise = runtime.channel.inbound.dispatchReply({
|
const dispatchPromise = runtime.channel.inbound.dispatch({
|
||||||
cfg: params.config as OpenClawConfig,
|
cfg: params.config as OpenClawConfig,
|
||||||
channel: CHANNEL_ID,
|
channel: CHANNEL_ID,
|
||||||
accountId: params.account.accountId,
|
accountId: params.account.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
toolsAllow: params.account.toolsAllow,
|
toolsAllow: params.account.toolsAllow,
|
||||||
// Provenance stamping shares the agentActivity opt-in: with the flag off
|
// Provenance stamping shares the agentActivity opt-in: with the flag off
|
||||||
// the extension's wire payloads stay byte-identical to pre-activity
|
// the extension's wire payloads stay byte-identical to pre-activity
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function createCodexAppServerAgentHarness(options: {
|
|||||||
delegatedExecutionPluginIds: ["voice-call"],
|
delegatedExecutionPluginIds: ["voice-call"],
|
||||||
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
|
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
|
||||||
deliveryDefaults: {
|
deliveryDefaults: {
|
||||||
sourceVisibleReplies: "message_tool",
|
visibleReplies: "message_tool",
|
||||||
},
|
},
|
||||||
authBootstrap: "harness",
|
authBootstrap: "harness",
|
||||||
authBinding: {
|
authBinding: {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ describe("codex plugin", () => {
|
|||||||
expect(agentHarnessRegistration.id).toBe("codex");
|
expect(agentHarnessRegistration.id).toBe("codex");
|
||||||
expect(agentHarnessRegistration.label).toBe("Codex agent harness");
|
expect(agentHarnessRegistration.label).toBe("Codex agent harness");
|
||||||
expect(agentHarnessRegistration.deliveryDefaults).toEqual({
|
expect(agentHarnessRegistration.deliveryDefaults).toEqual({
|
||||||
sourceVisibleReplies: "message_tool",
|
visibleReplies: "message_tool",
|
||||||
});
|
});
|
||||||
expect(typeof agentHarnessRegistration.dispose).toBe("function");
|
expect(typeof agentHarnessRegistration.dispose).toBe("function");
|
||||||
expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function");
|
expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function");
|
||||||
@@ -419,7 +419,7 @@ describe("codex plugin", () => {
|
|||||||
bindingStore: testCodexAppServerBindingStore,
|
bindingStore: testCodexAppServerBindingStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
|
expect(harness.deliveryDefaults?.visibleReplies).toBe("message_tool");
|
||||||
expect(
|
expect(
|
||||||
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
|
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
|
||||||
.supported,
|
.supported,
|
||||||
|
|||||||
@@ -153,9 +153,7 @@ export function createCodexAttemptLifecycleController(
|
|||||||
startedAt: attemptStartedAt,
|
startedAt: attemptStartedAt,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
...data,
|
...data,
|
||||||
...((params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd)
|
...(params.deferTerminalLifecycle ? { phase: "finishing" } : {}),
|
||||||
? { phase: "finishing" }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
state.lifecycleTerminalEmitted = true;
|
state.lifecycleTerminalEmitted = true;
|
||||||
|
|||||||
@@ -51,25 +51,15 @@ setupRunAttemptTestHooks();
|
|||||||
|
|
||||||
describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ label: "completed", status: "completed" as const, error: undefined, legacy: false },
|
{ label: "completed", status: "completed" as const, error: undefined },
|
||||||
{ label: "failed", status: "failed" as const, error: "codex exploded", legacy: false },
|
{ label: "failed", status: "failed" as const, error: "codex exploded" },
|
||||||
{
|
])("defers $label lifecycle terminal ownership", async ({ status, error }) => {
|
||||||
label: "completed legacy alias",
|
|
||||||
status: "completed" as const,
|
|
||||||
error: undefined,
|
|
||||||
legacy: true,
|
|
||||||
},
|
|
||||||
])("defers $label lifecycle terminal ownership", async ({ status, error, legacy }) => {
|
|
||||||
const onRunAgentEvent = vi.fn();
|
const onRunAgentEvent = vi.fn();
|
||||||
const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`);
|
const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`);
|
||||||
const workspaceDir = path.join(tempDir, `workspace-${status}`);
|
const workspaceDir = path.join(tempDir, `workspace-${status}`);
|
||||||
const harness = createStartedThreadHarness();
|
const harness = createStartedThreadHarness();
|
||||||
const params = createParams(sessionFile, workspaceDir);
|
const params = createParams(sessionFile, workspaceDir);
|
||||||
if (legacy) {
|
params.deferTerminalLifecycle = true;
|
||||||
params.deferTerminalLifecycleEnd = true;
|
|
||||||
} else {
|
|
||||||
params.deferTerminalLifecycle = true;
|
|
||||||
}
|
|
||||||
params.onAgentEvent = onRunAgentEvent;
|
params.onAgentEvent = onRunAgentEvent;
|
||||||
const run = runCodexAppServerAttempt(params);
|
const run = runCodexAppServerAttempt(params);
|
||||||
await harness.waitForMethod("turn/start");
|
await harness.waitForMethod("turn/start");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
|
|||||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||||
import {
|
import {
|
||||||
adaptMessagePresentationForChannel,
|
adaptMessagePresentationForChannel,
|
||||||
normalizeInteractiveReply,
|
normalizeLegacyInteractiveReply,
|
||||||
normalizeMessagePresentation,
|
normalizeMessagePresentation,
|
||||||
renderMessagePresentationFallbackText,
|
renderMessagePresentationFallbackText,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
@@ -153,7 +153,7 @@ export async function handleDiscordMessageAction(
|
|||||||
? undefined
|
? undefined
|
||||||
: (params.components ??
|
: (params.components ??
|
||||||
presentationComponents ??
|
presentationComponents ??
|
||||||
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive)));
|
buildDiscordInteractiveComponents(normalizeLegacyInteractiveReply(params.interactive)));
|
||||||
const hasComponents =
|
const hasComponents =
|
||||||
Boolean(rawComponents) &&
|
Boolean(rawComponents) &&
|
||||||
(typeof rawComponents === "function" || typeof rawComponents === "object");
|
(typeof rawComponents === "function" || typeof rawComponents === "object");
|
||||||
|
|||||||
@@ -178,11 +178,9 @@ export async function dispatchDiscordComponentEvent(params: {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
createReplyReferencePlanner,
|
createReplyReferencePlanner,
|
||||||
dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
resolveTextChunkLimit,
|
resolveTextChunkLimit,
|
||||||
recordInboundSession,
|
|
||||||
} = await (async () => {
|
} = await (async () => {
|
||||||
const conversationRuntime = await loadConversationRuntime();
|
const conversationRuntime = await loadConversationRuntime();
|
||||||
return {
|
return {
|
||||||
@@ -273,12 +271,8 @@ export async function dispatchDiscordComponentEvent(params: {
|
|||||||
cfg: ctx.cfg,
|
cfg: ctx.cfg,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId,
|
accountId,
|
||||||
agentId,
|
route: { agentId, sessionKey },
|
||||||
routeSessionKey: sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
record: {
|
record: {
|
||||||
updateLastRoute: interactionCtx.isDirectMessage
|
updateLastRoute: interactionCtx.isDirectMessage
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -369,6 +369,55 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||||
|
const replyRuntime = await import("openclaw/plugin-sdk/reply-runtime");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
dispatchChannelInboundTurn: async (
|
||||||
|
plan: Parameters<typeof actual.dispatchChannelInboundTurn>[0],
|
||||||
|
) => {
|
||||||
|
const { cfg, route, delivery, sessionInitRetry, ...prepared } = plan;
|
||||||
|
const runDispatch = async () => {
|
||||||
|
for (let retryIndex = 0; ; retryIndex += 1) {
|
||||||
|
try {
|
||||||
|
return await replyRuntime.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: plan.ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
...plan.dispatcherOptions,
|
||||||
|
deliver: delivery.deliver,
|
||||||
|
onError: delivery.onError,
|
||||||
|
},
|
||||||
|
toolsAllow: plan.toolsAllow,
|
||||||
|
replyOptions: plan.replyOptions,
|
||||||
|
replyResolver: plan.replyResolver,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const delayMs = sessionInitRetry?.delaysMs[retryIndex];
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (
|
||||||
|
delayMs === undefined ||
|
||||||
|
sessionInitRetry?.signal?.aborted === true ||
|
||||||
|
!/^reply session initialization conflicted for \S+$/u.test(message)
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await sessionInitRetry?.sleep?.(delayMs, sessionInitRetry.signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return await actual.runPreparedInboundReply({
|
||||||
|
...prepared,
|
||||||
|
routeSessionKey: route.sessionKey,
|
||||||
|
storePath: resolveStorePath(cfg.session?.store, { agentId: route.agentId }),
|
||||||
|
recordInboundSession,
|
||||||
|
runDispatch,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
||||||
recordInboundSession: (...args: unknown[]) => recordInboundSession(...args),
|
recordInboundSession: (...args: unknown[]) => recordInboundSession(...args),
|
||||||
resolvePinnedMainDmOwnerFromAllowlist: (params: {
|
resolvePinnedMainDmOwnerFromAllowlist: (params: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
shouldAckReaction as shouldAckReactionGate,
|
shouldAckReaction as shouldAckReactionGate,
|
||||||
} from "openclaw/plugin-sdk/channel-feedback";
|
} from "openclaw/plugin-sdk/channel-feedback";
|
||||||
import {
|
import {
|
||||||
dispatchChannelInboundReply,
|
dispatchChannelInboundTurn,
|
||||||
hasFinalInboundReplyDispatch,
|
hasFinalInboundReplyDispatch,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import {
|
import {
|
||||||
@@ -24,8 +24,6 @@ import {
|
|||||||
resolveChannelStreamingBlockEnabled,
|
resolveChannelStreamingBlockEnabled,
|
||||||
resolveTranscriptBackedChannelFinalText,
|
resolveTranscriptBackedChannelFinalText,
|
||||||
} from "openclaw/plugin-sdk/channel-outbound";
|
} from "openclaw/plugin-sdk/channel-outbound";
|
||||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
|
||||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
|
||||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||||
@@ -36,7 +34,13 @@ import {
|
|||||||
resolveSendableOutboundReplyParts,
|
resolveSendableOutboundReplyParts,
|
||||||
} from "openclaw/plugin-sdk/reply-payload";
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { danger, logVerbose, shouldLogVerbose, sleep } from "openclaw/plugin-sdk/runtime-env";
|
import {
|
||||||
|
danger,
|
||||||
|
logVerbose,
|
||||||
|
shouldLogVerbose,
|
||||||
|
sleep,
|
||||||
|
sleepWithAbort,
|
||||||
|
} from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||||
import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||||
@@ -57,15 +61,11 @@ import {
|
|||||||
import { buildDiscordMessageProcessContext } from "./message-handler.context.js";
|
import { buildDiscordMessageProcessContext } from "./message-handler.context.js";
|
||||||
import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js";
|
import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js";
|
||||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||||
import {
|
import { completeDiscordSessionConflict } from "./message-handler.retry.js";
|
||||||
completeDiscordSessionConflict,
|
|
||||||
withDiscordSessionRetry,
|
|
||||||
} from "./message-handler.retry.js";
|
|
||||||
import { deliverDiscordReply, formatDiscordReplyDeliveryFailure } from "./reply-delivery.js";
|
import { deliverDiscordReply, formatDiscordReplyDeliveryFailure } from "./reply-delivery.js";
|
||||||
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
|
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
|
||||||
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
||||||
|
|
||||||
const loadReplyRuntime = createLazyRuntimeModule(() => import("openclaw/plugin-sdk/reply-runtime"));
|
|
||||||
const TARGETED_ONLY_ALLOWED_MENTIONS = {
|
const TARGETED_ONLY_ALLOWED_MENTIONS = {
|
||||||
parse: ["users", "roles"],
|
parse: ["users", "roles"],
|
||||||
} as APIAllowedMentions;
|
} as APIAllowedMentions;
|
||||||
@@ -185,7 +185,6 @@ async function processDiscordMessageInner(
|
|||||||
if (boundThreadId && typeof threadBindings.touchThread === "function") {
|
if (boundThreadId && typeof threadBindings.touchThread === "function") {
|
||||||
threadBindings.touchThread({ threadId: boundThreadId });
|
threadBindings.touchThread({ threadId: boundThreadId });
|
||||||
}
|
}
|
||||||
const { dispatchReplyWithBufferedBlockDispatcher: dispatchReply } = await loadReplyRuntime();
|
|
||||||
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
|
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
|
||||||
cfg,
|
cfg,
|
||||||
ctx: {
|
ctx: {
|
||||||
@@ -991,7 +990,11 @@ async function processDiscordMessageInner(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
||||||
let dispatchResult: Awaited<ReturnType<typeof dispatchReply>> | null = null;
|
let dispatchResult: {
|
||||||
|
queuedFinal: boolean;
|
||||||
|
counts: Record<ReplyDispatchKind, number>;
|
||||||
|
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
|
||||||
|
} | null = null;
|
||||||
let dispatchError = false;
|
let dispatchError = false;
|
||||||
let dispatchAborted = false;
|
let dispatchAborted = false;
|
||||||
const deliverPendingToolWarningFinalIfNeeded = async () => {
|
const deliverPendingToolWarningFinalIfNeeded = async () => {
|
||||||
@@ -1015,17 +1018,18 @@ async function processDiscordMessageInner(
|
|||||||
dispatchAborted = true;
|
dispatchAborted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preparedResult = await dispatchChannelInboundReply({
|
const preparedResult = await dispatchChannelInboundTurn({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: persistedSessionKey },
|
||||||
routeSessionKey: persistedSessionKey,
|
|
||||||
storePath: turn.storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession,
|
|
||||||
afterRecord: queueInitialAckReactionAfterRecord,
|
afterRecord: queueInitialAckReactionAfterRecord,
|
||||||
dispatchReplyWithBufferedBlockDispatcher: withDiscordSessionRetry(dispatchReply, abortSignal),
|
sessionInitRetry: {
|
||||||
|
delaysMs: [250, 1_000, 2_500],
|
||||||
|
signal: abortSignal,
|
||||||
|
sleep: sleepWithAbort,
|
||||||
|
},
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
...replyPipeline,
|
...replyPipeline,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
// Discord plugin module implements narrow inbound dispatch retry behavior.
|
|
||||||
import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
|
||||||
import { DiscordRetryableInboundError } from "./inbound-dedupe.js";
|
import { DiscordRetryableInboundError } from "./inbound-dedupe.js";
|
||||||
|
|
||||||
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /^reply session initialization conflicted for \S+$/u;
|
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /^reply session initialization conflicted for \S+$/u;
|
||||||
const DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS = [250, 1_000, 2_500] as const;
|
|
||||||
const DISCORD_SESSION_CONFLICT_FAILURE_TEXT =
|
const DISCORD_SESSION_CONFLICT_FAILURE_TEXT =
|
||||||
"⚠️ Couldn't process this message because the session stayed busy. Please try again in a moment.";
|
"⚠️ Couldn't process this message because the session stayed busy. Please try again in a moment.";
|
||||||
|
|
||||||
type AsyncDispatch<TParams, TResult> = (params: TParams) => Promise<TResult>;
|
|
||||||
type TerminalFailureDelivery = (
|
type TerminalFailureDelivery = (
|
||||||
payload: { text: string; isError: true },
|
payload: { text: string; isError: true },
|
||||||
info: { kind: "final" },
|
info: { kind: "final" },
|
||||||
@@ -19,63 +15,12 @@ function isReplySessionInitConflictError(error: unknown): boolean {
|
|||||||
return REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(message);
|
return REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
class DiscordReplySessionConflictExhaustedError extends DiscordRetryableInboundError {
|
|
||||||
constructor(message: string, options?: ErrorOptions) {
|
|
||||||
super(message, options);
|
|
||||||
this.name = "DiscordReplySessionConflictExhaustedError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchDiscordReplyWithSessionConflictRetry<T>(params: {
|
|
||||||
dispatch: () => Promise<T>;
|
|
||||||
abortSignal?: AbortSignal;
|
|
||||||
onRetry?: (attempt: number, delayMs: number) => void;
|
|
||||||
}): Promise<T> {
|
|
||||||
for (let retryIndex = 0; ; retryIndex += 1) {
|
|
||||||
try {
|
|
||||||
return await params.dispatch();
|
|
||||||
} catch (error) {
|
|
||||||
if (!isReplySessionInitConflictError(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const delayMs = DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS[retryIndex];
|
|
||||||
if (delayMs === undefined) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
// Let the caller either complete with a visible terminal notice or
|
|
||||||
// reopen replay ownership when that notice cannot land.
|
|
||||||
throw new DiscordReplySessionConflictExhaustedError(
|
|
||||||
`discord: reply session init conflict persisted after shared and channel retries: ${message}`,
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.onRetry?.(retryIndex + 1, delayMs);
|
|
||||||
await sleepWithAbort(delayMs, params.abortSignal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withDiscordSessionRetry<TParams, TResult>(
|
|
||||||
dispatch: AsyncDispatch<TParams, TResult>,
|
|
||||||
abortSignal: AbortSignal | undefined,
|
|
||||||
): AsyncDispatch<TParams, TResult> {
|
|
||||||
return (dispatchParams) =>
|
|
||||||
dispatchDiscordReplyWithSessionConflictRetry({
|
|
||||||
dispatch: () => dispatch(dispatchParams),
|
|
||||||
abortSignal,
|
|
||||||
onRetry: (attempt, delayMs) => {
|
|
||||||
logVerbose(
|
|
||||||
`discord: reply session init conflict; retrying dispatch ${attempt} after ${delayMs}ms`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function completeDiscordSessionConflict(
|
export async function completeDiscordSessionConflict(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
deliver: TerminalFailureDelivery,
|
deliver: TerminalFailureDelivery,
|
||||||
onDeliveryError: DeliveryErrorHandler,
|
onDeliveryError: DeliveryErrorHandler,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!(error instanceof DiscordReplySessionConflictExhaustedError)) {
|
if (!isReplySessionInitConflictError(error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -87,7 +32,10 @@ export async function completeDiscordSessionConflict(
|
|||||||
} catch (deliveryError) {
|
} catch (deliveryError) {
|
||||||
// Keep the conflict retryable when its visible terminal notice cannot land.
|
// Keep the conflict retryable when its visible terminal notice cannot land.
|
||||||
onDeliveryError(deliveryError, { kind: "final" });
|
onDeliveryError(deliveryError, { kind: "final" });
|
||||||
return false;
|
throw new DiscordRetryableInboundError(
|
||||||
|
`discord: reply session init conflict exhausted and terminal notice failed: ${String(deliveryError)}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Discord plugin module implements shared interactive behavior.
|
// Discord plugin module implements shared interactive behavior.
|
||||||
import {
|
import {
|
||||||
reduceInteractiveReply,
|
reduceLegacyInteractiveReply,
|
||||||
resolveMessagePresentationButtonAction,
|
resolveMessagePresentationButtonAction,
|
||||||
resolveMessagePresentationOptionAction,
|
resolveMessagePresentationOptionAction,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import type {
|
import type {
|
||||||
InteractiveButtonStyle,
|
InteractiveButtonStyle,
|
||||||
InteractiveReply,
|
LegacyInteractiveReply,
|
||||||
MessagePresentation,
|
MessagePresentation,
|
||||||
MessagePresentationButton,
|
MessagePresentationButton,
|
||||||
MessagePresentationOption,
|
MessagePresentationOption,
|
||||||
@@ -141,9 +141,9 @@ function appendDiscordButtonBlocks(
|
|||||||
* @deprecated Use buildDiscordPresentationComponents with MessagePresentation.
|
* @deprecated Use buildDiscordPresentationComponents with MessagePresentation.
|
||||||
*/
|
*/
|
||||||
export function buildDiscordInteractiveComponents(
|
export function buildDiscordInteractiveComponents(
|
||||||
interactive?: InteractiveReply,
|
interactive?: LegacyInteractiveReply,
|
||||||
): DiscordComponentMessageSpec | undefined {
|
): DiscordComponentMessageSpec | undefined {
|
||||||
const blocks = reduceInteractiveReply(
|
const blocks = reduceLegacyInteractiveReply(
|
||||||
interactive,
|
interactive,
|
||||||
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
|
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
|
||||||
(state, block) => {
|
(state, block) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Feishu tests cover bot.broadcast plugin behavior.
|
// Feishu tests cover bot.broadcast plugin behavior.
|
||||||
import type { EnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
|
|
||||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
||||||
import { feishuGroupNameCache } from "./bot-group-name-state.js";
|
import { feishuGroupNameCache } from "./bot-group-name-state.js";
|
||||||
@@ -7,25 +6,71 @@ import type { FeishuMessageEvent } from "./bot.js";
|
|||||||
import { handleFeishuMessage } from "./bot.js";
|
import { handleFeishuMessage } from "./bot.js";
|
||||||
import { setFeishuRuntime } from "./runtime.js";
|
import { setFeishuRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } =
|
const {
|
||||||
vi.hoisted(() => ({
|
builtInboundContextCalls,
|
||||||
mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({
|
mockCreateFeishuReplyDispatcher,
|
||||||
dispatcher: {
|
mockCreateFeishuClient,
|
||||||
sendToolResult: vi.fn(),
|
mockDispatchInboundMessage,
|
||||||
sendBlockReply: vi.fn(),
|
mockRecordInboundSession,
|
||||||
sendFinalReply: vi.fn(),
|
mockResolveAgentRoute,
|
||||||
waitForIdle: vi.fn(),
|
mockResolveStorePath,
|
||||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
} = vi.hoisted(() => ({
|
||||||
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
builtInboundContextCalls: [] as Array<Record<string, unknown>>,
|
||||||
markComplete: vi.fn(),
|
mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({
|
||||||
},
|
dispatcher: {
|
||||||
replyOptions: {},
|
sendToolResult: vi.fn(),
|
||||||
markDispatchIdle: vi.fn(),
|
sendBlockReply: vi.fn(),
|
||||||
ensureNoVisibleReplyFallback: vi.fn(),
|
sendFinalReply: vi.fn(),
|
||||||
})),
|
waitForIdle: vi.fn(),
|
||||||
mockCreateFeishuClient: vi.fn(),
|
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||||
mockResolveAgentRoute: vi.fn(),
|
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||||
}));
|
markComplete: vi.fn(),
|
||||||
|
},
|
||||||
|
replyOptions: {},
|
||||||
|
markDispatchIdle: vi.fn(),
|
||||||
|
ensureNoVisibleReplyFallback: vi.fn(),
|
||||||
|
})),
|
||||||
|
mockCreateFeishuClient: vi.fn(),
|
||||||
|
mockDispatchInboundMessage: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }),
|
||||||
|
mockRecordInboundSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockResolveAgentRoute: vi.fn(),
|
||||||
|
mockResolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
|
||||||
|
"openclaw/plugin-sdk/channel-inbound",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
buildChannelInboundEventContext: (
|
||||||
|
params: Parameters<typeof actual.buildChannelInboundEventContext>[0],
|
||||||
|
) =>
|
||||||
|
actual.buildChannelInboundEventContext({
|
||||||
|
...params,
|
||||||
|
finalize: (ctx) => {
|
||||||
|
builtInboundContextCalls.push(ctx);
|
||||||
|
return ctx as never;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
|
||||||
|
"openclaw/plugin-sdk/reply-runtime",
|
||||||
|
);
|
||||||
|
return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
|
||||||
|
"openclaw/plugin-sdk/session-store-runtime",
|
||||||
|
);
|
||||||
|
return { ...actual, resolveStorePath: mockResolveStorePath };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./reply-dispatcher.js", () => ({
|
vi.mock("./reply-dispatcher.js", () => ({
|
||||||
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
||||||
@@ -48,43 +93,7 @@ function createRuntimeEnv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("broadcast dispatch", () => {
|
describe("broadcast dispatch", () => {
|
||||||
const finalizeInboundContextCalls: Array<Record<string, unknown>> = [];
|
|
||||||
const mockGetChatInfo = vi.fn();
|
const mockGetChatInfo = vi.fn();
|
||||||
const mockFinalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"] = (
|
|
||||||
ctx,
|
|
||||||
) => {
|
|
||||||
finalizeInboundContextCalls.push(ctx);
|
|
||||||
return {
|
|
||||||
...ctx,
|
|
||||||
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
|
|
||||||
CommandTurn: {
|
|
||||||
kind: "normal",
|
|
||||||
source: "message",
|
|
||||||
authorized: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const mockDispatchReplyFromConfig = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
|
||||||
const mockWithReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"] = async ({
|
|
||||||
dispatcher,
|
|
||||||
run,
|
|
||||||
onSettled,
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
return await run();
|
|
||||||
} finally {
|
|
||||||
dispatcher.markComplete();
|
|
||||||
try {
|
|
||||||
await dispatcher.waitForIdle();
|
|
||||||
} finally {
|
|
||||||
await onSettled?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const resolveEnvelopeFormatOptionsMock: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"] =
|
|
||||||
() => ({}) satisfies EnvelopeFormatOptions;
|
|
||||||
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
|
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
|
||||||
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
||||||
path: "/tmp/inbound-clip.mp4",
|
path: "/tmp/inbound-clip.mp4",
|
||||||
@@ -99,18 +108,10 @@ describe("broadcast dispatch", () => {
|
|||||||
resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
|
resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
|
resolveStorePath: mockResolveStorePath,
|
||||||
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
recordInboundSession: mockRecordInboundSession,
|
||||||
},
|
|
||||||
reply: {
|
|
||||||
resolveEnvelopeFormatOptions: resolveEnvelopeFormatOptionsMock,
|
|
||||||
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
||||||
finalizeInboundContext:
|
|
||||||
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
||||||
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
||||||
withReplyDispatcher:
|
|
||||||
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
|
||||||
},
|
},
|
||||||
|
reply: {},
|
||||||
commands: {
|
commands: {
|
||||||
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
||||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||||
@@ -135,9 +136,13 @@ describe("broadcast dispatch", () => {
|
|||||||
if (!("runDispatch" in turn)) {
|
if (!("runDispatch" in turn)) {
|
||||||
throw new Error("feishu broadcast test runtime only supports prepared turns");
|
throw new Error("feishu broadcast test runtime only supports prepared turns");
|
||||||
}
|
}
|
||||||
await turn.recordInboundSession({
|
const routeSessionKey = "route" in turn ? turn.route.sessionKey : turn.routeSessionKey;
|
||||||
storePath: turn.storePath,
|
const storePath = "storePath" in turn ? turn.storePath : mockResolveStorePath();
|
||||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
const recordInboundSession =
|
||||||
|
"recordInboundSession" in turn ? turn.recordInboundSession : mockRecordInboundSession;
|
||||||
|
await recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey: turn.ctxPayload.SessionKey ?? routeSessionKey,
|
||||||
ctx: turn.ctxPayload,
|
ctx: turn.ctxPayload,
|
||||||
groupResolution: turn.record?.groupResolution,
|
groupResolution: turn.record?.groupResolution,
|
||||||
createIfMissing: turn.record?.createIfMissing,
|
createIfMissing: turn.record?.createIfMissing,
|
||||||
@@ -148,7 +153,7 @@ describe("broadcast dispatch", () => {
|
|||||||
admission: { kind: "dispatch" as const },
|
admission: { kind: "dispatch" as const },
|
||||||
dispatched: true,
|
dispatched: true,
|
||||||
ctxPayload: turn.ctxPayload,
|
ctxPayload: turn.ctxPayload,
|
||||||
routeSessionKey: turn.routeSessionKey,
|
routeSessionKey,
|
||||||
dispatchResult: await turn.runDispatch(),
|
dispatchResult: await turn.runDispatch(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -219,8 +224,12 @@ describe("broadcast dispatch", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockDispatchInboundMessage.mockReset().mockResolvedValue({
|
||||||
|
queuedFinal: false,
|
||||||
|
counts: { final: 1 },
|
||||||
|
});
|
||||||
feishuGroupNameCache.clear();
|
feishuGroupNameCache.clear();
|
||||||
finalizeInboundContextCalls.length = 0;
|
builtInboundContextCalls.length = 0;
|
||||||
mockResolveAgentRoute.mockReturnValue({
|
mockResolveAgentRoute.mockReturnValue({
|
||||||
agentId: "main",
|
agentId: "main",
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
@@ -277,8 +286,8 @@ describe("broadcast dispatch", () => {
|
|||||||
runtime: createRuntimeEnv(),
|
runtime: createRuntimeEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
|
||||||
const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey);
|
const sessionKeys = builtInboundContextCalls.map((call) => call.SessionKey);
|
||||||
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
|
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
|
||||||
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
|
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
|
||||||
const recordCalls = (
|
const recordCalls = (
|
||||||
@@ -320,7 +329,7 @@ describe("broadcast dispatch", () => {
|
|||||||
]);
|
]);
|
||||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
|
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
|
||||||
expect(
|
expect(
|
||||||
finalizeInboundContextCalls
|
builtInboundContextCalls
|
||||||
.map((call) => ({
|
.map((call) => ({
|
||||||
sessionKey: call.SessionKey,
|
sessionKey: call.SessionKey,
|
||||||
groupSubject: call.GroupSubject,
|
groupSubject: call.GroupSubject,
|
||||||
@@ -347,7 +356,7 @@ describe("broadcast dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
|
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
|
||||||
mockDispatchReplyFromConfig
|
mockDispatchInboundMessage
|
||||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
@@ -389,7 +398,7 @@ describe("broadcast dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
|
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
|
||||||
mockDispatchReplyFromConfig
|
mockDispatchInboundMessage
|
||||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
@@ -430,7 +439,7 @@ describe("broadcast dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
|
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
|
||||||
mockDispatchReplyFromConfig
|
mockDispatchInboundMessage
|
||||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
@@ -484,7 +493,7 @@ describe("broadcast dispatch", () => {
|
|||||||
runtime: createRuntimeEnv(),
|
runtime: createRuntimeEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
|
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
|
||||||
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -502,7 +511,7 @@ describe("broadcast dispatch", () => {
|
|||||||
runtime: createRuntimeEnv(),
|
runtime: createRuntimeEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
|
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
|
||||||
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -539,14 +548,14 @@ describe("broadcast dispatch", () => {
|
|||||||
runtime: createRuntimeEnv(),
|
runtime: createRuntimeEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
|
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
|
||||||
expect(finalizeInboundContextCalls).toHaveLength(1);
|
expect(builtInboundContextCalls).toHaveLength(1);
|
||||||
expect(finalizeInboundContextCalls[0]?.SessionKey).toBe(
|
expect(builtInboundContextCalls[0]?.SessionKey).toBe(
|
||||||
"agent:main:feishu:group:oc-broadcast-group",
|
"agent:main:feishu:group:oc-broadcast-group",
|
||||||
);
|
);
|
||||||
expect(finalizeInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
|
expect(builtInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
|
||||||
expect(finalizeInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
|
expect(builtInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
|
||||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
|
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -584,11 +593,11 @@ describe("broadcast dispatch", () => {
|
|||||||
runtime: createRuntimeEnv(),
|
runtime: createRuntimeEnv(),
|
||||||
accountId: "account-A",
|
accountId: "account-A",
|
||||||
});
|
});
|
||||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
mockDispatchReplyFromConfig.mockClear();
|
mockDispatchInboundMessage.mockClear();
|
||||||
mockGetChatInfo.mockClear();
|
mockGetChatInfo.mockClear();
|
||||||
finalizeInboundContextCalls.length = 0;
|
builtInboundContextCalls.length = 0;
|
||||||
|
|
||||||
await handleFeishuMessage({
|
await handleFeishuMessage({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -596,7 +605,7 @@ describe("broadcast dispatch", () => {
|
|||||||
runtime: createRuntimeEnv(),
|
runtime: createRuntimeEnv(),
|
||||||
accountId: "account-B",
|
accountId: "account-B",
|
||||||
});
|
});
|
||||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(mockDispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
expect(mockGetChatInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -634,10 +643,10 @@ describe("broadcast dispatch", () => {
|
|||||||
runtime: createRuntimeEnv(),
|
runtime: createRuntimeEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
typeof finalizeInboundContextCalls[0]?.SessionKey === "string"
|
typeof builtInboundContextCalls[0]?.SessionKey === "string"
|
||||||
? finalizeInboundContextCalls[0].SessionKey
|
? builtInboundContextCalls[0].SessionKey
|
||||||
: "";
|
: "";
|
||||||
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
|
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function buildDefaultResolveRoute(): ResolvedAgentRoute {
|
|||||||
let currentRuntimeConfig = {} as ClawdbotConfig;
|
let currentRuntimeConfig = {} as ClawdbotConfig;
|
||||||
|
|
||||||
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
||||||
return {
|
const runtime = {
|
||||||
config: {
|
config: {
|
||||||
current: vi.fn(() => currentRuntimeConfig),
|
current: vi.fn(() => currentRuntimeConfig),
|
||||||
},
|
},
|
||||||
@@ -209,9 +209,14 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
|
|||||||
kind: "message",
|
kind: "message",
|
||||||
canStartAgentTurn: true,
|
canStartAgentTurn: true,
|
||||||
});
|
});
|
||||||
await turn.recordInboundSession({
|
if (!("route" in turn) || !("runDispatch" in turn)) {
|
||||||
storePath: turn.storePath,
|
throw new Error("expected a prepared channel turn plan");
|
||||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
}
|
||||||
|
await runtime.channel.session.recordInboundSession({
|
||||||
|
storePath: runtime.channel.session.resolveStorePath(turn.cfg.session?.store, {
|
||||||
|
agentId: turn.route.agentId,
|
||||||
|
}),
|
||||||
|
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
|
||||||
ctx: turn.ctxPayload,
|
ctx: turn.ctxPayload,
|
||||||
groupResolution: turn.record?.groupResolution,
|
groupResolution: turn.record?.groupResolution,
|
||||||
createIfMissing: turn.record?.createIfMissing,
|
createIfMissing: turn.record?.createIfMissing,
|
||||||
@@ -229,6 +234,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
|
|||||||
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
|
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
|
||||||
...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
|
...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
|
||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
|
const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
|
||||||
@@ -239,7 +245,6 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession
|
|||||||
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
|
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
|
||||||
mockResolveStorePath(params);
|
mockResolveStorePath(params);
|
||||||
const resolveEnvelopeFormatOptionsMock = () => ({});
|
const resolveEnvelopeFormatOptionsMock = () => ({});
|
||||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
|
||||||
const withReplyDispatcherMock = async ({
|
const withReplyDispatcherMock = async ({
|
||||||
run,
|
run,
|
||||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
|
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
|
||||||
@@ -299,6 +304,8 @@ const {
|
|||||||
mockResolveFeishuReasoningPreviewEnabled,
|
mockResolveFeishuReasoningPreviewEnabled,
|
||||||
mockTranscribeFirstAudio,
|
mockTranscribeFirstAudio,
|
||||||
mockMaybeCreateDynamicAgent,
|
mockMaybeCreateDynamicAgent,
|
||||||
|
mockBuildChannelInboundEventContext,
|
||||||
|
mockDispatchInboundMessage,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||||
dispatcher: createReplyDispatcher(),
|
dispatcher: createReplyDispatcher(),
|
||||||
@@ -336,8 +343,49 @@ const {
|
|||||||
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
|
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
|
||||||
mockTranscribeFirstAudio: vi.fn(),
|
mockTranscribeFirstAudio: vi.fn(),
|
||||||
mockMaybeCreateDynamicAgent: vi.fn(),
|
mockMaybeCreateDynamicAgent: vi.fn(),
|
||||||
|
mockBuildChannelInboundEventContext: vi.fn(),
|
||||||
|
mockDispatchInboundMessage: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const finalizeInboundContextMock = mockBuildChannelInboundEventContext;
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
|
||||||
|
"openclaw/plugin-sdk/channel-inbound",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||||
|
resolveEnvelopeFormatOptions: () => ({}),
|
||||||
|
buildChannelInboundEventContext: (
|
||||||
|
params: Parameters<typeof actual.buildChannelInboundEventContext>[0],
|
||||||
|
) =>
|
||||||
|
actual.buildChannelInboundEventContext({
|
||||||
|
...params,
|
||||||
|
finalize: (ctx) => {
|
||||||
|
mockBuildChannelInboundEventContext(ctx);
|
||||||
|
return ctx as never;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
|
||||||
|
"openclaw/plugin-sdk/reply-runtime",
|
||||||
|
);
|
||||||
|
return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
|
||||||
|
"openclaw/plugin-sdk/session-store-runtime",
|
||||||
|
);
|
||||||
|
return { ...actual, resolveStorePath: mockResolveStorePath };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./reply-dispatcher.js", () => ({
|
vi.mock("./reply-dispatcher.js", () => ({
|
||||||
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
||||||
}));
|
}));
|
||||||
@@ -966,42 +1014,11 @@ describe("handleFeishuMessage ACP routing", () => {
|
|||||||
);
|
);
|
||||||
expect(dispatcherOptions.allowReasoningPreview).toBe(true);
|
expect(dispatcherOptions.allowReasoningPreview).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to full runtime channel when partial channelRuntime lacks inbound", async () => {
|
|
||||||
const partialChannelRuntime = {
|
|
||||||
runtimeContexts: {} as PluginRuntime["channel"]["runtimeContexts"],
|
|
||||||
} as PluginRuntime["channel"];
|
|
||||||
|
|
||||||
await dispatchMessage({
|
|
||||||
cfg: {
|
|
||||||
session: { mainKey: "main", scope: "per-sender" },
|
|
||||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
||||||
},
|
|
||||||
event: {
|
|
||||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
||||||
message: {
|
|
||||||
message_id: "msg-partial-runtime",
|
|
||||||
chat_id: "oc_dm",
|
|
||||||
chat_type: "p2p",
|
|
||||||
message_type: "text",
|
|
||||||
content: JSON.stringify({ text: "hello" }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channelRuntime: partialChannelRuntime,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleFeishuMessage command authorization", () => {
|
describe("handleFeishuMessage command authorization", () => {
|
||||||
const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({
|
const mockFinalizeInboundContext = mockBuildChannelInboundEventContext;
|
||||||
...ctx,
|
const mockDispatchReplyFromConfig = mockDispatchInboundMessage;
|
||||||
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
|
|
||||||
}));
|
|
||||||
const mockDispatchReplyFromConfig = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
|
||||||
const mockWithReplyDispatcher = vi.fn(
|
const mockWithReplyDispatcher = vi.fn(
|
||||||
async ({
|
async ({
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -1037,6 +1054,10 @@ describe("handleFeishuMessage command authorization", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockDispatchReplyFromConfig.mockReset().mockResolvedValue({
|
||||||
|
queuedFinal: false,
|
||||||
|
counts: { final: 1 },
|
||||||
|
});
|
||||||
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
||||||
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
|
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
|
||||||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Feishu plugin module implements bot behavior.
|
|
||||||
import {
|
import {
|
||||||
buildChannelInboundEventContext,
|
buildChannelInboundEventContext,
|
||||||
|
formatAgentEnvelope,
|
||||||
formatInboundMediaUnavailableText,
|
formatInboundMediaUnavailableText,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
toInboundMediaFacts,
|
toInboundMediaFacts,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/channel-outbound";
|
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/channel-outbound";
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
createChannelHistoryWindow,
|
createChannelHistoryWindow,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "openclaw/plugin-sdk/reply-history";
|
} from "openclaw/plugin-sdk/reply-history";
|
||||||
|
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||||
import {
|
import {
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
warnMissingProviderGroupPolicyFallbackOnce,
|
warnMissingProviderGroupPolicyFallbackOnce,
|
||||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||||
|
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||||
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||||
@@ -977,7 +980,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
(groupSession?.groupSessionScope === "group_topic" ||
|
(groupSession?.groupSessionScope === "group_topic" ||
|
||||||
groupSession?.groupSessionScope === "group_topic_sender");
|
groupSession?.groupSessionScope === "group_topic_sender");
|
||||||
|
|
||||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
const messageBody = buildFeishuAgentBody({
|
const messageBody = buildFeishuAgentBody({
|
||||||
ctx: agentFacingCtx,
|
ctx: agentFacingCtx,
|
||||||
quotedContent,
|
quotedContent,
|
||||||
@@ -990,7 +993,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
channel: "Feishu",
|
channel: "Feishu",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@@ -1008,7 +1011,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
core.channel.reply.formatAgentEnvelope({
|
formatAgentEnvelope({
|
||||||
channel: "Feishu",
|
channel: "Feishu",
|
||||||
// Preserve speaker identity in group history as well.
|
// Preserve speaker identity in group history as well.
|
||||||
from: `${ctx.chatId}:${entry.sender}`,
|
from: `${ctx.chatId}:${entry.sender}`,
|
||||||
@@ -1116,7 +1119,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
return threadContext;
|
return threadContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||||
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
|
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: agentSessionKey,
|
sessionKey: agentSessionKey,
|
||||||
@@ -1182,7 +1185,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
: relevantMessages.slice(1);
|
: relevantMessages.slice(1);
|
||||||
const historyParts = historyMessages.map((msg) => {
|
const historyParts = historyMessages.map((msg) => {
|
||||||
const role = msg.senderType === "app" ? "assistant" : "user";
|
const role = msg.senderType === "app" ? "assistant" : "user";
|
||||||
return core.channel.reply.formatAgentEnvelope({
|
return formatAgentEnvelope({
|
||||||
channel: "Feishu",
|
channel: "Feishu",
|
||||||
from: `${msg.senderId ?? "Unknown"} (${role})`,
|
from: `${msg.senderId ?? "Unknown"} (${role})`,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
@@ -1216,7 +1219,6 @@ export async function handleFeishuMessage(params: {
|
|||||||
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
|
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
|
||||||
return buildChannelInboundEventContext({
|
return buildChannelInboundEventContext({
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
finalize: core.channel.reply.finalizeInboundContext,
|
|
||||||
supplemental: {
|
supplemental: {
|
||||||
quote: quotedContent ? { id: ctx.parentId, body: quotedContent } : undefined,
|
quote: quotedContent ? { id: ctx.parentId, body: quotedContent } : undefined,
|
||||||
thread: {
|
thread: {
|
||||||
@@ -1388,7 +1390,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
||||||
const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
const agentStorePath = resolveStorePath(cfg.session?.store, {
|
||||||
agentId,
|
agentId,
|
||||||
});
|
});
|
||||||
const agentRecord = {
|
const agentRecord = {
|
||||||
@@ -1456,12 +1458,11 @@ export async function handleFeishuMessage(params: {
|
|||||||
raw: ctx,
|
raw: ctx,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: agentSessionKey,
|
route: { agentId, sessionKey: agentSessionKey },
|
||||||
storePath: agentStorePath,
|
|
||||||
ctxPayload: agentCtx,
|
ctxPayload: agentCtx,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
record: agentRecord,
|
record: agentRecord,
|
||||||
onPreDispatchFailure: () =>
|
onPreDispatchFailure: () =>
|
||||||
core.channel.reply.settleReplyDispatcher({
|
core.channel.reply.settleReplyDispatcher({
|
||||||
@@ -1469,16 +1470,12 @@ export async function handleFeishuMessage(params: {
|
|||||||
onSettled: () => markDispatchIdle(),
|
onSettled: () => markDispatchIdle(),
|
||||||
}),
|
}),
|
||||||
runDispatch: () =>
|
runDispatch: () =>
|
||||||
core.channel.reply.withReplyDispatcher({
|
dispatchInboundMessage({
|
||||||
|
ctx: agentCtx,
|
||||||
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
onSettled: () => markDispatchIdle(),
|
onSettled: () => markDispatchIdle(),
|
||||||
run: () =>
|
replyOptions,
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: agentCtx,
|
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -1524,22 +1521,17 @@ export async function handleFeishuMessage(params: {
|
|||||||
raw: ctx,
|
raw: ctx,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: agentSessionKey,
|
route: { agentId, sessionKey: agentSessionKey },
|
||||||
storePath: agentStorePath,
|
|
||||||
ctxPayload: agentCtx,
|
ctxPayload: agentCtx,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
record: agentRecord,
|
record: agentRecord,
|
||||||
runDispatch: () =>
|
runDispatch: () =>
|
||||||
core.channel.reply.withReplyDispatcher({
|
dispatchInboundMessage({
|
||||||
|
ctx: agentCtx,
|
||||||
|
cfg,
|
||||||
dispatcher: noopDispatcher,
|
dispatcher: noopDispatcher,
|
||||||
run: () =>
|
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: agentCtx,
|
|
||||||
cfg,
|
|
||||||
dispatcher: noopDispatcher,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -1592,7 +1584,7 @@ export async function handleFeishuMessage(params: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const identity = resolveAgentOutboundIdentity(effectiveCfg, route.agentId);
|
const identity = resolveAgentOutboundIdentity(effectiveCfg, route.agentId);
|
||||||
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
|
const storePath = resolveStorePath(effectiveCfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
|
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
|
||||||
@@ -1637,12 +1629,11 @@ export async function handleFeishuMessage(params: {
|
|||||||
raw: ctx,
|
raw: ctx,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg: effectiveCfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
updateLastRoute: buildFeishuInboundLastRouteUpdate({
|
updateLastRoute: buildFeishuInboundLastRouteUpdate({
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
@@ -1666,18 +1657,12 @@ export async function handleFeishuMessage(params: {
|
|||||||
onSettled: () => markDispatchIdle(),
|
onSettled: () => markDispatchIdle(),
|
||||||
}),
|
}),
|
||||||
runDispatch: () =>
|
runDispatch: () =>
|
||||||
core.channel.reply.withReplyDispatcher({
|
dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: effectiveCfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
onSettled: () => {
|
onSettled: () => markDispatchIdle(),
|
||||||
markDispatchIdle();
|
replyOptions,
|
||||||
},
|
|
||||||
run: () =>
|
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg: effectiveCfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ import {
|
|||||||
createRuntimeDirectoryLiveAdapter,
|
createRuntimeDirectoryLiveAdapter,
|
||||||
} from "openclaw/plugin-sdk/directory-runtime";
|
} from "openclaw/plugin-sdk/directory-runtime";
|
||||||
import {
|
import {
|
||||||
interactiveReplyToPresentation,
|
legacyInteractiveReplyToPresentation,
|
||||||
normalizeInteractiveReply,
|
normalizeLegacyInteractiveReply,
|
||||||
normalizeMessagePresentation,
|
normalizeMessagePresentation,
|
||||||
resolveInteractiveTextFallback,
|
resolveLegacyInteractiveTextFallback,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||||
@@ -1074,10 +1074,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
|||||||
const textCard = readNativeFeishuCardJson(text, {
|
const textCard = readNativeFeishuCardJson(text, {
|
||||||
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
|
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
|
||||||
});
|
});
|
||||||
const interactive = normalizeInteractiveReply(ctx.params.interactive);
|
const interactive = normalizeLegacyInteractiveReply(ctx.params.interactive);
|
||||||
const presentation =
|
const presentation =
|
||||||
normalizeMessagePresentation(ctx.params.presentation) ??
|
normalizeMessagePresentation(ctx.params.presentation) ??
|
||||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||||
const mediaUrl = readFeishuMediaParam(ctx.params);
|
const mediaUrl = readFeishuMediaParam(ctx.params);
|
||||||
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
|
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
|
||||||
if (textCard && !presentation) {
|
if (textCard && !presentation) {
|
||||||
@@ -1088,7 +1088,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
|||||||
presentation,
|
presentation,
|
||||||
fallbackText: textCard
|
fallbackText: textCard
|
||||||
? undefined
|
? undefined
|
||||||
: resolveInteractiveTextFallback({ text, interactive }),
|
: resolveLegacyInteractiveTextFallback({ text, interactive }),
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const presentationCard =
|
const presentationCard =
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Feishu plugin module implements comment dispatcher behavior.
|
// Feishu plugin module implements comment dispatcher behavior.
|
||||||
|
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||||
|
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||||
import { createFeishuClient } from "./client.js";
|
import { createFeishuClient } from "./client.js";
|
||||||
import {
|
import {
|
||||||
@@ -56,10 +58,10 @@ export function createFeishuCommentReplyDispatcher(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
createReplyDispatcherWithTyping({
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefix: prefixContext.responsePrefix,
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||||
onReplyStart: async () => {
|
onReplyStart: async () => {
|
||||||
await typingReaction.start();
|
await typingReaction.start();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Feishu tests cover comment handler plugin behavior.
|
// Feishu tests cover comment handler plugin behavior.
|
||||||
import type { PreparedInboundReply } from "openclaw/plugin-sdk/channel-inbound";
|
|
||||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
||||||
import { handleFeishuCommentEvent } from "./comment-handler.js";
|
import { handleFeishuCommentEvent } from "./comment-handler.js";
|
||||||
@@ -10,6 +9,7 @@ const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
|
|||||||
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
|
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
|
||||||
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
|
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
|
||||||
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
|
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
|
||||||
|
const dispatchInboundMessageMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("./monitor.comment.js", () => ({
|
vi.mock("./monitor.comment.js", () => ({
|
||||||
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
|
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
|
||||||
@@ -31,6 +31,11 @@ vi.mock("./drive.js", () => ({
|
|||||||
deliverCommentThreadText: deliverCommentThreadTextMock,
|
deliverCommentThreadText: deliverCommentThreadTextMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>()),
|
||||||
|
dispatchInboundMessage: dispatchInboundMessageMock,
|
||||||
|
}));
|
||||||
|
|
||||||
async function raceWithNextMacrotask<T>(promise: Promise<T>): Promise<T | "pending"> {
|
async function raceWithNextMacrotask<T>(promise: Promise<T>): Promise<T | "pending"> {
|
||||||
return await Promise.race([
|
return await Promise.race([
|
||||||
promise,
|
promise,
|
||||||
@@ -83,38 +88,19 @@ function createTestRuntime(overrides?: {
|
|||||||
readAllowFromStore?: () => Promise<unknown[]>;
|
readAllowFromStore?: () => Promise<unknown[]>;
|
||||||
upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>;
|
upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>;
|
||||||
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
|
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
|
||||||
dispatchReplyFromConfig?: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"];
|
|
||||||
withReplyDispatcher?: PluginRuntime["channel"]["reply"]["withReplyDispatcher"];
|
|
||||||
}) {
|
}) {
|
||||||
const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ctx);
|
const recordInboundSession = vi.fn(async (_params: unknown) => {});
|
||||||
const dispatchReplyFromConfig =
|
type PreparedCommentTurnPlan = {
|
||||||
overrides?.dispatchReplyFromConfig ??
|
route: { agentId: string; sessionKey: string };
|
||||||
vi.fn(async () => ({
|
ctxPayload: { SessionKey?: string };
|
||||||
queuedFinal: true,
|
record?: Record<string, unknown> & { onRecordError?: (error: unknown) => void };
|
||||||
counts: { tool: 0, block: 0, final: 1 },
|
runDispatch: () => Promise<unknown>;
|
||||||
}));
|
};
|
||||||
const withReplyDispatcher =
|
const dispatchPreparedForTest = vi.fn(async (turn: PreparedCommentTurnPlan) => {
|
||||||
overrides?.withReplyDispatcher ??
|
const storePath = "/tmp/feishu-session-store.json";
|
||||||
vi.fn(
|
await recordInboundSession({
|
||||||
async ({
|
storePath,
|
||||||
run,
|
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
|
||||||
onSettled,
|
|
||||||
}: {
|
|
||||||
run: () => Promise<unknown>;
|
|
||||||
onSettled?: () => Promise<void> | void;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
return await run();
|
|
||||||
} finally {
|
|
||||||
await onSettled?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const recordInboundSession = vi.fn(async () => {});
|
|
||||||
const dispatchPreparedForTest = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
|
|
||||||
await turn.recordInboundSession({
|
|
||||||
storePath: turn.storePath,
|
|
||||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
|
||||||
ctx: turn.ctxPayload,
|
ctx: turn.ctxPayload,
|
||||||
groupResolution: turn.record?.groupResolution,
|
groupResolution: turn.record?.groupResolution,
|
||||||
createIfMissing: turn.record?.createIfMissing,
|
createIfMissing: turn.record?.createIfMissing,
|
||||||
@@ -126,7 +112,7 @@ function createTestRuntime(overrides?: {
|
|||||||
admission: { kind: "dispatch" as const },
|
admission: { kind: "dispatch" as const },
|
||||||
dispatched: true,
|
dispatched: true,
|
||||||
ctxPayload: turn.ctxPayload,
|
ctxPayload: turn.ctxPayload,
|
||||||
routeSessionKey: turn.routeSessionKey,
|
routeSessionKey: turn.route.sessionKey,
|
||||||
dispatchResult,
|
dispatchResult,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -151,9 +137,11 @@ function createTestRuntime(overrides?: {
|
|||||||
resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())),
|
resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())),
|
||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
finalizeInboundContext,
|
settleReplyDispatcher: vi.fn(async ({ dispatcher, onSettled }) => {
|
||||||
dispatchReplyFromConfig,
|
dispatcher.markComplete();
|
||||||
withReplyDispatcher,
|
await dispatcher.waitForIdle();
|
||||||
|
await onSettled?.();
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
|
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
|
||||||
@@ -176,7 +164,7 @@ function createTestRuntime(overrides?: {
|
|||||||
if (!("runDispatch" in turn)) {
|
if (!("runDispatch" in turn)) {
|
||||||
throw new Error("feishu comment test runtime only supports prepared turns");
|
throw new Error("feishu comment test runtime only supports prepared turns");
|
||||||
}
|
}
|
||||||
return await dispatchPreparedForTest(turn as PreparedInboundReply<unknown>);
|
return await dispatchPreparedForTest(turn as PreparedCommentTurnPlan);
|
||||||
}) as unknown as PluginRuntime["channel"]["inbound"]["run"],
|
}) as unknown as PluginRuntime["channel"]["inbound"]["run"],
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
@@ -206,6 +194,10 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
dispatchInboundMessageMock.mockResolvedValue({
|
||||||
|
queuedFinal: true,
|
||||||
|
counts: { tool: 0, block: 0, final: 1 },
|
||||||
|
});
|
||||||
currentRuntimeConfig = buildConfig();
|
currentRuntimeConfig = buildConfig();
|
||||||
maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({
|
maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({
|
||||||
created: false,
|
created: false,
|
||||||
@@ -270,20 +262,16 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runtime = (await import("./runtime.js")).getFeishuRuntime();
|
const runtime = (await import("./runtime.js")).getFeishuRuntime();
|
||||||
const finalizeInboundContext = runtime.channel.reply.finalizeInboundContext as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType<
|
const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType<
|
||||||
typeof vi.fn
|
typeof vi.fn
|
||||||
>;
|
>;
|
||||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
|
|
||||||
expect(finalizeInboundContext).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||||
const finalizedContext = mockCallArg(finalizeInboundContext, "finalizeInboundContext") as
|
const finalizedContext = (
|
||||||
| Record<string, unknown>
|
mockCallArg(dispatchInboundMessageMock, "dispatchInboundMessage") as {
|
||||||
| undefined;
|
ctx?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
).ctx;
|
||||||
expect({
|
expect({
|
||||||
from: finalizedContext?.From,
|
from: finalizedContext?.From,
|
||||||
to: finalizedContext?.To,
|
to: finalizedContext?.To,
|
||||||
@@ -306,7 +294,6 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
| { sessionKey?: string }
|
| { sessionKey?: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(recordArgs?.sessionKey).toBe("agent:main:feishu:direct:comment-doc:docx:doc_token_1");
|
expect(recordArgs?.sessionKey).toBe("agent:main:feishu:direct:comment-doc:docx:doc_token_1");
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows comment senders matched by user_id allowlist entries", async () => {
|
it("allows comment senders matched by user_id allowlist entries", async () => {
|
||||||
@@ -332,10 +319,7 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
||||||
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
|
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,10 +360,7 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
| undefined;
|
| undefined;
|
||||||
expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender");
|
expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender");
|
||||||
expect(dynamicAgentArgs?.accountId).toBe("default");
|
expect(dynamicAgentArgs?.accountId).toBe("default");
|
||||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops a comment denied by refreshed dynamic-agent policy", async () => {
|
it("drops a comment denied by refreshed dynamic-agent policy", async () => {
|
||||||
@@ -410,12 +391,9 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
|
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
|
||||||
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
|
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
|
||||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("issues a pairing challenge before dynamic comment-agent creation", async () => {
|
it("issues a pairing challenge before dynamic comment-agent creation", async () => {
|
||||||
@@ -446,12 +424,9 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
|
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
|
||||||
expect(deliverCommentThreadTextMock).toHaveBeenCalledTimes(1);
|
expect(deliverCommentThreadTextMock).toHaveBeenCalledTimes(1);
|
||||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
|
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
|
||||||
@@ -506,10 +481,7 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
is_whole_comment: false,
|
is_whole_comment: false,
|
||||||
});
|
});
|
||||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes whole-comment metadata to the comment reply dispatcher", async () => {
|
it("passes whole-comment metadata to the comment reply dispatcher", async () => {
|
||||||
@@ -565,10 +537,8 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("always finalizes comment typing cleanup even when dispatch fails", async () => {
|
it("always finalizes comment typing cleanup even when dispatch fails", async () => {
|
||||||
const dispatchReplyFromConfig = vi.fn(async () => {
|
dispatchInboundMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
|
||||||
throw new Error("dispatch failed");
|
const runtime = createTestRuntime();
|
||||||
});
|
|
||||||
const runtime = createTestRuntime({ dispatchReplyFromConfig });
|
|
||||||
setFeishuRuntime(runtime);
|
setFeishuRuntime(runtime);
|
||||||
const markRunComplete = vi.fn();
|
const markRunComplete = vi.fn();
|
||||||
const markDispatchIdle = vi.fn();
|
const markDispatchIdle = vi.fn();
|
||||||
@@ -669,10 +639,6 @@ describe("handleFeishuCommentEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(startTypingReaction).not.toHaveBeenCalled();
|
expect(startTypingReaction).not.toHaveBeenCalled();
|
||||||
const runtime = (await import("./runtime.js")).getFeishuRuntime();
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
||||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
|
||||||
typeof vi.fn
|
|
||||||
>;
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Feishu plugin module implements comment handler behavior.
|
// Feishu plugin module implements comment handler behavior.
|
||||||
|
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||||
|
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||||
import { createFeishuClient } from "./client.js";
|
import { createFeishuClient } from "./client.js";
|
||||||
@@ -214,36 +216,36 @@ export async function handleFeishuCommentEvent(
|
|||||||
fileToken: turn.fileToken,
|
fileToken: turn.fileToken,
|
||||||
});
|
});
|
||||||
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
|
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const rawBody = turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt;
|
||||||
Body: bodyForAgent,
|
const conversationLabel = turn.documentTitle
|
||||||
BodyForAgent: bodyForAgent,
|
? `Feishu comment · ${turn.documentTitle}`
|
||||||
RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
|
: "Feishu comment";
|
||||||
CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
|
const ctxPayload = buildChannelInboundEventContext({
|
||||||
From: `feishu:${turn.senderId}`,
|
channel: "feishu",
|
||||||
To: commentTarget,
|
accountId: route.accountId,
|
||||||
SessionKey: commentSessionKey,
|
surface: "feishu-comment",
|
||||||
AccountId: route.accountId,
|
messageId: turn.messageId,
|
||||||
ChatType: "direct",
|
timestamp: parseTimestampMs(turn.timestamp),
|
||||||
ConversationLabel: turn.documentTitle
|
from: `feishu:${turn.senderId}`,
|
||||||
? `Feishu comment · ${turn.documentTitle}`
|
sender: { id: turn.senderId, name: turn.senderId },
|
||||||
: "Feishu comment",
|
conversation: { kind: "direct", id: commentTarget, label: conversationLabel },
|
||||||
SenderName: turn.senderId,
|
route: {
|
||||||
SenderId: turn.senderId,
|
agentId: route.agentId,
|
||||||
Provider: "feishu",
|
accountId: route.accountId,
|
||||||
Surface: "feishu-comment",
|
routeSessionKey: commentSessionKey,
|
||||||
MessageSid: turn.messageId,
|
dispatchSessionKey: commentSessionKey,
|
||||||
// For Feishu comment turns, MessageThreadId carries the inbound reply_id so
|
},
|
||||||
// comment-aware tools can clean typing reaction before sending visible output.
|
reply: {
|
||||||
MessageThreadId: turn.replyId,
|
to: commentTarget,
|
||||||
Timestamp: parseTimestampMs(turn.timestamp),
|
originatingTo: commentTarget,
|
||||||
WasMentioned: turn.isMentioned,
|
// Comment-aware tools use the inbound reply id as the native thread id.
|
||||||
CommandAuthorized: false,
|
messageThreadId: turn.replyId,
|
||||||
OriginatingChannel: "feishu",
|
},
|
||||||
OriginatingTo: commentTarget,
|
message: { body: bodyForAgent, bodyForAgent, rawBody, commandBody: rawBody },
|
||||||
});
|
access: {
|
||||||
|
commands: { authorized: false },
|
||||||
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
|
mentions: { canDetectMention: true, wasMentioned: turn.isMentioned ?? false },
|
||||||
agentId: route.agentId,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
|
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
|
||||||
@@ -279,12 +281,11 @@ export async function handleFeishuCommentEvent(
|
|||||||
raw: turn,
|
raw: turn,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg: effectiveCfg,
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: commentSessionKey,
|
route: { agentId: route.agentId, sessionKey: commentSessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
error(
|
error(
|
||||||
@@ -303,15 +304,11 @@ export async function handleFeishuCommentEvent(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
runDispatch: () =>
|
runDispatch: () =>
|
||||||
core.channel.reply.withReplyDispatcher({
|
dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: effectiveCfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
run: () =>
|
replyOptions,
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg: effectiveCfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/channel-send-result";
|
} from "openclaw/plugin-sdk/channel-send-result";
|
||||||
import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime";
|
import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import {
|
import {
|
||||||
interactiveReplyToPresentation,
|
legacyInteractiveReplyToPresentation,
|
||||||
normalizeInteractiveReply,
|
normalizeLegacyInteractiveReply,
|
||||||
normalizeMessagePresentation,
|
normalizeMessagePresentation,
|
||||||
renderMessagePresentationFallbackText,
|
renderMessagePresentationFallbackText,
|
||||||
resolveInteractiveTextFallback,
|
resolveLegacyInteractiveTextFallback,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||||
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
||||||
@@ -179,10 +179,10 @@ function buildFeishuPayloadCard(params: {
|
|||||||
|
|
||||||
const rawText = params.text ?? params.payload.text;
|
const rawText = params.text ?? params.payload.text;
|
||||||
const textCard = readNativeFeishuCardJson(rawText);
|
const textCard = readNativeFeishuCardJson(rawText);
|
||||||
const interactive = normalizeInteractiveReply(params.payload.interactive);
|
const interactive = normalizeLegacyInteractiveReply(params.payload.interactive);
|
||||||
const presentation =
|
const presentation =
|
||||||
normalizeMessagePresentation(params.payload.presentation) ??
|
normalizeMessagePresentation(params.payload.presentation) ??
|
||||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||||
if (!presentation && !interactive) {
|
if (!presentation && !interactive) {
|
||||||
if (!textCard) {
|
if (!textCard) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -193,7 +193,7 @@ function buildFeishuPayloadCard(params: {
|
|||||||
|
|
||||||
const text = textCard
|
const text = textCard
|
||||||
? undefined
|
? undefined
|
||||||
: resolveInteractiveTextFallback({
|
: resolveLegacyInteractiveTextFallback({
|
||||||
text: rawText,
|
text: rawText,
|
||||||
interactive,
|
interactive,
|
||||||
});
|
});
|
||||||
@@ -600,10 +600,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|||||||
const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload);
|
const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload);
|
||||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||||
if (parseFeishuCommentTarget(ctx.to)) {
|
if (parseFeishuCommentTarget(ctx.to)) {
|
||||||
const interactive = normalizeInteractiveReply(payload.interactive);
|
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
|
||||||
const normalizedPresentation =
|
const normalizedPresentation =
|
||||||
normalizeMessagePresentation(payload.presentation) ??
|
normalizeMessagePresentation(payload.presentation) ??
|
||||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||||
// Document comments cannot render cards. Resolve the text path before
|
// Document comments cannot render cards. Resolve the text path before
|
||||||
// validating card limits so unused native card data cannot block delivery.
|
// validating card limits so unused native card data cannot block delivery.
|
||||||
const textCard = readNativeFeishuCardJson(payload.text);
|
const textCard = readNativeFeishuCardJson(payload.text);
|
||||||
@@ -652,10 +652,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|||||||
if (ttsSupplement) {
|
if (ttsSupplement) {
|
||||||
return await sendFeishuTtsSupplementPayload({ ctx, payload, supplement: ttsSupplement });
|
return await sendFeishuTtsSupplementPayload({ ctx, payload, supplement: ttsSupplement });
|
||||||
}
|
}
|
||||||
const interactive = normalizeInteractiveReply(payload.interactive);
|
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
|
||||||
const presentation =
|
const presentation =
|
||||||
normalizeMessagePresentation(payload.presentation) ??
|
normalizeMessagePresentation(payload.presentation) ??
|
||||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
(interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined);
|
||||||
const fallbackPayload = presentation
|
const fallbackPayload = presentation
|
||||||
? {
|
? {
|
||||||
...payload,
|
...payload,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Feishu plugin module implements reply dispatcher behavior.
|
// Feishu plugin module implements reply dispatcher behavior.
|
||||||
import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
|
import { formatReasoningMessage, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||||
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
resolveTextChunksWithFallback,
|
resolveTextChunksWithFallback,
|
||||||
sendMediaWithLeadingCaption,
|
sendMediaWithLeadingCaption,
|
||||||
} from "openclaw/plugin-sdk/reply-payload";
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
|
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
|
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
|
||||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||||
import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js";
|
import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js";
|
||||||
@@ -637,245 +638,240 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
return nextIdleSideEffects;
|
return nextIdleSideEffects;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
responsePrefix: prefixContext.responsePrefix,
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
humanDelay: resolveHumanDelayConfig(cfg, agentId),
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
silentReplyContext: {
|
||||||
silentReplyContext: {
|
cfg,
|
||||||
cfg,
|
sessionKey: params.sessionKey,
|
||||||
sessionKey: params.sessionKey,
|
surface: "feishu",
|
||||||
surface: "feishu",
|
conversationType: chatId.startsWith("oc_") ? "group" : "direct",
|
||||||
conversationType: chatId.startsWith("oc_") ? "group" : "direct",
|
},
|
||||||
},
|
onSkip: (_payload, info) => {
|
||||||
onSkip: (_payload, info) => {
|
if (info.kind === "final") {
|
||||||
if (info.kind === "final") {
|
skippedFinalReason = info.reason;
|
||||||
skippedFinalReason = info.reason;
|
}
|
||||||
}
|
},
|
||||||
},
|
onReplyStart: async () => {
|
||||||
onReplyStart: async () => {
|
if (!replyLifecycleStateInitialized) {
|
||||||
if (!replyLifecycleStateInitialized) {
|
replyLifecycleStateInitialized = true;
|
||||||
replyLifecycleStateInitialized = true;
|
deliveredFinalTexts.clear();
|
||||||
deliveredFinalTexts.clear();
|
sentIndependentBlockText = false;
|
||||||
sentIndependentBlockText = false;
|
streamingClosedForReply = false;
|
||||||
streamingClosedForReply = false;
|
streamingCloseErroredForReply = false;
|
||||||
streamingCloseErroredForReply = false;
|
visibleReplySent = false;
|
||||||
visibleReplySent = false;
|
skippedFinalReason = null;
|
||||||
skippedFinalReason = null;
|
}
|
||||||
}
|
if (streamingEnabled && renderMode === "card") {
|
||||||
if (streamingEnabled && renderMode === "card") {
|
startStreaming();
|
||||||
startStreaming();
|
}
|
||||||
}
|
await Promise.resolve(typingCallbacks?.onReplyStart?.());
|
||||||
await Promise.resolve(typingCallbacks?.onReplyStart?.());
|
},
|
||||||
},
|
deliver: async (payload: ReplyPayload, info) => {
|
||||||
deliver: async (payload: ReplyPayload, info) => {
|
if (info?.kind === "final") {
|
||||||
if (info?.kind === "final") {
|
skippedFinalReason = null;
|
||||||
skippedFinalReason = null;
|
}
|
||||||
}
|
const payloadText =
|
||||||
const payloadText =
|
payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text;
|
||||||
payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text;
|
const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText });
|
||||||
const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText });
|
const text =
|
||||||
const text =
|
info?.kind === "final"
|
||||||
info?.kind === "final"
|
? mergeStreamingFinalText(
|
||||||
? mergeStreamingFinalText(
|
streamText,
|
||||||
streamText,
|
reply.text,
|
||||||
reply.text,
|
payload.isError === true && hasStreamingFinalText,
|
||||||
payload.isError === true && hasStreamingFinalText,
|
)
|
||||||
)
|
: reply.text;
|
||||||
: reply.text;
|
const hasText = reply.hasText;
|
||||||
const hasText = reply.hasText;
|
const hasMedia = reply.hasMedia;
|
||||||
const hasMedia = reply.hasMedia;
|
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true;
|
||||||
const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true;
|
const hasVoiceMedia =
|
||||||
const hasVoiceMedia =
|
hasMedia &&
|
||||||
hasMedia &&
|
reply.mediaUrls.some((mediaUrl) =>
|
||||||
reply.mediaUrls.some((mediaUrl) =>
|
shouldSuppressFeishuTextForVoiceMedia({
|
||||||
shouldSuppressFeishuTextForVoiceMedia({
|
mediaUrl,
|
||||||
mediaUrl,
|
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||||
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
ttsSupplement,
|
||||||
ttsSupplement,
|
}),
|
||||||
}),
|
);
|
||||||
);
|
const finalTextExceedsStreamingLimit =
|
||||||
const finalTextExceedsStreamingLimit =
|
info?.kind === "final" && hasText && text.length > textChunkLimit;
|
||||||
info?.kind === "final" && hasText && text.length > textChunkLimit;
|
const useStaticCard =
|
||||||
const useStaticCard =
|
hasText &&
|
||||||
hasText &&
|
(renderMode === "card" ||
|
||||||
(renderMode === "card" ||
|
(info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") ||
|
||||||
(info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") ||
|
(renderMode === "auto" && shouldUseCard(text)));
|
||||||
(renderMode === "auto" && shouldUseCard(text)));
|
const useStreamingCard =
|
||||||
const useStreamingCard =
|
hasText &&
|
||||||
hasText &&
|
streamingEnabled &&
|
||||||
streamingEnabled &&
|
!finalTextExceedsStreamingLimit &&
|
||||||
!finalTextExceedsStreamingLimit &&
|
(info?.kind === "final" || useStaticCard);
|
||||||
(info?.kind === "final" || useStaticCard);
|
const finalTextWouldUseStreamingCard = info?.kind === "final" && hasText && streamingEnabled;
|
||||||
const finalTextWouldUseStreamingCard =
|
const useCard = useStaticCard || useStreamingCard;
|
||||||
info?.kind === "final" && hasText && streamingEnabled;
|
const skipTextForDuplicateFinal =
|
||||||
const useCard = useStaticCard || useStreamingCard;
|
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
||||||
const skipTextForDuplicateFinal =
|
const skipTextForClosedStreamingFinal =
|
||||||
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
info?.kind === "final" &&
|
||||||
const skipTextForClosedStreamingFinal =
|
hasText &&
|
||||||
info?.kind === "final" &&
|
streamingClosedForReply &&
|
||||||
hasText &&
|
!streamingCloseErroredForReply &&
|
||||||
streamingClosedForReply &&
|
finalTextWouldUseStreamingCard;
|
||||||
!streamingCloseErroredForReply &&
|
const shouldDeliverText =
|
||||||
finalTextWouldUseStreamingCard;
|
hasText && !hasVoiceMedia && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
|
||||||
const shouldDeliverText =
|
const shouldDiscardStreamingPreview =
|
||||||
hasText &&
|
info?.kind === "final" &&
|
||||||
!hasVoiceMedia &&
|
(finalTextExceedsStreamingLimit ||
|
||||||
!skipTextForDuplicateFinal &&
|
(hasMedia &&
|
||||||
!skipTextForClosedStreamingFinal;
|
((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) ||
|
||||||
const shouldDiscardStreamingPreview =
|
skipTextForDuplicateFinal)));
|
||||||
info?.kind === "final" &&
|
|
||||||
(finalTextExceedsStreamingLimit ||
|
|
||||||
(hasMedia &&
|
|
||||||
((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) ||
|
|
||||||
skipTextForDuplicateFinal)));
|
|
||||||
|
|
||||||
if (!shouldDeliverText && !hasMedia) {
|
if (!shouldDeliverText && !hasMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDiscardStreamingPreview) {
|
if (shouldDiscardStreamingPreview) {
|
||||||
await discardStreamingPreview();
|
await discardStreamingPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDeliverText) {
|
if (shouldDeliverText) {
|
||||||
if (info?.kind === "block") {
|
if (info?.kind === "block") {
|
||||||
// Drop internal block chunks unless we can safely consume them as
|
// Drop internal block chunks unless we can safely consume them as
|
||||||
// streaming-card fallback content or send them as independent
|
// streaming-card fallback content or send them as independent
|
||||||
// messages for true progressive delivery.
|
// messages for true progressive delivery.
|
||||||
if (!useStreamingCard) {
|
if (!useStreamingCard) {
|
||||||
if (coreBlockStreamingEnabled) {
|
if (coreBlockStreamingEnabled) {
|
||||||
// Reuse normal text chunking, but notify mentions only on the first visible chunk.
|
// Reuse normal text chunking, but notify mentions only on the first visible chunk.
|
||||||
const isFirstBlock = !sentIndependentBlockText;
|
const isFirstBlock = !sentIndependentBlockText;
|
||||||
const firstChunkMentions =
|
const firstChunkMentions =
|
||||||
isFirstBlock && mentionTargets?.length ? mentionTargets : undefined;
|
isFirstBlock && mentionTargets?.length ? mentionTargets : undefined;
|
||||||
await sendChunkedTextReply({
|
await sendChunkedTextReply({
|
||||||
text,
|
text,
|
||||||
useCard: false,
|
useCard: false,
|
||||||
infoKind: "block",
|
infoKind: "block",
|
||||||
firstChunkMentions,
|
firstChunkMentions,
|
||||||
sendChunk: async ({ chunk, isFirst }) => {
|
sendChunk: async ({ chunk, isFirst }) => {
|
||||||
await sendMessageFeishu({
|
await sendMessageFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
to: sendTarget,
|
to: sendTarget,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
replyToMessageId: sendReplyToMessageId,
|
replyToMessageId: sendReplyToMessageId,
|
||||||
replyInThread: effectiveReplyInThread,
|
replyInThread: effectiveReplyInThread,
|
||||||
allowTopLevelReplyFallback,
|
allowTopLevelReplyFallback,
|
||||||
accountId,
|
accountId,
|
||||||
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
|
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
sentIndependentBlockText = true;
|
sentIndependentBlockText = true;
|
||||||
if (hasMedia) {
|
if (hasMedia) {
|
||||||
await sendMediaReplies(payload);
|
await sendMediaReplies(payload);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
startStreaming();
|
|
||||||
if (streamingStartPromise) {
|
|
||||||
await streamingStartPromise;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info?.kind === "final" && useStreamingCard) {
|
|
||||||
startStreaming();
|
|
||||||
if (streamingStartPromise) {
|
|
||||||
await streamingStartPromise;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldStreamText = info?.kind === "block" || info?.kind === "final";
|
|
||||||
if (streaming?.isActive() && shouldStreamText) {
|
|
||||||
if (info?.kind === "block") {
|
|
||||||
// Some runtimes emit block payloads without onPartial/final callbacks.
|
|
||||||
// Mirror block text into streamText so onIdle close still sends content.
|
|
||||||
queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
|
|
||||||
}
|
|
||||||
if (info?.kind === "final") {
|
|
||||||
// Final payloads can be cumulative snapshots or independent
|
|
||||||
// notices. Preserve both when the latter arrives after an answer.
|
|
||||||
streamText = text;
|
|
||||||
hasStreamingFinalText = true;
|
|
||||||
snapshotBaseText = "";
|
|
||||||
lastSnapshotTextLength = text.length;
|
|
||||||
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
|
|
||||||
}
|
|
||||||
// Send media even when streaming handled the text
|
|
||||||
if (hasMedia) {
|
|
||||||
await sendMediaReplies(payload);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
startStreaming();
|
||||||
if (useCard) {
|
if (streamingStartPromise) {
|
||||||
const cardHeader = resolveCardHeader(agentId, identity);
|
await streamingStartPromise;
|
||||||
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
|
|
||||||
await sendChunkedTextReply({
|
|
||||||
text,
|
|
||||||
useCard: true,
|
|
||||||
infoKind: info?.kind,
|
|
||||||
sendChunk: async ({ chunk }) => {
|
|
||||||
await sendStructuredCardFeishu({
|
|
||||||
cfg,
|
|
||||||
to: sendTarget,
|
|
||||||
text: chunk,
|
|
||||||
replyToMessageId: sendReplyToMessageId,
|
|
||||||
replyInThread: effectiveReplyInThread,
|
|
||||||
allowTopLevelReplyFallback,
|
|
||||||
accountId,
|
|
||||||
header: cardHeader,
|
|
||||||
note: cardNote,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const firstChunkMentions =
|
|
||||||
info?.kind === "final" && mentionTargets?.length ? mentionTargets : undefined;
|
|
||||||
await sendChunkedTextReply({
|
|
||||||
text,
|
|
||||||
useCard: false,
|
|
||||||
infoKind: info?.kind,
|
|
||||||
firstChunkMentions,
|
|
||||||
sendChunk: async ({ chunk, isFirst }) => {
|
|
||||||
await sendMessageFeishu({
|
|
||||||
cfg,
|
|
||||||
to: sendTarget,
|
|
||||||
text: chunk,
|
|
||||||
replyToMessageId: sendReplyToMessageId,
|
|
||||||
replyInThread: effectiveReplyInThread,
|
|
||||||
allowTopLevelReplyFallback,
|
|
||||||
accountId,
|
|
||||||
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMedia) {
|
if (info?.kind === "final" && useStreamingCard) {
|
||||||
await sendMediaReplies(
|
startStreaming();
|
||||||
payload,
|
if (streamingStartPromise) {
|
||||||
hasVoiceMedia && hasText ? { fallbackText: text } : undefined,
|
await streamingStartPromise;
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onError: async (error, info) => {
|
const shouldStreamText = info?.kind === "block" || info?.kind === "final";
|
||||||
streamingCloseErroredForReply = true;
|
if (streaming?.isActive() && shouldStreamText) {
|
||||||
streamingClosedForReply = false;
|
if (info?.kind === "block") {
|
||||||
params.runtime.error?.(
|
// Some runtimes emit block payloads without onPartial/final callbacks.
|
||||||
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
|
// Mirror block text into streamText so onIdle close still sends content.
|
||||||
|
queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
|
||||||
|
}
|
||||||
|
if (info?.kind === "final") {
|
||||||
|
// Final payloads can be cumulative snapshots or independent
|
||||||
|
// notices. Preserve both when the latter arrives after an answer.
|
||||||
|
streamText = text;
|
||||||
|
hasStreamingFinalText = true;
|
||||||
|
snapshotBaseText = "";
|
||||||
|
lastSnapshotTextLength = text.length;
|
||||||
|
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
|
||||||
|
}
|
||||||
|
// Send media even when streaming handled the text
|
||||||
|
if (hasMedia) {
|
||||||
|
await sendMediaReplies(payload);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useCard) {
|
||||||
|
const cardHeader = resolveCardHeader(agentId, identity);
|
||||||
|
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
|
||||||
|
await sendChunkedTextReply({
|
||||||
|
text,
|
||||||
|
useCard: true,
|
||||||
|
infoKind: info?.kind,
|
||||||
|
sendChunk: async ({ chunk }) => {
|
||||||
|
await sendStructuredCardFeishu({
|
||||||
|
cfg,
|
||||||
|
to: sendTarget,
|
||||||
|
text: chunk,
|
||||||
|
replyToMessageId: sendReplyToMessageId,
|
||||||
|
replyInThread: effectiveReplyInThread,
|
||||||
|
allowTopLevelReplyFallback,
|
||||||
|
accountId,
|
||||||
|
header: cardHeader,
|
||||||
|
note: cardNote,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const firstChunkMentions =
|
||||||
|
info?.kind === "final" && mentionTargets?.length ? mentionTargets : undefined;
|
||||||
|
await sendChunkedTextReply({
|
||||||
|
text,
|
||||||
|
useCard: false,
|
||||||
|
infoKind: info?.kind,
|
||||||
|
firstChunkMentions,
|
||||||
|
sendChunk: async ({ chunk, isFirst }) => {
|
||||||
|
await sendMessageFeishu({
|
||||||
|
cfg,
|
||||||
|
to: sendTarget,
|
||||||
|
text: chunk,
|
||||||
|
replyToMessageId: sendReplyToMessageId,
|
||||||
|
replyInThread: effectiveReplyInThread,
|
||||||
|
allowTopLevelReplyFallback,
|
||||||
|
accountId,
|
||||||
|
...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMedia) {
|
||||||
|
await sendMediaReplies(
|
||||||
|
payload,
|
||||||
|
hasVoiceMedia && hasText ? { fallbackText: text } : undefined,
|
||||||
);
|
);
|
||||||
await queueIdleSideEffects({ markClosedForReply: false });
|
}
|
||||||
},
|
},
|
||||||
onIdle: () => queueIdleSideEffects(),
|
onError: async (error, info) => {
|
||||||
onCleanup: () => {
|
streamingCloseErroredForReply = true;
|
||||||
typingCallbacks?.onCleanup?.();
|
streamingClosedForReply = false;
|
||||||
},
|
params.runtime.error?.(
|
||||||
});
|
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
|
||||||
|
);
|
||||||
|
await queueIdleSideEffects({ markClosedForReply: false });
|
||||||
|
},
|
||||||
|
onIdle: () => queueIdleSideEffects(),
|
||||||
|
onCleanup: () => {
|
||||||
|
typingCallbacks?.onCleanup?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dispatcher,
|
dispatcher,
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export type {
|
|||||||
} from "openclaw/plugin-sdk/config-contracts";
|
} from "openclaw/plugin-sdk/config-contracts";
|
||||||
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||||
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
||||||
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
||||||
export {
|
export {
|
||||||
registerWebhookTargetWithPluginRoute,
|
registerWebhookTargetWithPluginRoute,
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ const routingMocks = vi.hoisted(() => ({
|
|||||||
| undefined,
|
| undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const inboundMocks = vi.hoisted(() => ({
|
||||||
|
buildEnvelope: vi.fn(({ body }: { body: string }) => body),
|
||||||
|
resolveChannelInboundRouteEnvelope: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveChannelInboundRouteEnvelope: inboundMocks.resolveChannelInboundRouteEnvelope,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
|
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
|
||||||
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
|
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
|
||||||
@@ -43,34 +56,29 @@ beforeEach(() => {
|
|||||||
apiMocks.downloadGoogleChatMedia.mockReset();
|
apiMocks.downloadGoogleChatMedia.mockReset();
|
||||||
apiMocks.sendGoogleChatMessage.mockReset();
|
apiMocks.sendGoogleChatMessage.mockReset();
|
||||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
|
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
|
||||||
|
inboundMocks.buildEnvelope.mockReset().mockImplementation(({ body }: { body: string }) => body);
|
||||||
|
inboundMocks.resolveChannelInboundRouteEnvelope
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(({ accountId }: { accountId: string }) => ({
|
||||||
|
route: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
accountId,
|
||||||
|
sessionKey: "session-1",
|
||||||
|
},
|
||||||
|
buildEnvelope: inboundMocks.buildEnvelope,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
function createInboundClassificationHarness() {
|
function createInboundClassificationHarness() {
|
||||||
const resolveAgentRoute = vi.fn(() => ({
|
|
||||||
agentId: "agent-1",
|
|
||||||
accountId: "work",
|
|
||||||
sessionKey: "session-1",
|
|
||||||
}));
|
|
||||||
const buildContext = vi.fn((payload: unknown) => payload);
|
const buildContext = vi.fn((payload: unknown) => payload);
|
||||||
const runTurn = vi.fn();
|
const runTurn = vi.fn();
|
||||||
const core = {
|
const core = {
|
||||||
logging: { shouldLogVerbose: () => false },
|
logging: { shouldLogVerbose: () => false },
|
||||||
channel: {
|
channel: {
|
||||||
routing: { resolveAgentRoute },
|
|
||||||
session: {
|
|
||||||
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
|
|
||||||
readSessionUpdatedAt: () => undefined,
|
|
||||||
recordInboundSession: vi.fn(),
|
|
||||||
},
|
|
||||||
reply: {
|
|
||||||
resolveEnvelopeFormatOptions: () => ({}),
|
|
||||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
|
||||||
},
|
|
||||||
inbound: { buildContext, run: runTurn },
|
inbound: { buildContext, run: runTurn },
|
||||||
},
|
},
|
||||||
} as unknown as GoogleChatCoreRuntime;
|
} as unknown as GoogleChatCoreRuntime;
|
||||||
return { buildContext, core, resolveAgentRoute, runTurn };
|
return { buildContext, core, runTurn };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processGoogleChatTestEvent(params: {
|
async function processGoogleChatTestEvent(params: {
|
||||||
@@ -177,7 +185,7 @@ describe("googlechat monitor inbound space classification", () => {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
|
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
|
||||||
const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness();
|
const { buildContext, core, runTurn } = createInboundClassificationHarness();
|
||||||
const account = {
|
const account = {
|
||||||
accountId: "work",
|
accountId: "work",
|
||||||
config: {},
|
config: {},
|
||||||
@@ -214,7 +222,7 @@ describe("googlechat monitor inbound space classification", () => {
|
|||||||
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
|
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ isGroup }),
|
expect.objectContaining({ isGroup }),
|
||||||
);
|
);
|
||||||
expect(resolveAgentRoute).toHaveBeenCalledWith({
|
expect(inboundMocks.resolveChannelInboundRouteEnvelope).toHaveBeenCalledWith({
|
||||||
cfg: {},
|
cfg: {},
|
||||||
channel: "googlechat",
|
channel: "googlechat",
|
||||||
accountId: "work",
|
accountId: "work",
|
||||||
@@ -509,7 +517,6 @@ describe("googlechat monitor direct messages", () => {
|
|||||||
it("drops invalid event timestamps from inbound runtime payloads", async () => {
|
it("drops invalid event timestamps from inbound runtime payloads", async () => {
|
||||||
const runTurn = vi.fn();
|
const runTurn = vi.fn();
|
||||||
const buildContext = vi.fn((payload: unknown) => payload);
|
const buildContext = vi.fn((payload: unknown) => payload);
|
||||||
const formatAgentEnvelope = vi.fn(({ body }: { body: string }) => body);
|
|
||||||
const core = {
|
const core = {
|
||||||
logging: { shouldLogVerbose: () => false },
|
logging: { shouldLogVerbose: () => false },
|
||||||
channel: {
|
channel: {
|
||||||
@@ -527,7 +534,7 @@ describe("googlechat monitor direct messages", () => {
|
|||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
resolveEnvelopeFormatOptions: () => ({}),
|
resolveEnvelopeFormatOptions: () => ({}),
|
||||||
formatAgentEnvelope,
|
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||||
},
|
},
|
||||||
inbound: { buildContext, run: runTurn },
|
inbound: { buildContext, run: runTurn },
|
||||||
@@ -569,7 +576,7 @@ describe("googlechat monitor direct messages", () => {
|
|||||||
mediaMaxMb: 10,
|
mediaMaxMb: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(formatAgentEnvelope).toHaveBeenCalledWith(
|
expect(inboundMocks.buildEnvelope).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timestamp: undefined }),
|
expect.objectContaining({ timestamp: undefined }),
|
||||||
);
|
);
|
||||||
expect(buildContext).toHaveBeenCalledWith(expect.objectContaining({ timestamp: undefined }));
|
expect(buildContext).toHaveBeenCalledWith(expect.objectContaining({ timestamp: undefined }));
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
// Googlechat plugin module implements monitor behavior.
|
// Googlechat plugin module implements monitor behavior.
|
||||||
import {
|
import {
|
||||||
recordChannelBotPairLoopAndCheckSuppression,
|
recordChannelBotPairLoopAndCheckSuppression,
|
||||||
|
resolveChannelInboundRouteEnvelope,
|
||||||
type ChannelBotLoopProtectionFacts,
|
type ChannelBotLoopProtectionFacts,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
|
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
|
||||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import type { OpenClawConfig } from "../runtime-api.js";
|
import type { OpenClawConfig } from "../runtime-api.js";
|
||||||
import {
|
import { resolveWebhookPath } from "../runtime-api.js";
|
||||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
|
||||||
resolveWebhookPath,
|
|
||||||
} from "../runtime-api.js";
|
|
||||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||||
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
|
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
|
||||||
@@ -252,7 +250,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
channel: "googlechat",
|
channel: "googlechat",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -260,8 +258,6 @@ async function processMessageWithPipeline(params: {
|
|||||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||||
id: spaceId,
|
id: spaceId,
|
||||||
},
|
},
|
||||||
runtime: core.channel,
|
|
||||||
sessionStore: config.session?.store,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mediaPath: string | undefined;
|
let mediaPath: string | undefined;
|
||||||
@@ -279,7 +275,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
? space.displayName || `space:${spaceId}`
|
? space.displayName || `space:${spaceId}`
|
||||||
: senderName || `user:${senderId}`;
|
: senderName || `user:${senderId}`;
|
||||||
const timestampMs = resolveGoogleChatTimestampMs(event.eventTime);
|
const timestampMs = resolveGoogleChatTimestampMs(event.eventTime);
|
||||||
const { storePath, body } = buildEnvelope({
|
const body = buildEnvelope({
|
||||||
channel: "Google Chat",
|
channel: "Google Chat",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: timestampMs,
|
timestamp: timestampMs,
|
||||||
@@ -399,13 +395,8 @@ async function processMessageWithPipeline(params: {
|
|||||||
cfg: config,
|
cfg: config,
|
||||||
channel: "googlechat",
|
channel: "googlechat",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
durable: (payload, info) =>
|
durable: (payload, info) =>
|
||||||
resolveGoogleChatDurableReplyOptions({
|
resolveGoogleChatDurableReplyOptions({
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
readChannelAllowFromStore,
|
readChannelAllowFromStore,
|
||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
|
||||||
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
||||||
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
|
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
|
||||||
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||||
@@ -1453,12 +1452,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
raw: decision,
|
raw: decision,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg,
|
||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
accountId: decision.route.accountId,
|
accountId: decision.route.accountId,
|
||||||
routeSessionKey: decision.route.sessionKey,
|
route: {
|
||||||
storePath,
|
agentId: decision.route.agentId,
|
||||||
|
sessionKey: decision.route.sessionKey,
|
||||||
|
},
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
updateLastRoute:
|
updateLastRoute:
|
||||||
!decision.isGroup && updateTarget
|
!decision.isGroup && updateTarget
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// Irc plugin module implements inbound behavior.
|
// Irc plugin module implements inbound behavior.
|
||||||
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
import {
|
||||||
|
buildChannelInboundEventContext,
|
||||||
|
logInboundDrop,
|
||||||
|
resolveChannelInboundRouteEnvelope,
|
||||||
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import {
|
import {
|
||||||
channelIngressRoutes,
|
channelIngressRoutes,
|
||||||
createChannelIngressResolver,
|
createChannelIngressResolver,
|
||||||
@@ -9,7 +13,6 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
|
|||||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
||||||
import {
|
import {
|
||||||
deliverFormattedTextWithAttachments,
|
deliverFormattedTextWithAttachments,
|
||||||
type OutboundReplyPayload,
|
type OutboundReplyPayload,
|
||||||
@@ -371,7 +374,7 @@ export async function handleIrcInbound(params: {
|
|||||||
? message.target
|
? message.target
|
||||||
: `#${message.target}`;
|
: `#${message.target}`;
|
||||||
const peerId = message.isGroup ? channelTarget : message.senderNick;
|
const peerId = message.isGroup ? channelTarget : message.senderNick;
|
||||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||||
cfg: config as OpenClawConfig,
|
cfg: config as OpenClawConfig,
|
||||||
channel: CHANNEL_ID,
|
channel: CHANNEL_ID,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -379,12 +382,10 @@ export async function handleIrcInbound(params: {
|
|||||||
kind: message.isGroup ? "group" : "direct",
|
kind: message.isGroup ? "group" : "direct",
|
||||||
id: peerId,
|
id: peerId,
|
||||||
},
|
},
|
||||||
runtime: core.channel,
|
|
||||||
sessionStore: config.session?.store,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
||||||
const { storePath, body } = buildEnvelope({
|
const body = buildEnvelope({
|
||||||
channel: "IRC",
|
channel: "IRC",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
@@ -394,41 +395,44 @@ export async function handleIrcInbound(params: {
|
|||||||
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
|
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
|
||||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
||||||
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = buildChannelInboundEventContext({
|
||||||
Body: body,
|
channel: CHANNEL_ID,
|
||||||
RawBody: rawBody,
|
accountId: route.accountId,
|
||||||
CommandBody: rawBody,
|
messageId: message.messageId,
|
||||||
From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
|
timestamp: message.timestamp,
|
||||||
To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
|
||||||
SessionKey: route.sessionKey,
|
sender: { id: senderDisplay, name: message.senderNick || undefined },
|
||||||
AccountId: route.accountId,
|
conversation: {
|
||||||
ChatType: message.isGroup ? "group" : "direct",
|
kind: message.isGroup ? "group" : "direct",
|
||||||
ConversationLabel: fromLabel,
|
id: peerId,
|
||||||
SenderName: message.senderNick || undefined,
|
label: fromLabel,
|
||||||
SenderId: senderDisplay,
|
},
|
||||||
GroupSubject: message.isGroup ? message.target : undefined,
|
route: {
|
||||||
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
agentId: route.agentId,
|
||||||
Provider: CHANNEL_ID,
|
accountId: route.accountId,
|
||||||
Surface: CHANNEL_ID,
|
routeSessionKey: route.sessionKey,
|
||||||
WasMentioned: message.isGroup ? wasMentioned : undefined,
|
},
|
||||||
MessageSid: message.messageId,
|
reply: {
|
||||||
Timestamp: message.timestamp,
|
to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||||
OriginatingChannel: CHANNEL_ID,
|
originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||||
OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
},
|
||||||
CommandAuthorized: commandAuthorized,
|
message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody },
|
||||||
|
access: {
|
||||||
|
commands: { authorized: commandAuthorized },
|
||||||
|
mentions: { canDetectMention: message.isGroup, wasMentioned },
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
GroupSubject: message.isGroup ? message.target : undefined,
|
||||||
|
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.inbound.dispatchReply({
|
await core.channel.inbound.dispatch({
|
||||||
cfg: config as OpenClawConfig,
|
cfg: config as OpenClawConfig,
|
||||||
channel: CHANNEL_ID,
|
channel: CHANNEL_ID,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
await deliverIrcReply({
|
await deliverIrcReply({
|
||||||
|
|||||||
@@ -191,13 +191,8 @@ export async function monitorLineProvider(
|
|||||||
cfg: config,
|
cfg: config,
|
||||||
channel: "line",
|
channel: "line",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath: ctx.turn.storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
record: ctx.turn.record,
|
record: ctx.turn.record,
|
||||||
replyPipeline: {},
|
replyPipeline: {},
|
||||||
...(deliveryControl.abortSignal
|
...(deliveryControl.abortSignal
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ async function setupMatrixTrace(recorder: WireRecorder) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The scripted steps stand in for the model run: dispatchReplyFromConfig
|
// The scripted steps stand in for the model run: dispatchInboundMessage
|
||||||
// stays pending until the script's final/cancel step settles it, so the
|
// stays pending until the script's final/cancel step settles it, so the
|
||||||
// handler's post-dispatch flow (including the finally-block draft abandon
|
// handler's post-dispatch flow (including the finally-block draft abandon
|
||||||
// path) runs exactly where the real run would settle.
|
// path) runs exactly where the real run would settle.
|
||||||
@@ -188,7 +188,7 @@ async function setupMatrixTrace(recorder: WireRecorder) {
|
|||||||
markRunComplete: () => {},
|
markRunComplete: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
dispatchReplyFromConfig: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
|
dispatchInboundMessage: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
|
||||||
capturedReplyOptions = args?.replyOptions;
|
capturedReplyOptions = args?.replyOptions;
|
||||||
notifyCaptured();
|
notifyCaptured();
|
||||||
const result = await runGate;
|
const result = await runGate;
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ function createAudioPreflightHarness(
|
|||||||
matchedBy: "binding.account",
|
matchedBy: "binding.account",
|
||||||
}),
|
}),
|
||||||
resolveStorePath: () => "/tmp/openclaw-test-session.json",
|
resolveStorePath: () => "/tmp/openclaw-test-session.json",
|
||||||
readSessionUpdatedAt: () => 123,
|
|
||||||
getRoomInfo: async () => ({
|
getRoomInfo: async () => ({
|
||||||
name: "Audio Room",
|
name: "Audio Room",
|
||||||
canonicalAlias: "#audio:example.org",
|
canonicalAlias: "#audio:example.org",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
|
|||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -119,24 +119,17 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown
|
|||||||
}) => {
|
}) => {
|
||||||
capturedOnError = params?.onError;
|
capturedOnError = params?.onError;
|
||||||
return {
|
return {
|
||||||
dispatcher: {},
|
dispatcher: {
|
||||||
|
markComplete: () => {},
|
||||||
|
waitForIdle: async () => {
|
||||||
|
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
|
||||||
|
},
|
||||||
|
},
|
||||||
replyOptions: {},
|
replyOptions: {},
|
||||||
markDispatchIdle: () => {},
|
markDispatchIdle: () => {},
|
||||||
markRunComplete: () => {},
|
markRunComplete: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
withReplyDispatcher: async <T>(params: {
|
|
||||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
|
||||||
run: () => Promise<T>;
|
|
||||||
onSettled?: () => void | Promise<void>;
|
|
||||||
}) => {
|
|
||||||
const result = await params.run();
|
|
||||||
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
|
|
||||||
params.dispatcher.markComplete?.();
|
|
||||||
await params.dispatcher.waitForIdle?.();
|
|
||||||
await params.onSettled?.();
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +166,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -200,7 +193,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
threadReplies: "off",
|
threadReplies: "off",
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -233,7 +226,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
threadReplies: "always",
|
threadReplies: "always",
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -267,7 +260,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)),
|
resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)),
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -313,7 +306,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -341,7 +334,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -371,7 +364,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
return "@bot:example.org";
|
return "@bot:example.org";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -394,7 +387,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
historyLimit: 20,
|
historyLimit: 20,
|
||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -428,7 +421,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
historyLimit: 20,
|
historyLimit: 20,
|
||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -458,7 +451,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -520,7 +513,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => {
|
|||||||
getRelations,
|
getRelations,
|
||||||
},
|
},
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -560,7 +553,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
|||||||
let firstDispatchStarted = false;
|
let firstDispatchStarted = false;
|
||||||
|
|
||||||
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
||||||
const dispatchReplyFromConfig = vi.fn(async () => {
|
const dispatchInboundMessage = vi.fn(async () => {
|
||||||
if (!firstDispatchStarted) {
|
if (!firstDispatchStarted) {
|
||||||
firstDispatchStarted = true;
|
firstDispatchStarted = true;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -575,7 +568,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
|||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 1: trigger msg A — don't await, let it block in dispatch
|
// Step 1: trigger msg A — don't await, let it block in dispatch
|
||||||
@@ -687,7 +680,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
|||||||
isDirectMessage: false,
|
isDirectMessage: false,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -739,7 +732,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
|
|||||||
getEvent: async () => ({ sender: "@bot:example.org" }),
|
getEvent: async () => ({ sender: "@bot:example.org" }),
|
||||||
},
|
},
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ function createMediaFailureHarness() {
|
|||||||
matchedBy: "binding.account",
|
matchedBy: "binding.account",
|
||||||
}),
|
}),
|
||||||
resolveStorePath: () => "/tmp/openclaw-test-session.json",
|
resolveStorePath: () => "/tmp/openclaw-test-session.json",
|
||||||
readSessionUpdatedAt: () => 123,
|
|
||||||
getRoomInfo: async () => ({
|
getRoomInfo: async () => ({
|
||||||
name: "Media Room",
|
name: "Media Room",
|
||||||
canonicalAlias: "#media:example.org",
|
canonicalAlias: "#media:example.org",
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ import { createMatrixRoomMessageHandler } from "./handler.js";
|
|||||||
import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js";
|
import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js";
|
||||||
|
|
||||||
type MatrixMonitorHandlerParams = Parameters<typeof createMatrixRoomMessageHandler>[0];
|
type MatrixMonitorHandlerParams = Parameters<typeof createMatrixRoomMessageHandler>[0];
|
||||||
|
type MatrixDispatchInboundMessage = (params: {
|
||||||
|
ctx: unknown;
|
||||||
|
cfg: unknown;
|
||||||
|
dispatcher: unknown;
|
||||||
|
replyOptions?: Record<string, unknown>;
|
||||||
|
}) => Promise<{
|
||||||
|
queuedFinal: boolean;
|
||||||
|
counts: { final: number; block: number; tool: number };
|
||||||
|
}>;
|
||||||
|
|
||||||
const DEFAULT_ROUTE = {
|
const DEFAULT_ROUTE = {
|
||||||
agentId: "ops",
|
agentId: "ops",
|
||||||
@@ -68,9 +77,7 @@ type MatrixHandlerTestHarnessOptions = {
|
|||||||
resolveMarkdownTableMode?: () => string;
|
resolveMarkdownTableMode?: () => string;
|
||||||
resolveAgentRoute?: () => typeof DEFAULT_ROUTE;
|
resolveAgentRoute?: () => typeof DEFAULT_ROUTE;
|
||||||
resolveStorePath?: () => string;
|
resolveStorePath?: () => string;
|
||||||
readSessionUpdatedAt?: () => number | undefined;
|
|
||||||
recordInboundSession?: (...args: unknown[]) => Promise<void>;
|
recordInboundSession?: (...args: unknown[]) => Promise<void>;
|
||||||
resolveEnvelopeFormatOptions?: () => Record<string, never>;
|
|
||||||
formatAgentEnvelope?: ({ body }: { body: string }) => string;
|
formatAgentEnvelope?: ({ body }: { body: string }) => string;
|
||||||
finalizeInboundContext?: (ctx: unknown) => unknown;
|
finalizeInboundContext?: (ctx: unknown) => unknown;
|
||||||
createReplyDispatcherWithTyping?: (params?: {
|
createReplyDispatcherWithTyping?: (params?: {
|
||||||
@@ -82,19 +89,8 @@ type MatrixHandlerTestHarnessOptions = {
|
|||||||
markRunComplete: () => void;
|
markRunComplete: () => void;
|
||||||
};
|
};
|
||||||
resolveHumanDelayConfig?: () => undefined;
|
resolveHumanDelayConfig?: () => undefined;
|
||||||
dispatchReplyFromConfig?: () => Promise<{
|
dispatchInboundMessage?: MatrixDispatchInboundMessage;
|
||||||
queuedFinal: boolean;
|
|
||||||
counts: { final: number; block: number; tool: number };
|
|
||||||
}>;
|
|
||||||
runPrepared?: MatrixRunPreparedMock;
|
runPrepared?: MatrixRunPreparedMock;
|
||||||
withReplyDispatcher?: <T>(params: {
|
|
||||||
dispatcher: {
|
|
||||||
markComplete?: () => void;
|
|
||||||
waitForIdle?: () => Promise<void>;
|
|
||||||
};
|
|
||||||
run: () => Promise<T>;
|
|
||||||
onSettled?: () => void | Promise<void>;
|
|
||||||
}) => Promise<T>;
|
|
||||||
inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"];
|
inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"];
|
||||||
shouldAckReaction?: () => boolean;
|
shouldAckReaction?: () => boolean;
|
||||||
enqueueSystemEvent?: (...args: unknown[]) => void;
|
enqueueSystemEvent?: (...args: unknown[]) => void;
|
||||||
@@ -104,10 +100,7 @@ type MatrixHandlerTestHarnessOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type MatrixHandlerTestHarness = {
|
type MatrixHandlerTestHarness = {
|
||||||
dispatchReplyFromConfig: () => Promise<{
|
dispatchInboundMessage: MatrixDispatchInboundMessage;
|
||||||
queuedFinal: boolean;
|
|
||||||
counts: { final: number; block: number; tool: number };
|
|
||||||
}>;
|
|
||||||
enqueueSystemEvent: (...args: unknown[]) => void;
|
enqueueSystemEvent: (...args: unknown[]) => void;
|
||||||
finalizeInboundContext: (ctx: unknown) => unknown;
|
finalizeInboundContext: (ctx: unknown) => unknown;
|
||||||
handler: ReturnType<typeof createMatrixRoomMessageHandler>;
|
handler: ReturnType<typeof createMatrixRoomMessageHandler>;
|
||||||
@@ -137,12 +130,55 @@ export function createMatrixHandlerTestHarness(
|
|||||||
? finalizeCoreInboundContext(ctx as Record<string, unknown>)
|
? finalizeCoreInboundContext(ctx as Record<string, unknown>)
|
||||||
: ctx,
|
: ctx,
|
||||||
);
|
);
|
||||||
const dispatchReplyFromConfig =
|
const dispatchInboundMessage =
|
||||||
options.dispatchReplyFromConfig ??
|
options.dispatchInboundMessage ??
|
||||||
(async () => ({
|
(async () => ({
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
counts: { final: 0, block: 0, tool: 0 },
|
counts: { final: 0, block: 0, tool: 0 },
|
||||||
}));
|
}));
|
||||||
|
const createReplyDispatcherWithTyping =
|
||||||
|
options.createReplyDispatcherWithTyping ??
|
||||||
|
(() => ({
|
||||||
|
dispatcher: {},
|
||||||
|
replyOptions: {},
|
||||||
|
markDispatchIdle: () => {},
|
||||||
|
markRunComplete: () => {},
|
||||||
|
}));
|
||||||
|
const dispatchInboundMessageWithBufferedDispatcher = (async ({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions,
|
||||||
|
replyOptions,
|
||||||
|
}: {
|
||||||
|
ctx: unknown;
|
||||||
|
cfg: unknown;
|
||||||
|
dispatcherOptions: Record<string, unknown>;
|
||||||
|
replyOptions?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
const prepared = createReplyDispatcherWithTyping(dispatcherOptions);
|
||||||
|
try {
|
||||||
|
return await dispatchInboundMessage({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcher: prepared.dispatcher,
|
||||||
|
replyOptions: { ...replyOptions, ...prepared.replyOptions },
|
||||||
|
} as never);
|
||||||
|
} finally {
|
||||||
|
const dispatcher = prepared.dispatcher as {
|
||||||
|
markComplete?: () => void;
|
||||||
|
waitForIdle?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
dispatcher.markComplete?.();
|
||||||
|
await dispatcher.waitForIdle?.();
|
||||||
|
await (dispatcherOptions.onSettled as (() => Promise<void> | void) | undefined)?.();
|
||||||
|
prepared.markRunComplete();
|
||||||
|
prepared.markDispatchIdle();
|
||||||
|
}
|
||||||
|
}) as NonNullable<MatrixMonitorHandlerParams["dispatchInboundMessageWithBufferedDispatcher"]>;
|
||||||
|
const createChannelInboundEnvelopeBuilder = (() => (input: { body: string }) =>
|
||||||
|
(options.formatAgentEnvelope ?? (({ body }: { body: string }) => body))({
|
||||||
|
body: input.body,
|
||||||
|
})) as NonNullable<MatrixMonitorHandlerParams["createChannelInboundEnvelopeBuilder"]>;
|
||||||
const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn();
|
const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn();
|
||||||
const runPrepared =
|
const runPrepared =
|
||||||
options.runPrepared ??
|
options.runPrepared ??
|
||||||
@@ -184,7 +220,16 @@ export function createMatrixHandlerTestHarness(
|
|||||||
: (preflightResult ?? {});
|
: (preflightResult ?? {});
|
||||||
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
||||||
if ("runDispatch" in turn) {
|
if ("runDispatch" in turn) {
|
||||||
return await runPrepared(turn);
|
const preparedTurn =
|
||||||
|
"route" in turn
|
||||||
|
? ({
|
||||||
|
...turn,
|
||||||
|
routeSessionKey: turn.route.sessionKey,
|
||||||
|
storePath: "/tmp/matrix-sessions.json",
|
||||||
|
recordInboundSession,
|
||||||
|
} as PreparedInboundReply<unknown>)
|
||||||
|
: turn;
|
||||||
|
return await runPrepared(preparedTurn);
|
||||||
}
|
}
|
||||||
throw new Error("matrix test helper only supports prepared turn dispatch");
|
throw new Error("matrix test helper only supports prepared turn dispatch");
|
||||||
},
|
},
|
||||||
@@ -233,47 +278,19 @@ export function createMatrixHandlerTestHarness(
|
|||||||
buildMentionRegexes: () => options.mentionRegexes ?? [],
|
buildMentionRegexes: () => options.mentionRegexes ?? [],
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
|
|
||||||
readSessionUpdatedAt: options.readSessionUpdatedAt ?? (() => undefined),
|
|
||||||
recordInboundSession,
|
recordInboundSession,
|
||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})),
|
settleReplyDispatcher: async ({
|
||||||
formatAgentEnvelope:
|
dispatcher,
|
||||||
options.formatAgentEnvelope ?? (({ body }: { body: string }) => body),
|
onSettled,
|
||||||
finalizeInboundContext,
|
}: Parameters<
|
||||||
createReplyDispatcherWithTyping:
|
MatrixMonitorHandlerParams["core"]["channel"]["reply"]["settleReplyDispatcher"]
|
||||||
options.createReplyDispatcherWithTyping ??
|
>[0]) => {
|
||||||
(() => ({
|
dispatcher.markComplete?.();
|
||||||
dispatcher: {},
|
await dispatcher.waitForIdle?.();
|
||||||
replyOptions: {},
|
await onSettled?.();
|
||||||
markDispatchIdle: () => {},
|
},
|
||||||
markRunComplete: () => {},
|
|
||||||
})),
|
|
||||||
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
|
|
||||||
dispatchReplyFromConfig,
|
|
||||||
withReplyDispatcher:
|
|
||||||
options.withReplyDispatcher ??
|
|
||||||
(async <T>(params: {
|
|
||||||
dispatcher: {
|
|
||||||
markComplete?: () => void;
|
|
||||||
waitForIdle?: () => Promise<void>;
|
|
||||||
};
|
|
||||||
run: () => Promise<T>;
|
|
||||||
onSettled?: () => void | Promise<void>;
|
|
||||||
}) => {
|
|
||||||
const { dispatcher, run: runLocal, onSettled } = params;
|
|
||||||
try {
|
|
||||||
return await runLocal();
|
|
||||||
} finally {
|
|
||||||
dispatcher.markComplete?.();
|
|
||||||
try {
|
|
||||||
await dispatcher.waitForIdle?.();
|
|
||||||
} finally {
|
|
||||||
await onSettled?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
inbound: {
|
inbound: {
|
||||||
run,
|
run,
|
||||||
@@ -332,11 +349,16 @@ export function createMatrixHandlerTestHarness(
|
|||||||
getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"),
|
getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"),
|
||||||
needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false,
|
needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false,
|
||||||
resolveLiveUserAllowlist: options.resolveLiveUserAllowlist,
|
resolveLiveUserAllowlist: options.resolveLiveUserAllowlist,
|
||||||
|
resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"),
|
||||||
|
createChannelInboundEnvelopeBuilder,
|
||||||
|
finalizeInboundContext,
|
||||||
|
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
|
||||||
|
dispatchInboundMessageWithBufferedDispatcher,
|
||||||
historyLimit: options.historyLimit ?? 0,
|
historyLimit: options.historyLimit ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
handler,
|
handler,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
|
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
|
||||||
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
|
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
|
||||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
|
||||||
import {
|
import {
|
||||||
createMatrixHandlerTestHarness,
|
createMatrixHandlerTestHarness,
|
||||||
createMatrixReactionEvent,
|
createMatrixReactionEvent,
|
||||||
@@ -527,6 +526,10 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
getMemberDisplayName: async () => "sender",
|
getMemberDisplayName: async () => "sender",
|
||||||
dropPreStartupMessages: true,
|
dropPreStartupMessages: true,
|
||||||
needsRoomAliasesForConfig: false,
|
needsRoomAliasesForConfig: false,
|
||||||
|
dispatchInboundMessage: async () => ({
|
||||||
|
queuedFinal: true,
|
||||||
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(
|
await handler(
|
||||||
@@ -580,12 +583,12 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not enqueue delivered text messages into system events", async () => {
|
it("does not enqueue delivered text messages into system events", async () => {
|
||||||
const dispatchReplyFromConfig = vi.fn(async () => ({
|
const dispatchInboundMessage = vi.fn(async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
}));
|
}));
|
||||||
const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({
|
const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
getMemberDisplayName: async () => "sender",
|
getMemberDisplayName: async () => "sender",
|
||||||
});
|
});
|
||||||
@@ -599,7 +602,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalled();
|
expect(dispatchInboundMessage).toHaveBeenCalled();
|
||||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1275,7 +1278,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
resolveNotice = resolve;
|
resolveNotice = resolve;
|
||||||
});
|
});
|
||||||
const sendNotice = vi.fn(() => noticeSent);
|
const sendNotice = vi.fn(() => noticeSent);
|
||||||
const dispatchReplyFromConfig = vi.fn(async () => ({
|
const dispatchInboundMessage = vi.fn(async () => ({
|
||||||
counts: { block: 0, final: 0, tool: 0 },
|
counts: { block: 0, final: 0, tool: 0 },
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
}));
|
}));
|
||||||
@@ -1289,7 +1292,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
resolveStorePath: () => storePath,
|
resolveStorePath: () => storePath,
|
||||||
client: {
|
client: {
|
||||||
@@ -1308,12 +1311,12 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(sendNotice).toHaveBeenCalledTimes(1);
|
expect(sendNotice).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
resolveNotice?.("$notice");
|
resolveNotice?.("$notice");
|
||||||
await handled;
|
await handled;
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -1613,7 +1616,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
altAliases: ["#alt:example.org"],
|
altAliases: ["#alt:example.org"],
|
||||||
}),
|
}),
|
||||||
getMemberDisplayName: async () => "sender",
|
getMemberDisplayName: async () => "sender",
|
||||||
dispatchReplyFromConfig: async () => ({
|
dispatchInboundMessage: async () => ({
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
counts: { final: 0, block: 0, tool: 0 },
|
counts: { final: 0, block: 0, tool: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -1755,121 +1758,13 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
|
|
||||||
it("does not enqueue system events for delivered text replies", async () => {
|
it("does not enqueue system events for delivered text replies", async () => {
|
||||||
const enqueueSystemEvent = vi.fn();
|
const enqueueSystemEvent = vi.fn();
|
||||||
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
const handler = createMatrixRoomMessageHandler({
|
enqueueSystemEvent,
|
||||||
client: {
|
isDirectMessage: false,
|
||||||
getUserId: async () => "@bot:example.org",
|
dispatchInboundMessage: async () => ({
|
||||||
} as never,
|
queuedFinal: true,
|
||||||
core: {
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
channel: {
|
}),
|
||||||
pairing: {
|
|
||||||
readAllowFromStore: async () => [] as string[],
|
|
||||||
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
|
|
||||||
buildPairingReply: () => "pairing",
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
shouldHandleTextCommands: () => false,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
hasControlCommand: () => false,
|
|
||||||
resolveMarkdownTableMode: () => "preserve",
|
|
||||||
},
|
|
||||||
routing: {
|
|
||||||
resolveAgentRoute: () => ({
|
|
||||||
agentId: "ops",
|
|
||||||
channel: "matrix",
|
|
||||||
accountId: "ops",
|
|
||||||
sessionKey: "agent:ops:main",
|
|
||||||
mainSessionKey: "agent:ops:main",
|
|
||||||
matchedBy: "binding.account",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
mentions: {
|
|
||||||
buildMentionRegexes: () => [],
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
resolveStorePath: () => "/tmp/session-store",
|
|
||||||
readSessionUpdatedAt: () => undefined,
|
|
||||||
recordInboundSession: vi.fn(async () => {}),
|
|
||||||
},
|
|
||||||
reply: {
|
|
||||||
resolveEnvelopeFormatOptions: () => ({}),
|
|
||||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
|
||||||
finalizeInboundContext: (ctx: unknown) => ctx,
|
|
||||||
createReplyDispatcherWithTyping: () => ({
|
|
||||||
dispatcher: {},
|
|
||||||
replyOptions: {},
|
|
||||||
markDispatchIdle: () => {},
|
|
||||||
markRunComplete: () => {},
|
|
||||||
}),
|
|
||||||
resolveHumanDelayConfig: () => undefined,
|
|
||||||
dispatchReplyFromConfig: async () => ({
|
|
||||||
queuedFinal: true,
|
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
|
||||||
}),
|
|
||||||
withReplyDispatcher: async <T>({
|
|
||||||
dispatcher,
|
|
||||||
run,
|
|
||||||
onSettled,
|
|
||||||
}: {
|
|
||||||
dispatcher: {
|
|
||||||
markComplete?: () => void;
|
|
||||||
waitForIdle?: () => Promise<void>;
|
|
||||||
};
|
|
||||||
run: () => Promise<T>;
|
|
||||||
onSettled?: () => void | Promise<void>;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
return await run();
|
|
||||||
} finally {
|
|
||||||
dispatcher.markComplete?.();
|
|
||||||
try {
|
|
||||||
await dispatcher.waitForIdle?.();
|
|
||||||
} finally {
|
|
||||||
await onSettled?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reactions: {
|
|
||||||
shouldAckReaction: () => false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
enqueueSystemEvent,
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
cfg: {} as never,
|
|
||||||
accountId: "ops",
|
|
||||||
runtime: {
|
|
||||||
error: () => {},
|
|
||||||
} as never,
|
|
||||||
logger: {
|
|
||||||
info: () => {},
|
|
||||||
warn: () => {},
|
|
||||||
} as never,
|
|
||||||
logVerboseMessage: () => {},
|
|
||||||
allowFrom: [],
|
|
||||||
groupPolicy: "open",
|
|
||||||
replyToMode: "off",
|
|
||||||
threadReplies: "inbound",
|
|
||||||
streaming: "off",
|
|
||||||
previewToolProgressEnabled: false,
|
|
||||||
blockStreamingEnabled: false,
|
|
||||||
dmEnabled: true,
|
|
||||||
dmPolicy: "open",
|
|
||||||
textLimit: 8_000,
|
|
||||||
mediaMaxBytes: 10_000_000,
|
|
||||||
historyLimit: 0,
|
|
||||||
startupMs: 0,
|
|
||||||
startupGraceMs: 0,
|
|
||||||
directTracker: {
|
|
||||||
isDirectMessage: async () => false,
|
|
||||||
},
|
|
||||||
dropPreStartupMessages: true,
|
|
||||||
getRoomInfo: async () => ({ altAliases: [] }),
|
|
||||||
getMemberDisplayName: async () => "sender",
|
|
||||||
needsRoomAliasesForConfig: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(
|
await handler(
|
||||||
@@ -2177,7 +2072,7 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||||||
describe("matrix monitor handler live allowlist reload", () => {
|
describe("matrix monitor handler live allowlist reload", () => {
|
||||||
type MatrixHandler = ReturnType<typeof createMatrixHandlerTestHarness>["handler"];
|
type MatrixHandler = ReturnType<typeof createMatrixHandlerTestHarness>["handler"];
|
||||||
|
|
||||||
const createDispatchReplyFromConfig = () =>
|
const createDispatchInboundMessage = () =>
|
||||||
vi.fn(async () => ({
|
vi.fn(async () => ({
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
counts: { final: 0, block: 0, tool: 0 },
|
counts: { final: 0, block: 0, tool: 0 },
|
||||||
@@ -2222,7 +2117,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
).length;
|
).length;
|
||||||
|
|
||||||
it("accepts a DM sender added to live dm.allowFrom", async () => {
|
it("accepts a DM sender added to live dm.allowFrom", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
@@ -2236,7 +2131,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
allowFromResolvedEntries: [],
|
allowFromResolvedEntries: [],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2244,7 +2139,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"];
|
cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"];
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2253,11 +2148,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
body: "hello again",
|
body: "hello again",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a DM sender removed from live dm.allowFrom", async () => {
|
it("blocks a DM sender removed from live dm.allowFrom", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
@@ -2271,7 +2166,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: ["@alice:example.org"],
|
allowFrom: ["@alice:example.org"],
|
||||||
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
|
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2279,7 +2174,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
cfg.channels.matrix.dm.allowFrom = [];
|
cfg.channels.matrix.dm.allowFrom = [];
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2288,11 +2183,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
body: "hello again",
|
body: "hello again",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a DM sender after live wildcard removal", async () => {
|
it("blocks a DM sender after live wildcard removal", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
@@ -2306,7 +2201,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
allowFromResolvedEntries: [],
|
allowFromResolvedEntries: [],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2314,7 +2209,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
cfg.channels.matrix.dm.allowFrom = [];
|
cfg.channels.matrix.dm.allowFrom = [];
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2323,11 +2218,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
body: "hello again",
|
body: "hello again",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses account-scoped live dm.allowFrom overrides", async () => {
|
it("uses account-scoped live dm.allowFrom overrides", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
@@ -2347,7 +2242,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: ["@alice:example.org"],
|
allowFrom: ["@alice:example.org"],
|
||||||
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
|
allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2355,7 +2250,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
cfg.channels.matrix.accounts.ops.dm.allowFrom = [];
|
cfg.channels.matrix.accounts.ops.dm.allowFrom = [];
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2364,11 +2259,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
body: "hello again",
|
body: "hello again",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps startup-resolved display names only while the raw input remains configured", async () => {
|
it("keeps startup-resolved display names only while the raw input remains configured", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
@@ -2383,7 +2278,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: ["@alice:example.org"],
|
allowFrom: ["@alice:example.org"],
|
||||||
allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }],
|
allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2391,7 +2286,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
cfg.channels.matrix.dm.allowFrom = [];
|
cfg.channels.matrix.dm.allowFrom = [];
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2400,11 +2295,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
body: "hello again",
|
body: "hello again",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a DM sender added as a live-resolved display name", async () => {
|
it("accepts a DM sender added as a live-resolved display name", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const resolveLiveUserAllowlist = vi.fn(
|
const resolveLiveUserAllowlist = vi.fn(
|
||||||
async (params: { entries?: ReadonlyArray<string | number> }) => {
|
async (params: { entries?: ReadonlyArray<string | number> }) => {
|
||||||
const entries = (params.entries ?? []).map(String);
|
const entries = (params.entries ?? []).map(String);
|
||||||
@@ -2425,7 +2320,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
allowFromResolvedEntries: [],
|
allowFromResolvedEntries: [],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
resolveLiveUserAllowlist,
|
resolveLiveUserAllowlist,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2434,7 +2329,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
cfg.channels.matrix.dm.allowFrom = ["Alice"];
|
cfg.channels.matrix.dm.allowFrom = ["Alice"];
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2449,11 +2344,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
);
|
);
|
||||||
expect(liveAllowlistRequest.accountId).toBe("ops");
|
expect(liveAllowlistRequest.accountId).toBe("ops");
|
||||||
expect(liveAllowlistRequest.entries).toEqual(["Alice"]);
|
expect(liveAllowlistRequest.entries).toEqual(["Alice"]);
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes cached live display-name allowlists when name matching is disabled", async () => {
|
it("refreshes cached live display-name allowlists when name matching is disabled", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
|
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
|
||||||
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
|
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
|
||||||
);
|
);
|
||||||
@@ -2471,7 +2366,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
allowFromResolvedEntries: [],
|
allowFromResolvedEntries: [],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
resolveLiveUserAllowlist,
|
resolveLiveUserAllowlist,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2480,7 +2375,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
cfg.channels.matrix.dangerouslyAllowNameMatching = false;
|
cfg.channels.matrix.dangerouslyAllowNameMatching = false;
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2492,11 +2387,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
|
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes cached live display-name allowlists when name matching is enabled", async () => {
|
it("refreshes cached live display-name allowlists when name matching is enabled", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
|
const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) =>
|
||||||
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
|
isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [],
|
||||||
);
|
);
|
||||||
@@ -2514,7 +2409,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
isDirectMessage: true,
|
isDirectMessage: true,
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
allowFromResolvedEntries: [],
|
allowFromResolvedEntries: [],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
resolveLiveUserAllowlist,
|
resolveLiveUserAllowlist,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2523,7 +2418,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
sender: "@alice:example.org",
|
sender: "@alice:example.org",
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(dispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
cfg.channels.matrix.dangerouslyAllowNameMatching = true;
|
cfg.channels.matrix.dangerouslyAllowNameMatching = true;
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2535,11 +2430,11 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
|
expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe(
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => {
|
it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => {
|
||||||
const dispatchReplyFromConfig = createDispatchReplyFromConfig();
|
const dispatchInboundMessage = createDispatchInboundMessage();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
matrix: {
|
matrix: {
|
||||||
@@ -2557,7 +2452,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
{ input: "@alice:example.org", id: "@alice:example.org" },
|
{ input: "@alice:example.org", id: "@alice:example.org" },
|
||||||
{ input: "@bob:example.org", id: "@bob:example.org" },
|
{ input: "@bob:example.org", id: "@bob:example.org" },
|
||||||
],
|
],
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2567,7 +2462,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
body: "@room hello",
|
body: "@room hello",
|
||||||
mentions: { room: true },
|
mentions: { room: true },
|
||||||
});
|
});
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"];
|
cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"];
|
||||||
await sendLiveAllowlistMessage(handler, {
|
await sendLiveAllowlistMessage(handler, {
|
||||||
@@ -2578,7 +2473,7 @@ describe("matrix monitor handler live allowlist reload", () => {
|
|||||||
mentions: { room: true },
|
mentions: { room: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2589,7 +2484,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
};
|
};
|
||||||
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
|
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
|
||||||
inboundDeduper,
|
inboundDeduper,
|
||||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
dispatchInboundMessage: vi.fn(async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
})),
|
})),
|
||||||
@@ -2631,7 +2526,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
const recordInboundSession = vi.fn(async () => {
|
const recordInboundSession = vi.fn(async () => {
|
||||||
callOrder.push("record");
|
callOrder.push("record");
|
||||||
});
|
});
|
||||||
const dispatchReplyFromConfig = vi.fn(async () => {
|
const dispatchInboundMessage = vi.fn(async () => {
|
||||||
callOrder.push("dispatch");
|
callOrder.push("dispatch");
|
||||||
return {
|
return {
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
@@ -2641,7 +2536,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
inboundDeduper,
|
inboundDeduper,
|
||||||
recordInboundSession,
|
recordInboundSession,
|
||||||
dispatchReplyFromConfig,
|
dispatchInboundMessage,
|
||||||
createReplyDispatcherWithTyping: () => ({
|
createReplyDispatcherWithTyping: () => ({
|
||||||
dispatcher: {
|
dispatcher: {
|
||||||
markComplete: () => {
|
markComplete: () => {
|
||||||
@@ -2673,9 +2568,9 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
"claim",
|
"claim",
|
||||||
"record",
|
"record",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"run-complete",
|
|
||||||
"mark-complete",
|
"mark-complete",
|
||||||
"wait-for-idle",
|
"wait-for-idle",
|
||||||
|
"run-complete",
|
||||||
"dispatch-idle",
|
"dispatch-idle",
|
||||||
"commit",
|
"commit",
|
||||||
]);
|
]);
|
||||||
@@ -2742,7 +2637,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
recordInboundSession: vi.fn(async () => {
|
recordInboundSession: vi.fn(async () => {
|
||||||
throw new Error("disk failed");
|
throw new Error("disk failed");
|
||||||
}),
|
}),
|
||||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
dispatchInboundMessage: vi.fn(async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
})),
|
})),
|
||||||
@@ -2776,7 +2671,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
inboundDeduper,
|
inboundDeduper,
|
||||||
runtime: runtime as never,
|
runtime: runtime as never,
|
||||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
dispatchInboundMessage: vi.fn(async () => ({
|
||||||
queuedFinal: true,
|
queuedFinal: true,
|
||||||
counts: { final: 1, block: 0, tool: 0 },
|
counts: { final: 1, block: 0, tool: 0 },
|
||||||
})),
|
})),
|
||||||
@@ -2823,7 +2718,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
inboundDeduper,
|
inboundDeduper,
|
||||||
runtime: runtime as never,
|
runtime: runtime as never,
|
||||||
dispatchReplyFromConfig: vi.fn(async () => ({
|
dispatchInboundMessage: vi.fn(async () => ({
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
counts: {
|
counts: {
|
||||||
final: 0,
|
final: 0,
|
||||||
@@ -2881,7 +2776,7 @@ describe("matrix monitor handler durable inbound dedupe", () => {
|
|||||||
recordInboundSession: vi.fn(async () => {
|
recordInboundSession: vi.fn(async () => {
|
||||||
callOrder.push("record");
|
callOrder.push("record");
|
||||||
}),
|
}),
|
||||||
dispatchReplyFromConfig: vi.fn(async () => {
|
dispatchInboundMessage: vi.fn(async () => {
|
||||||
callOrder.push("dispatch");
|
callOrder.push("dispatch");
|
||||||
return {
|
return {
|
||||||
queuedFinal: false,
|
queuedFinal: false,
|
||||||
@@ -3031,22 +2926,13 @@ describe("matrix monitor handler draft streaming", () => {
|
|||||||
markRunComplete: () => {},
|
markRunComplete: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||||
capturedReplyOpts = args?.replyOptions;
|
capturedReplyOpts = args?.replyOptions;
|
||||||
notifyCaptured();
|
notifyCaptured();
|
||||||
// Block until the test is done exercising callbacks.
|
// Block until the test is done exercising callbacks.
|
||||||
await runGate;
|
await runGate;
|
||||||
return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
|
return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
|
||||||
}) as never,
|
}) as never,
|
||||||
withReplyDispatcher: async <T>(params: {
|
|
||||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
|
||||||
run: () => Promise<T>;
|
|
||||||
onSettled?: () => void | Promise<void>;
|
|
||||||
}) => {
|
|
||||||
const result = await params.run();
|
|
||||||
await params.onSettled?.();
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatch = async () => {
|
const dispatch = async () => {
|
||||||
@@ -4098,7 +3984,7 @@ describe("matrix monitor handler draft streaming", () => {
|
|||||||
markDispatchIdle: () => {},
|
markDispatchIdle: () => {},
|
||||||
markRunComplete: () => {},
|
markRunComplete: () => {},
|
||||||
}),
|
}),
|
||||||
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||||
capturedReplyOpts = args?.replyOptions;
|
capturedReplyOpts = args?.replyOptions;
|
||||||
// Simulate streaming then model error.
|
// Simulate streaming then model error.
|
||||||
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
|
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
|
||||||
@@ -4107,15 +3993,6 @@ describe("matrix monitor handler draft streaming", () => {
|
|||||||
});
|
});
|
||||||
throw new Error("model timeout");
|
throw new Error("model timeout");
|
||||||
}) as never,
|
}) as never,
|
||||||
withReplyDispatcher: async <T>(params: {
|
|
||||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
|
||||||
run: () => Promise<T>;
|
|
||||||
onSettled?: () => void | Promise<void>;
|
|
||||||
}) => {
|
|
||||||
const result = await params.run();
|
|
||||||
await params.onSettled?.();
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler should not throw (outer catch absorbs it).
|
// Handler should not throw (outer catch absorbs it).
|
||||||
@@ -4156,7 +4033,7 @@ describe("matrix monitor handler draft streaming", () => {
|
|||||||
markDispatchIdle: () => {},
|
markDispatchIdle: () => {},
|
||||||
markRunComplete: () => {},
|
markRunComplete: () => {},
|
||||||
}),
|
}),
|
||||||
dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
|
||||||
capturedReplyOpts = args?.replyOptions;
|
capturedReplyOpts = args?.replyOptions;
|
||||||
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
|
capturedReplyOpts?.onPartialReply?.({ text: "partial" });
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
@@ -4164,15 +4041,6 @@ describe("matrix monitor handler draft streaming", () => {
|
|||||||
});
|
});
|
||||||
throw new Error("model timeout");
|
throw new Error("model timeout");
|
||||||
}) as never,
|
}) as never,
|
||||||
withReplyDispatcher: async <T>(params: {
|
|
||||||
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
|
|
||||||
run: () => Promise<T>;
|
|
||||||
onSettled?: () => void | Promise<void>;
|
|
||||||
}) => {
|
|
||||||
const result = await params.run();
|
|
||||||
await params.onSettled?.();
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(
|
await handler(
|
||||||
@@ -4448,7 +4316,7 @@ describe("matrix monitor handler block streaming config", () => {
|
|||||||
|
|
||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
streaming: "off",
|
streaming: "off",
|
||||||
dispatchReplyFromConfig: vi.fn(
|
dispatchInboundMessage: vi.fn(
|
||||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||||
@@ -4469,7 +4337,7 @@ describe("matrix monitor handler block streaming config", () => {
|
|||||||
|
|
||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
streaming: "partial",
|
streaming: "partial",
|
||||||
dispatchReplyFromConfig: vi.fn(
|
dispatchInboundMessage: vi.fn(
|
||||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||||
@@ -4490,7 +4358,7 @@ describe("matrix monitor handler block streaming config", () => {
|
|||||||
|
|
||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
streaming: "quiet",
|
streaming: "quiet",
|
||||||
dispatchReplyFromConfig: vi.fn(
|
dispatchInboundMessage: vi.fn(
|
||||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||||
@@ -4512,7 +4380,7 @@ describe("matrix monitor handler block streaming config", () => {
|
|||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
streaming: "partial",
|
streaming: "partial",
|
||||||
blockStreamingEnabled: true,
|
blockStreamingEnabled: true,
|
||||||
dispatchReplyFromConfig: vi.fn(
|
dispatchInboundMessage: vi.fn(
|
||||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||||
@@ -4534,7 +4402,7 @@ describe("matrix monitor handler block streaming config", () => {
|
|||||||
const { handler } = createMatrixHandlerTestHarness({
|
const { handler } = createMatrixHandlerTestHarness({
|
||||||
streaming: "off",
|
streaming: "off",
|
||||||
blockStreamingEnabled: true,
|
blockStreamingEnabled: true,
|
||||||
dispatchReplyFromConfig: vi.fn(
|
dispatchInboundMessage: vi.fn(
|
||||||
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
|
||||||
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
|
||||||
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
// Matrix plugin module implements handler behavior.
|
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
import {
|
import {
|
||||||
buildChannelInboundEventContext,
|
buildChannelInboundEventContext,
|
||||||
|
createChannelInboundEnvelopeBuilder,
|
||||||
|
hasFinalInboundReplyDispatch,
|
||||||
resolveInboundMentionDecision,
|
resolveInboundMentionDecision,
|
||||||
toInboundMediaFacts,
|
toInboundMediaFacts,
|
||||||
|
type ChannelBotLoopProtectionFacts,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
|
|
||||||
import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-inbound";
|
|
||||||
import {
|
|
||||||
createPreviewMessageReceipt,
|
|
||||||
defineFinalizableLivePreviewAdapter,
|
|
||||||
deliverWithFinalizableLivePreviewAdapter,
|
|
||||||
type MessageReceipt,
|
|
||||||
} from "openclaw/plugin-sdk/channel-outbound";
|
|
||||||
import {
|
import {
|
||||||
type AgentPlanStep,
|
type AgentPlanStep,
|
||||||
buildChannelProgressDraftLineForEntry,
|
buildChannelProgressDraftLineForEntry,
|
||||||
createChannelProgressDraftGate,
|
|
||||||
type ChannelProgressDraftLine,
|
type ChannelProgressDraftLine,
|
||||||
|
createChannelProgressDraftGate,
|
||||||
|
createPreviewMessageReceipt,
|
||||||
|
defineFinalizableLivePreviewAdapter,
|
||||||
|
deliverWithFinalizableLivePreviewAdapter,
|
||||||
formatChannelProgressDraftLine,
|
formatChannelProgressDraftLine,
|
||||||
formatChannelProgressDraftText,
|
formatChannelProgressDraftText,
|
||||||
isChannelProgressDraftWorkToolName,
|
isChannelProgressDraftWorkToolName,
|
||||||
mergeChannelProgressDraftLine,
|
mergeChannelProgressDraftLine,
|
||||||
normalizeChannelProgressDraftLineIdentity,
|
normalizeChannelProgressDraftLineIdentity,
|
||||||
resolveChannelProgressDraftMaxLines,
|
resolveChannelProgressDraftMaxLines,
|
||||||
|
type MessageReceipt,
|
||||||
} from "openclaw/plugin-sdk/channel-outbound";
|
} from "openclaw/plugin-sdk/channel-outbound";
|
||||||
import {
|
import {
|
||||||
evaluateSupplementalContextVisibility,
|
evaluateSupplementalContextVisibility,
|
||||||
@@ -41,10 +40,13 @@ import {
|
|||||||
buildTtsSupplementMediaPayload,
|
buildTtsSupplementMediaPayload,
|
||||||
getReplyPayloadTtsSupplement,
|
getReplyPayloadTtsSupplement,
|
||||||
} from "openclaw/plugin-sdk/reply-payload";
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
|
import {
|
||||||
|
dispatchInboundMessageWithBufferedDispatcher,
|
||||||
|
type GetReplyOptions,
|
||||||
|
} from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||||
import type {
|
import type {
|
||||||
@@ -228,6 +230,11 @@ type MatrixMonitorHandlerParams = {
|
|||||||
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||||
needsRoomAliasesForConfig: boolean;
|
needsRoomAliasesForConfig: boolean;
|
||||||
resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist;
|
resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist;
|
||||||
|
resolveStorePath?: typeof resolveStorePath;
|
||||||
|
createChannelInboundEnvelopeBuilder?: typeof createChannelInboundEnvelopeBuilder;
|
||||||
|
finalizeInboundContext?: (ctx: Record<string, unknown>) => unknown;
|
||||||
|
resolveHumanDelayConfig?: typeof resolveHumanDelayConfig;
|
||||||
|
dispatchInboundMessageWithBufferedDispatcher?: typeof dispatchInboundMessageWithBufferedDispatcher;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveMatrixMentionPrecheckText(params: {
|
function resolveMatrixMentionPrecheckText(params: {
|
||||||
@@ -472,6 +479,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
needsRoomAliasesForConfig,
|
needsRoomAliasesForConfig,
|
||||||
resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist,
|
resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist,
|
||||||
|
resolveStorePath: resolveStorePathImpl = resolveStorePath,
|
||||||
|
createChannelInboundEnvelopeBuilder:
|
||||||
|
createChannelInboundEnvelopeBuilderImpl = createChannelInboundEnvelopeBuilder,
|
||||||
|
finalizeInboundContext,
|
||||||
|
resolveHumanDelayConfig: resolveHumanDelayConfigImpl = resolveHumanDelayConfig,
|
||||||
|
dispatchInboundMessageWithBufferedDispatcher:
|
||||||
|
dispatchInboundMessageWithBufferedDispatcherImpl = dispatchInboundMessageWithBufferedDispatcher,
|
||||||
} = params;
|
} = params;
|
||||||
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -1536,14 +1550,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
const roomName = roomInfo?.name;
|
const roomName = roomInfo?.name;
|
||||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
const storePath = resolveStorePathImpl(cfg.session?.store, {
|
||||||
agentId: _route.agentId,
|
agentId: _route.agentId,
|
||||||
});
|
});
|
||||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
const buildEnvelope = createChannelInboundEnvelopeBuilderImpl({ cfg, route: _route });
|
||||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
||||||
storePath,
|
|
||||||
sessionKey: _route.sessionKey,
|
|
||||||
});
|
|
||||||
const sharedDmNoticeSessionKey = threadTarget
|
const sharedDmNoticeSessionKey = threadTarget
|
||||||
? _route.mainSessionKey || _route.sessionKey
|
? _route.mainSessionKey || _route.sessionKey
|
||||||
: _route.sessionKey;
|
: _route.sessionKey;
|
||||||
@@ -1560,12 +1570,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
logVerboseMessage,
|
logVerboseMessage,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = buildEnvelope({
|
||||||
channel: "Matrix",
|
channel: "Matrix",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp: eventTs ?? undefined,
|
timestamp: eventTs ?? undefined,
|
||||||
previousTimestamp,
|
|
||||||
envelope: envelopeOptions,
|
|
||||||
body: textWithId,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
||||||
@@ -1579,8 +1587,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
);
|
);
|
||||||
const ctxPayload = buildChannelInboundEventContext({
|
const ctxPayload = buildChannelInboundEventContext({
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
finalize: core.channel.reply.finalizeInboundContext,
|
|
||||||
contextVisibility: contextVisibilityMode,
|
contextVisibility: contextVisibilityMode,
|
||||||
|
finalize: finalizeInboundContext,
|
||||||
supplemental: {
|
supplemental: {
|
||||||
quote: replyContext
|
quote: replyContext
|
||||||
? {
|
? {
|
||||||
@@ -2098,261 +2106,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
resetPreviewToolProgress();
|
resetPreviewToolProgress();
|
||||||
};
|
};
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
const dispatcherOptions = {
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
...prefixOptions,
|
||||||
...prefixOptions,
|
humanDelay: resolveHumanDelayConfigImpl(cfg, _route.agentId),
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, _route.agentId),
|
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
|
||||||
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
|
if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
|
||||||
if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
|
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
const fallbackPayload =
|
||||||
const fallbackPayload =
|
ttsSupplement &&
|
||||||
ttsSupplement &&
|
ttsSupplement.visibleTextAlreadyDelivered !== true &&
|
||||||
ttsSupplement.visibleTextAlreadyDelivered !== true &&
|
!payload.text?.trim()
|
||||||
!payload.text?.trim()
|
? { ...payload, text: ttsSupplement.spokenText }
|
||||||
? { ...payload, text: ttsSupplement.spokenText }
|
: payload;
|
||||||
: payload;
|
|
||||||
|
|
||||||
if (draftConsumed) {
|
if (draftConsumed) {
|
||||||
await draftStream.discardPending();
|
await draftStream.discardPending();
|
||||||
await deliverMatrixReplies({
|
|
||||||
cfg,
|
|
||||||
replies: [fallbackPayload],
|
|
||||||
roomId,
|
|
||||||
client,
|
|
||||||
runtime,
|
|
||||||
textLimit,
|
|
||||||
replyToMode,
|
|
||||||
threadId: threadTarget,
|
|
||||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
|
||||||
accountId: _route.accountId,
|
|
||||||
mediaLocalRoots,
|
|
||||||
tableMode,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadReplyToId = normalizeOptionalString(payload.replyToId);
|
|
||||||
const payloadReplyMismatch =
|
|
||||||
replyToMode !== "off" &&
|
|
||||||
!threadTarget &&
|
|
||||||
payloadReplyToId !== currentDraftReplyToId;
|
|
||||||
let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
|
|
||||||
const canPotentiallyFinalizeDraft =
|
|
||||||
Boolean(payload.text?.trim()) &&
|
|
||||||
!payload.isError &&
|
|
||||||
!payloadReplyMismatch &&
|
|
||||||
!mustDeliverFinalNormally;
|
|
||||||
|
|
||||||
if (canPotentiallyFinalizeDraft) {
|
|
||||||
await draftStream.stop();
|
|
||||||
mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
|
|
||||||
} else {
|
|
||||||
await draftStream.discardPending();
|
|
||||||
}
|
|
||||||
const draftEventId = draftStream.eventId();
|
|
||||||
const draftFinalTextNeedsNormalMentionDelivery =
|
|
||||||
Boolean(draftEventId) &&
|
|
||||||
typeof payload.text === "string" &&
|
|
||||||
Boolean(payload.text.trim()) &&
|
|
||||||
!payload.isError &&
|
|
||||||
!payloadReplyMismatch &&
|
|
||||||
!mustDeliverFinalNormally &&
|
|
||||||
(await matrixTextWouldActivateMentions(client, payload.text));
|
|
||||||
|
|
||||||
if (
|
|
||||||
draftEventId &&
|
|
||||||
payload.text &&
|
|
||||||
!payload.isError &&
|
|
||||||
!hasMedia &&
|
|
||||||
!payloadReplyMismatch &&
|
|
||||||
!mustDeliverFinalNormally &&
|
|
||||||
!draftFinalTextNeedsNormalMentionDelivery
|
|
||||||
) {
|
|
||||||
const finalPreviewText = payload.text;
|
|
||||||
await deliverWithFinalizableLivePreviewAdapter<
|
|
||||||
ReplyPayload,
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
text: string;
|
|
||||||
finalizeLive: boolean;
|
|
||||||
extraContent?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
kind: "final",
|
|
||||||
payload,
|
|
||||||
adapter: defineFinalizableLivePreviewAdapter({
|
|
||||||
draft: {
|
|
||||||
flush: async () => {},
|
|
||||||
clear: async () => {},
|
|
||||||
discardPending: async () => {},
|
|
||||||
id: () => draftEventId,
|
|
||||||
},
|
|
||||||
buildFinalEdit: () => ({
|
|
||||||
text: finalPreviewText,
|
|
||||||
finalizeLive: !(
|
|
||||||
quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText)
|
|
||||||
),
|
|
||||||
...(quietDraftStreaming
|
|
||||||
? { extraContent: buildMatrixFinalizedPreviewContent() }
|
|
||||||
: {}),
|
|
||||||
}),
|
|
||||||
editFinal: async (_draftEventId, edit) => {
|
|
||||||
if (edit.finalizeLive) {
|
|
||||||
if (!(await draftStream.finalizeLive())) {
|
|
||||||
throw new Error("Matrix draft live finalize failed");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { editMessageMatrix } = await loadMatrixSendModule();
|
|
||||||
await editMessageMatrix(roomId, _draftEventId, edit.text, {
|
|
||||||
client,
|
|
||||||
cfg,
|
|
||||||
threadId: threadTarget,
|
|
||||||
accountId: _route.accountId,
|
|
||||||
extraContent: edit.extraContent,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
createPreviewReceipt: (id): MessageReceipt =>
|
|
||||||
createPreviewMessageReceipt({
|
|
||||||
id,
|
|
||||||
...(threadTarget ? { threadId: threadTarget } : {}),
|
|
||||||
...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}),
|
|
||||||
}),
|
|
||||||
logPreviewEditFailure: (err) => {
|
|
||||||
logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
deliverNormally: async () => {
|
|
||||||
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
|
||||||
await deliverMatrixReplies({
|
|
||||||
cfg,
|
|
||||||
replies: [fallbackPayload],
|
|
||||||
roomId,
|
|
||||||
client,
|
|
||||||
runtime,
|
|
||||||
textLimit,
|
|
||||||
replyToMode,
|
|
||||||
threadId: threadTarget,
|
|
||||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
|
||||||
accountId: _route.accountId,
|
|
||||||
mediaLocalRoots,
|
|
||||||
tableMode,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
draftConsumed = true;
|
|
||||||
} else if (draftEventId && hasMedia && !payloadReplyMismatch) {
|
|
||||||
let textEditOk = !mustDeliverFinalNormally;
|
|
||||||
const payloadText = payload.text ?? ttsSupplement?.spokenText;
|
|
||||||
const payloadTextMatchesDraft =
|
|
||||||
typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText);
|
|
||||||
const reusesDraftTextUnchanged =
|
|
||||||
typeof payloadText === "string" &&
|
|
||||||
Boolean(payloadText.trim()) &&
|
|
||||||
payloadTextMatchesDraft;
|
|
||||||
const mediaTextNeedsNormalMentionDelivery =
|
|
||||||
typeof payloadText === "string" &&
|
|
||||||
Boolean(payloadText.trim()) &&
|
|
||||||
(await matrixTextWouldActivateMentions(client, payloadText));
|
|
||||||
const requiresFinalTextEdit =
|
|
||||||
quietDraftStreaming ||
|
|
||||||
(typeof payloadText === "string" && !payloadTextMatchesDraft);
|
|
||||||
if (textEditOk && mediaTextNeedsNormalMentionDelivery) {
|
|
||||||
textEditOk = false;
|
|
||||||
} else if (textEditOk && payloadText && requiresFinalTextEdit) {
|
|
||||||
const { editMessageMatrix } = await loadMatrixSendModule();
|
|
||||||
textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
|
|
||||||
client,
|
|
||||||
cfg,
|
|
||||||
threadId: threadTarget,
|
|
||||||
accountId: _route.accountId,
|
|
||||||
extraContent: quietDraftStreaming
|
|
||||||
? buildMatrixFinalizedPreviewContent()
|
|
||||||
: undefined,
|
|
||||||
}).then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
);
|
|
||||||
} else if (textEditOk && reusesDraftTextUnchanged) {
|
|
||||||
textEditOk = await draftStream.finalizeLive();
|
|
||||||
}
|
|
||||||
const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk;
|
|
||||||
if (!reusesDraftAsFinalText) {
|
|
||||||
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
|
||||||
}
|
|
||||||
const mediaPayload =
|
|
||||||
ttsSupplement && reusesDraftAsFinalText
|
|
||||||
? buildTtsSupplementMediaPayload(payload)
|
|
||||||
: {
|
|
||||||
...payload,
|
|
||||||
text: reusesDraftAsFinalText
|
|
||||||
? undefined
|
|
||||||
: (payload.text ??
|
|
||||||
(ttsSupplement?.visibleTextAlreadyDelivered === true
|
|
||||||
? undefined
|
|
||||||
: ttsSupplement?.spokenText)),
|
|
||||||
};
|
|
||||||
await deliverMatrixReplies({
|
|
||||||
cfg,
|
|
||||||
replies: [mediaPayload],
|
|
||||||
roomId,
|
|
||||||
client,
|
|
||||||
runtime,
|
|
||||||
textLimit,
|
|
||||||
replyToMode,
|
|
||||||
threadId: threadTarget,
|
|
||||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
|
||||||
accountId: _route.accountId,
|
|
||||||
mediaLocalRoots,
|
|
||||||
tableMode,
|
|
||||||
});
|
|
||||||
draftConsumed = true;
|
|
||||||
} else {
|
|
||||||
const draftRedacted =
|
|
||||||
Boolean(draftEventId) &&
|
|
||||||
(payload.isError ||
|
|
||||||
payloadReplyMismatch ||
|
|
||||||
mustDeliverFinalNormally ||
|
|
||||||
draftFinalTextNeedsNormalMentionDelivery);
|
|
||||||
if (draftRedacted && draftEventId) {
|
|
||||||
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
|
||||||
}
|
|
||||||
const deliveredFallback = await deliverMatrixReplies({
|
|
||||||
cfg,
|
|
||||||
replies: [fallbackPayload],
|
|
||||||
roomId,
|
|
||||||
client,
|
|
||||||
runtime,
|
|
||||||
textLimit,
|
|
||||||
replyToMode,
|
|
||||||
threadId: threadTarget,
|
|
||||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
|
||||||
accountId: _route.accountId,
|
|
||||||
mediaLocalRoots,
|
|
||||||
tableMode,
|
|
||||||
});
|
|
||||||
if (draftRedacted || deliveredFallback) {
|
|
||||||
draftConsumed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.kind === "block") {
|
|
||||||
draftConsumed = false;
|
|
||||||
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
|
|
||||||
draftStream.reset();
|
|
||||||
currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined;
|
|
||||||
updateDraftFromLatestFullText();
|
|
||||||
|
|
||||||
// Re-assert typing so the user still sees the indicator while
|
|
||||||
// the next block generates.
|
|
||||||
const { sendTypingMatrix } = await loadMatrixSendModule();
|
|
||||||
await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await deliverMatrixReplies({
|
await deliverMatrixReplies({
|
||||||
cfg,
|
cfg,
|
||||||
replies: [payload],
|
replies: [fallbackPayload],
|
||||||
roomId,
|
roomId,
|
||||||
client,
|
client,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -2364,22 +2136,255 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
tableMode,
|
tableMode,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
|
const payloadReplyToId = normalizeOptionalString(payload.replyToId);
|
||||||
if (info.kind === "final") {
|
const payloadReplyMismatch =
|
||||||
finalReplyDeliveryFailed = true;
|
replyToMode !== "off" && !threadTarget && payloadReplyToId !== currentDraftReplyToId;
|
||||||
|
let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
|
||||||
|
const canPotentiallyFinalizeDraft =
|
||||||
|
Boolean(payload.text?.trim()) &&
|
||||||
|
!payload.isError &&
|
||||||
|
!payloadReplyMismatch &&
|
||||||
|
!mustDeliverFinalNormally;
|
||||||
|
|
||||||
|
if (canPotentiallyFinalizeDraft) {
|
||||||
|
await draftStream.stop();
|
||||||
|
mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
|
||||||
} else {
|
} else {
|
||||||
nonFinalReplyDeliveryFailed = true;
|
await draftStream.discardPending();
|
||||||
}
|
}
|
||||||
|
const draftEventId = draftStream.eventId();
|
||||||
|
const draftFinalTextNeedsNormalMentionDelivery =
|
||||||
|
Boolean(draftEventId) &&
|
||||||
|
typeof payload.text === "string" &&
|
||||||
|
Boolean(payload.text.trim()) &&
|
||||||
|
!payload.isError &&
|
||||||
|
!payloadReplyMismatch &&
|
||||||
|
!mustDeliverFinalNormally &&
|
||||||
|
(await matrixTextWouldActivateMentions(client, payload.text));
|
||||||
|
|
||||||
|
if (
|
||||||
|
draftEventId &&
|
||||||
|
payload.text &&
|
||||||
|
!payload.isError &&
|
||||||
|
!hasMedia &&
|
||||||
|
!payloadReplyMismatch &&
|
||||||
|
!mustDeliverFinalNormally &&
|
||||||
|
!draftFinalTextNeedsNormalMentionDelivery
|
||||||
|
) {
|
||||||
|
const finalPreviewText = payload.text;
|
||||||
|
await deliverWithFinalizableLivePreviewAdapter<
|
||||||
|
ReplyPayload,
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
finalizeLive: boolean;
|
||||||
|
extraContent?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
kind: "final",
|
||||||
|
payload,
|
||||||
|
adapter: defineFinalizableLivePreviewAdapter({
|
||||||
|
draft: {
|
||||||
|
flush: async () => {},
|
||||||
|
clear: async () => {},
|
||||||
|
discardPending: async () => {},
|
||||||
|
id: () => draftEventId,
|
||||||
|
},
|
||||||
|
buildFinalEdit: () => ({
|
||||||
|
text: finalPreviewText,
|
||||||
|
finalizeLive: !(
|
||||||
|
quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText)
|
||||||
|
),
|
||||||
|
...(quietDraftStreaming
|
||||||
|
? { extraContent: buildMatrixFinalizedPreviewContent() }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
editFinal: async (_draftEventId, edit) => {
|
||||||
|
if (edit.finalizeLive) {
|
||||||
|
if (!(await draftStream.finalizeLive())) {
|
||||||
|
throw new Error("Matrix draft live finalize failed");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { editMessageMatrix } = await loadMatrixSendModule();
|
||||||
|
await editMessageMatrix(roomId, _draftEventId, edit.text, {
|
||||||
|
client,
|
||||||
|
cfg,
|
||||||
|
threadId: threadTarget,
|
||||||
|
accountId: _route.accountId,
|
||||||
|
extraContent: edit.extraContent,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createPreviewReceipt: (id): MessageReceipt =>
|
||||||
|
createPreviewMessageReceipt({
|
||||||
|
id,
|
||||||
|
...(threadTarget ? { threadId: threadTarget } : {}),
|
||||||
|
...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}),
|
||||||
|
}),
|
||||||
|
logPreviewEditFailure: (err) => {
|
||||||
|
logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
deliverNormally: async () => {
|
||||||
|
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||||
|
await deliverMatrixReplies({
|
||||||
|
cfg,
|
||||||
|
replies: [fallbackPayload],
|
||||||
|
roomId,
|
||||||
|
client,
|
||||||
|
runtime,
|
||||||
|
textLimit,
|
||||||
|
replyToMode,
|
||||||
|
threadId: threadTarget,
|
||||||
|
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||||
|
accountId: _route.accountId,
|
||||||
|
mediaLocalRoots,
|
||||||
|
tableMode,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
draftConsumed = true;
|
||||||
|
} else if (draftEventId && hasMedia && !payloadReplyMismatch) {
|
||||||
|
let textEditOk = !mustDeliverFinalNormally;
|
||||||
|
const payloadText = payload.text ?? ttsSupplement?.spokenText;
|
||||||
|
const payloadTextMatchesDraft =
|
||||||
|
typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText);
|
||||||
|
const reusesDraftTextUnchanged =
|
||||||
|
typeof payloadText === "string" &&
|
||||||
|
Boolean(payloadText.trim()) &&
|
||||||
|
payloadTextMatchesDraft;
|
||||||
|
const mediaTextNeedsNormalMentionDelivery =
|
||||||
|
typeof payloadText === "string" &&
|
||||||
|
Boolean(payloadText.trim()) &&
|
||||||
|
(await matrixTextWouldActivateMentions(client, payloadText));
|
||||||
|
const requiresFinalTextEdit =
|
||||||
|
quietDraftStreaming ||
|
||||||
|
(typeof payloadText === "string" && !payloadTextMatchesDraft);
|
||||||
|
if (textEditOk && mediaTextNeedsNormalMentionDelivery) {
|
||||||
|
textEditOk = false;
|
||||||
|
} else if (textEditOk && payloadText && requiresFinalTextEdit) {
|
||||||
|
const { editMessageMatrix } = await loadMatrixSendModule();
|
||||||
|
textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
|
||||||
|
client,
|
||||||
|
cfg,
|
||||||
|
threadId: threadTarget,
|
||||||
|
accountId: _route.accountId,
|
||||||
|
extraContent: quietDraftStreaming
|
||||||
|
? buildMatrixFinalizedPreviewContent()
|
||||||
|
: undefined,
|
||||||
|
}).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
} else if (textEditOk && reusesDraftTextUnchanged) {
|
||||||
|
textEditOk = await draftStream.finalizeLive();
|
||||||
|
}
|
||||||
|
const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk;
|
||||||
|
if (!reusesDraftAsFinalText) {
|
||||||
|
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||||
|
}
|
||||||
|
const mediaPayload =
|
||||||
|
ttsSupplement && reusesDraftAsFinalText
|
||||||
|
? buildTtsSupplementMediaPayload(payload)
|
||||||
|
: {
|
||||||
|
...payload,
|
||||||
|
text: reusesDraftAsFinalText
|
||||||
|
? undefined
|
||||||
|
: (payload.text ??
|
||||||
|
(ttsSupplement?.visibleTextAlreadyDelivered === true
|
||||||
|
? undefined
|
||||||
|
: ttsSupplement?.spokenText)),
|
||||||
|
};
|
||||||
|
await deliverMatrixReplies({
|
||||||
|
cfg,
|
||||||
|
replies: [mediaPayload],
|
||||||
|
roomId,
|
||||||
|
client,
|
||||||
|
runtime,
|
||||||
|
textLimit,
|
||||||
|
replyToMode,
|
||||||
|
threadId: threadTarget,
|
||||||
|
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||||
|
accountId: _route.accountId,
|
||||||
|
mediaLocalRoots,
|
||||||
|
tableMode,
|
||||||
|
});
|
||||||
|
draftConsumed = true;
|
||||||
|
} else {
|
||||||
|
const draftRedacted =
|
||||||
|
Boolean(draftEventId) &&
|
||||||
|
(payload.isError ||
|
||||||
|
payloadReplyMismatch ||
|
||||||
|
mustDeliverFinalNormally ||
|
||||||
|
draftFinalTextNeedsNormalMentionDelivery);
|
||||||
|
if (draftRedacted && draftEventId) {
|
||||||
|
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||||
|
}
|
||||||
|
const deliveredFallback = await deliverMatrixReplies({
|
||||||
|
cfg,
|
||||||
|
replies: [fallbackPayload],
|
||||||
|
roomId,
|
||||||
|
client,
|
||||||
|
runtime,
|
||||||
|
textLimit,
|
||||||
|
replyToMode,
|
||||||
|
threadId: threadTarget,
|
||||||
|
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||||
|
accountId: _route.accountId,
|
||||||
|
mediaLocalRoots,
|
||||||
|
tableMode,
|
||||||
|
});
|
||||||
|
if (draftRedacted || deliveredFallback) {
|
||||||
|
draftConsumed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (info.kind === "block") {
|
if (info.kind === "block") {
|
||||||
|
draftConsumed = false;
|
||||||
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
|
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
|
||||||
|
draftStream.reset();
|
||||||
|
currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined;
|
||||||
|
updateDraftFromLatestFullText();
|
||||||
|
|
||||||
|
// Re-assert typing so the user still sees the indicator while
|
||||||
|
// the next block generates.
|
||||||
|
const { sendTypingMatrix } = await loadMatrixSendModule();
|
||||||
|
await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
|
||||||
}
|
}
|
||||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
} else {
|
||||||
},
|
await deliverMatrixReplies({
|
||||||
onReplyStart: typingCallbacks.onReplyStart,
|
cfg,
|
||||||
onIdle: typingCallbacks.onIdle,
|
replies: [payload],
|
||||||
});
|
roomId,
|
||||||
|
client,
|
||||||
|
runtime,
|
||||||
|
textLimit,
|
||||||
|
replyToMode,
|
||||||
|
threadId: threadTarget,
|
||||||
|
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||||
|
accountId: _route.accountId,
|
||||||
|
mediaLocalRoots,
|
||||||
|
tableMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
|
||||||
|
if (info.kind === "final") {
|
||||||
|
finalReplyDeliveryFailed = true;
|
||||||
|
} else {
|
||||||
|
nonFinalReplyDeliveryFailed = true;
|
||||||
|
}
|
||||||
|
if (info.kind === "block") {
|
||||||
|
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
|
||||||
|
}
|
||||||
|
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
onReplyStart: typingCallbacks.onReplyStart,
|
||||||
|
onIdle: typingCallbacks.onIdle,
|
||||||
|
};
|
||||||
const pinnedMainDmOwner = isDirectMessage
|
const pinnedMainDmOwner = isDirectMessage
|
||||||
? await (async () => {
|
? await (async () => {
|
||||||
const livePinnedCfg = core.config.current() as CoreConfig;
|
const livePinnedCfg = core.config.current() as CoreConfig;
|
||||||
@@ -2422,12 +2427,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
raw: event,
|
raw: event,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
accountId: _route.accountId,
|
accountId: _route.accountId,
|
||||||
routeSessionKey: _route.sessionKey,
|
route: { agentId: _route.agentId, sessionKey: _route.sessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
botLoopProtection,
|
botLoopProtection,
|
||||||
record: {
|
record: {
|
||||||
updateLastRoute: isDirectMessage
|
updateLastRoute: isDirectMessage
|
||||||
@@ -2464,14 +2468,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onPreDispatchFailure: () =>
|
|
||||||
core.channel.reply.settleReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => {
|
|
||||||
markRunComplete();
|
|
||||||
markDispatchIdle();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
runDispatch: async () => {
|
runDispatch: async () => {
|
||||||
if (
|
if (
|
||||||
sharedDmContextNotice &&
|
sharedDmContextNotice &&
|
||||||
@@ -2489,61 +2485,50 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await core.channel.reply.withReplyDispatcher({
|
return await dispatchInboundMessageWithBufferedDispatcherImpl({
|
||||||
dispatcher,
|
ctx: ctxPayload,
|
||||||
onSettled: () => {
|
cfg,
|
||||||
markDispatchIdle();
|
dispatcherOptions: {
|
||||||
|
...dispatcherOptions,
|
||||||
|
onSettled: () => progressDraftGate.cancel(),
|
||||||
},
|
},
|
||||||
run: async () => {
|
replyOptions: {
|
||||||
try {
|
skillFilter: roomConfig?.skills,
|
||||||
return await core.channel.reply.dispatchReplyFromConfig({
|
// Keep block streaming enabled when explicitly requested, even
|
||||||
ctx: ctxPayload,
|
// with draft previews on. The draft remains the live preview
|
||||||
cfg,
|
// for the current assistant block, while block deliveries
|
||||||
dispatcher,
|
// finalize completed blocks into their own preserved events.
|
||||||
replyOptions: {
|
disableBlockStreaming: !blockStreamingEnabled,
|
||||||
...replyOptions,
|
onPartialReply: draftStream
|
||||||
skillFilter: roomConfig?.skills,
|
? (payload) => {
|
||||||
// Keep block streaming enabled when explicitly requested, even
|
if (progressDraftStreaming) {
|
||||||
// with draft previews on. The draft remains the live preview
|
return;
|
||||||
// for the current assistant block, while block deliveries
|
}
|
||||||
// finalize completed blocks into their own preserved events.
|
latestDraftFullText = payload.text ?? "";
|
||||||
disableBlockStreaming: !blockStreamingEnabled,
|
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
|
||||||
onPartialReply: draftStream
|
updateDraftFromLatestFullText();
|
||||||
? (payload) => {
|
}
|
||||||
if (progressDraftStreaming) {
|
: undefined,
|
||||||
return;
|
onBlockReplyQueued: draftStream
|
||||||
}
|
? (payload, context) => {
|
||||||
latestDraftFullText = payload.text ?? "";
|
if (payload.isCompactionNotice === true) {
|
||||||
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
|
return;
|
||||||
updateDraftFromLatestFullText();
|
}
|
||||||
}
|
queueDraftBlockBoundary(payload, context);
|
||||||
: undefined,
|
}
|
||||||
onBlockReplyQueued: draftStream
|
: undefined,
|
||||||
? (payload, context) => {
|
// Reset draft boundary bookkeeping on assistant message
|
||||||
if (payload.isCompactionNotice === true) {
|
// boundaries so post-tool blocks stream from a fresh
|
||||||
return;
|
// cumulative payload (payload.text resets upstream).
|
||||||
}
|
onAssistantMessageStart: draftStream
|
||||||
queueDraftBlockBoundary(payload, context);
|
? () => {
|
||||||
}
|
resetDraftBlockOffsets();
|
||||||
: undefined,
|
resetPreviewToolProgress();
|
||||||
// Reset draft boundary bookkeeping on assistant message
|
}
|
||||||
// boundaries so post-tool blocks stream from a fresh
|
: undefined,
|
||||||
// cumulative payload (payload.text resets upstream).
|
onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined,
|
||||||
onAssistantMessageStart: draftStream
|
...buildPreviewToolProgressReplyOptions(),
|
||||||
? () => {
|
onModelSelected,
|
||||||
resetDraftBlockOffsets();
|
|
||||||
resetPreviewToolProgress();
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined,
|
|
||||||
...buildPreviewToolProgressReplyOptions(),
|
|
||||||
onModelSelected,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
progressDraftGate.cancel();
|
|
||||||
markRunComplete();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { installMatrixTestRuntime } from "../test-runtime.js";
|
||||||
import type { CoreConfig } from "../types.js";
|
import type { CoreConfig } from "../types.js";
|
||||||
import { withAuthorizedMatrixReadTarget } from "./read-policy.js";
|
import { withAuthorizedMatrixReadTarget } from "./read-policy.js";
|
||||||
import type { MatrixClient } from "./sdk.js";
|
import type { MatrixClient } from "./sdk.js";
|
||||||
@@ -32,6 +33,10 @@ function createClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Matrix read policy", () => {
|
describe("Matrix read policy", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
installMatrixTestRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
it("allows configured rooms and rejects other rooms before the read", async () => {
|
it("allows configured rooms and rejects other rooms before the read", async () => {
|
||||||
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
|
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Matrix helper module supports formatting behavior.
|
// Matrix helper module supports formatting behavior.
|
||||||
|
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
markdownToMatrixHtml,
|
markdownToMatrixHtml,
|
||||||
@@ -187,7 +188,7 @@ export function resolveMatrixVoiceDecision(opts: {
|
|||||||
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
|
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
|
||||||
// Matrix currently shares the core voice compatibility policy.
|
// Matrix currently shares the core voice compatibility policy.
|
||||||
// Keep this wrapper as the seam if Matrix policy diverges later.
|
// Keep this wrapper as the seam if Matrix policy diverges later.
|
||||||
return getCore().media.isVoiceCompatibleAudio({
|
return isVoiceMessageCompatibleAudio({
|
||||||
contentType: opts.contentType,
|
contentType: opts.contentType,
|
||||||
fileName: opts.fileName,
|
fileName: opts.fileName,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,9 +80,10 @@ class FakeWebSocket {
|
|||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
abortController: undefined as AbortController | undefined,
|
abortController: undefined as AbortController | undefined,
|
||||||
|
createReplyDispatcherWithTyping: vi.fn(),
|
||||||
createMattermostClient: vi.fn(),
|
createMattermostClient: vi.fn(),
|
||||||
createMattermostDraftStream: vi.fn(),
|
createMattermostDraftStream: vi.fn(),
|
||||||
dispatchReplyFromConfig: vi.fn(),
|
dispatchInboundMessage: vi.fn(),
|
||||||
enqueueSystemEvent: vi.fn(),
|
enqueueSystemEvent: vi.fn(),
|
||||||
fetchMattermostMe: vi.fn(),
|
fetchMattermostMe: vi.fn(),
|
||||||
registerMattermostMonitorSlashCommands: vi.fn(),
|
registerMattermostMonitorSlashCommands: vi.fn(),
|
||||||
@@ -96,6 +97,22 @@ const mockState = vi.hoisted(() => ({
|
|||||||
updateMattermostPost: vi.fn(),
|
updateMattermostPost: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createReplyDispatcherWithTyping: (...args: unknown[]) =>
|
||||||
|
mockState.createReplyDispatcherWithTyping(...args),
|
||||||
|
dispatchInboundMessage: async (params: Parameters<typeof actual.dispatchInboundMessage>[0]) => {
|
||||||
|
try {
|
||||||
|
return await mockState.dispatchInboundMessage(params);
|
||||||
|
} finally {
|
||||||
|
await params.onSettled?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./client.js", async () => {
|
vi.mock("./client.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
||||||
return {
|
return {
|
||||||
@@ -193,16 +210,43 @@ function createRuntimeCore(
|
|||||||
type ReplyDispatcherOptions = {
|
type ReplyDispatcherOptions = {
|
||||||
deliver: (payload: ReplyPayload, info: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
deliver: (payload: ReplyPayload, info: { kind: "tool" | "block" | "final" }) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
mockState.createReplyDispatcherWithTyping.mockImplementation(
|
||||||
|
(options: ReplyDispatcherOptions) => ({
|
||||||
|
dispatcher: {},
|
||||||
|
replyOptions: {},
|
||||||
|
markDispatchIdle: vi.fn(),
|
||||||
|
markRunComplete: vi.fn(),
|
||||||
|
options,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
type RecordInboundSessionInput = {
|
||||||
|
storePath: string;
|
||||||
|
sessionKey: string;
|
||||||
|
ctx: unknown;
|
||||||
|
createIfMissing?: boolean;
|
||||||
|
groupResolution?: unknown;
|
||||||
|
onRecordError?: (error: unknown) => void;
|
||||||
|
updateLastRoute?: {
|
||||||
|
accountId?: string;
|
||||||
|
channel?: string;
|
||||||
|
mainDmOwnerPin?: {
|
||||||
|
onSkip?: () => void;
|
||||||
|
ownerRecipient?: string;
|
||||||
|
senderRecipient?: string;
|
||||||
|
};
|
||||||
|
sessionKey?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const recordInboundSession = vi.fn(async (_params: RecordInboundSessionInput) => {});
|
||||||
const dispatchPreparedForTest = vi.fn(
|
const dispatchPreparedForTest = vi.fn(
|
||||||
async (turn: {
|
async (turn: {
|
||||||
storePath: string;
|
route: { agentId: string; sessionKey: string };
|
||||||
routeSessionKey: string;
|
|
||||||
ctxPayload: { SessionKey?: string };
|
ctxPayload: { SessionKey?: string };
|
||||||
recordInboundSession: (params: unknown) => Promise<void>;
|
|
||||||
record?: {
|
record?: {
|
||||||
groupResolution?: unknown;
|
groupResolution?: unknown;
|
||||||
createIfMissing?: boolean;
|
createIfMissing?: boolean;
|
||||||
updateLastRoute?: unknown;
|
updateLastRoute?: RecordInboundSessionInput["updateLastRoute"];
|
||||||
onRecordError?: (err: unknown) => void;
|
onRecordError?: (err: unknown) => void;
|
||||||
};
|
};
|
||||||
runDispatch: () => Promise<{
|
runDispatch: () => Promise<{
|
||||||
@@ -210,9 +254,9 @@ function createRuntimeCore(
|
|||||||
counts: { tool: number; block: number; final: number };
|
counts: { tool: number; block: number; final: number };
|
||||||
}>;
|
}>;
|
||||||
}) => {
|
}) => {
|
||||||
await turn.recordInboundSession({
|
await recordInboundSession({
|
||||||
storePath: turn.storePath,
|
storePath: "/tmp/openclaw-test-sessions.json",
|
||||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey,
|
||||||
ctx: turn.ctxPayload,
|
ctx: turn.ctxPayload,
|
||||||
groupResolution: turn.record?.groupResolution,
|
groupResolution: turn.record?.groupResolution,
|
||||||
createIfMissing: turn.record?.createIfMissing,
|
createIfMissing: turn.record?.createIfMissing,
|
||||||
@@ -224,7 +268,7 @@ function createRuntimeCore(
|
|||||||
admission: { kind: "dispatch" as const },
|
admission: { kind: "dispatch" as const },
|
||||||
dispatched: true,
|
dispatched: true,
|
||||||
ctxPayload: turn.ctxPayload,
|
ctxPayload: turn.ctxPayload,
|
||||||
routeSessionKey: turn.routeSessionKey,
|
routeSessionKey: turn.route.sessionKey,
|
||||||
dispatchResult,
|
dispatchResult,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -304,25 +348,7 @@ function createRuntimeCore(
|
|||||||
buildPairingReply: () => "pairing required",
|
buildPairingReply: () => "pairing required",
|
||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
createReplyDispatcherWithTyping: vi.fn((options: ReplyDispatcherOptions) => ({
|
settleReplyDispatcher: vi.fn(async ({ onSettled }) => onSettled?.()),
|
||||||
dispatcher: {},
|
|
||||||
replyOptions: {},
|
|
||||||
markDispatchIdle: vi.fn(),
|
|
||||||
markRunComplete: vi.fn(),
|
|
||||||
options,
|
|
||||||
})),
|
|
||||||
dispatchReplyFromConfig: mockState.dispatchReplyFromConfig,
|
|
||||||
finalizeInboundContext: (context: unknown) => context,
|
|
||||||
formatInboundEnvelope: (params: { channel: string; from: string; body: string }) =>
|
|
||||||
`${params.channel} ${params.from}\n${params.body}`,
|
|
||||||
resolveHumanDelayConfig: () => ({}),
|
|
||||||
withReplyDispatcher: async (params: { run: () => unknown; onSettled?: () => void }) => {
|
|
||||||
try {
|
|
||||||
return await params.run();
|
|
||||||
} finally {
|
|
||||||
params.onSettled?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
routing: {
|
routing: {
|
||||||
resolveAgentRoute: () => ({
|
resolveAgentRoute: () => ({
|
||||||
@@ -335,26 +361,7 @@ function createRuntimeCore(
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
|
resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
|
||||||
recordInboundSession: vi.fn(
|
recordInboundSession,
|
||||||
async (_params: {
|
|
||||||
createIfMissing?: unknown;
|
|
||||||
groupResolution?: unknown;
|
|
||||||
onRecordError?: unknown;
|
|
||||||
sessionKey?: string;
|
|
||||||
storePath?: string;
|
|
||||||
updateLastRoute?: {
|
|
||||||
accountId?: string;
|
|
||||||
channel?: string;
|
|
||||||
mainDmOwnerPin?: {
|
|
||||||
onSkip?: unknown;
|
|
||||||
ownerRecipient?: string;
|
|
||||||
senderRecipient?: string;
|
|
||||||
};
|
|
||||||
sessionKey?: string;
|
|
||||||
to?: string;
|
|
||||||
};
|
|
||||||
}) => {},
|
|
||||||
),
|
|
||||||
updateLastRoute: vi.fn(async () => {}),
|
updateLastRoute: vi.fn(async () => {}),
|
||||||
},
|
},
|
||||||
inbound: {
|
inbound: {
|
||||||
@@ -457,7 +464,7 @@ describe("mattermost inbound user posts", () => {
|
|||||||
mockState.resolveMattermostMedia.mockResolvedValue([]);
|
mockState.resolveMattermostMedia.mockResolvedValue([]);
|
||||||
mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
|
mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
|
||||||
mockState.sendMessageMattermost.mockResolvedValue({});
|
mockState.sendMessageMattermost.mockResolvedValue({});
|
||||||
mockState.dispatchReplyFromConfig.mockImplementation(async () => {
|
mockState.dispatchInboundMessage.mockImplementation(async () => {
|
||||||
mockState.abortController?.abort();
|
mockState.abortController?.abort();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -503,8 +510,8 @@ describe("mattermost inbound user posts", () => {
|
|||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
|
expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||||
expect(ctx?.BodyForAgent).toBe("hello from mattermost");
|
expect(ctx?.BodyForAgent).toBe("hello from mattermost");
|
||||||
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
|
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
|
||||||
expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular");
|
expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular");
|
||||||
@@ -599,8 +606,8 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||||
expect(ctx?.BodyForAgent).toBe("@openclaw");
|
expect(ctx?.BodyForAgent).toBe("@openclaw");
|
||||||
expect(ctx?.MessageSid).toBe("post-bare-mention");
|
expect(ctx?.MessageSid).toBe("post-bare-mention");
|
||||||
expect(ctx?.OriginatingChannel).toBe("mattermost");
|
expect(ctx?.OriginatingChannel).toBe("mattermost");
|
||||||
@@ -638,7 +645,7 @@ describe("mattermost inbound user posts", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockState.runtimeCore = createRuntimeCore(progressConfig);
|
mockState.runtimeCore = createRuntimeCore(progressConfig);
|
||||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||||
await params.replyOptions?.onToolStart?.({
|
await params.replyOptions?.onToolStart?.({
|
||||||
toolCallId: "read-1",
|
toolCallId: "read-1",
|
||||||
name: "read",
|
name: "read",
|
||||||
@@ -707,7 +714,7 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
|
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
|
||||||
expect(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
expect(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||||||
@@ -779,8 +786,8 @@ describe("mattermost inbound user posts", () => {
|
|||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig);
|
expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig);
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||||
expect(ctx?.BodyForAgent).toBe("hello /status");
|
expect(ctx?.BodyForAgent).toBe("hello /status");
|
||||||
expect(ctx?.CommandAuthorized).toBe(false);
|
expect(ctx?.CommandAuthorized).toBe(false);
|
||||||
// Inline non-control text must not be tagged as an explicit text-slash command turn —
|
// Inline non-control text must not be tagged as an explicit text-slash command turn —
|
||||||
@@ -861,8 +868,8 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||||
expect(ctx?.BodyForAgent).toBe("/reset");
|
expect(ctx?.BodyForAgent).toBe("/reset");
|
||||||
expect(ctx?.CommandBody).toBe("/reset");
|
expect(ctx?.CommandBody).toBe("/reset");
|
||||||
expect(ctx?.CommandAuthorized).toBe(true);
|
expect(ctx?.CommandAuthorized).toBe(true);
|
||||||
@@ -913,8 +920,8 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||||
expect(ctx?.BodyForAgent).toBe("hello with websocket kind");
|
expect(ctx?.BodyForAgent).toBe("hello with websocket kind");
|
||||||
expect(ctx?.ChatType).toBe("channel");
|
expect(ctx?.ChatType).toBe("channel");
|
||||||
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
|
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
|
||||||
@@ -976,7 +983,7 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled();
|
expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1041,7 +1048,7 @@ describe("mattermost inbound user posts", () => {
|
|||||||
user_id: "user-1",
|
user_id: "user-1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
|
expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await socket.emitMessage({
|
await socket.emitMessage({
|
||||||
event: "posted",
|
event: "posted",
|
||||||
@@ -1066,8 +1073,8 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
|
const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx;
|
||||||
expect(ctx?.BodyForAgent).toBe("abort");
|
expect(ctx?.BodyForAgent).toBe("abort");
|
||||||
expect(ctx?.CommandAuthorized).toBe(true);
|
expect(ctx?.CommandAuthorized).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1266,9 +1273,9 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(mockState.createMattermostDraftStream).not.toHaveBeenCalled();
|
expect(mockState.createMattermostDraftStream).not.toHaveBeenCalled();
|
||||||
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
|
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
|
||||||
expect(replyOptions?.disableBlockStreaming).toBe(false);
|
expect(replyOptions?.disableBlockStreaming).toBe(false);
|
||||||
expect(replyOptions?.preserveProgressCallbackStartOrder).toBeUndefined();
|
expect(replyOptions?.preserveProgressCallbackStartOrder).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -1354,7 +1361,7 @@ describe("mattermost inbound user posts", () => {
|
|||||||
let finalToolDraft = "";
|
let finalToolDraft = "";
|
||||||
let secondPartialArrivedBeforeBoundarySettled = false;
|
let secondPartialArrivedBeforeBoundarySettled = false;
|
||||||
let finalDeliveryWaitedForBoundary = false;
|
let finalDeliveryWaitedForBoundary = false;
|
||||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||||
await params.replyOptions?.onAssistantMessageStart?.();
|
await params.replyOptions?.onAssistantMessageStart?.();
|
||||||
params.replyOptions?.onPartialReply?.({ text: "A much longer first block" });
|
params.replyOptions?.onPartialReply?.({ text: "A much longer first block" });
|
||||||
const firstToolStart = params.replyOptions?.onToolStart?.({
|
const firstToolStart = params.replyOptions?.onToolStart?.({
|
||||||
@@ -1427,8 +1434,7 @@ describe("mattermost inbound user posts", () => {
|
|||||||
toolBeforeFinalBoundaryCount = forceNewMessage.mock.calls.length;
|
toolBeforeFinalBoundaryCount = forceNewMessage.mock.calls.length;
|
||||||
finalToolDraft = String(draftUpdate.mock.calls.at(-1)?.[0] ?? "");
|
finalToolDraft = String(draftUpdate.mock.calls.at(-1)?.[0] ?? "");
|
||||||
const dispatcherOptions =
|
const dispatcherOptions =
|
||||||
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
|
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
|
||||||
?.options;
|
|
||||||
const finalDelivery = dispatcherOptions?.deliver(
|
const finalDelivery = dispatcherOptions?.deliver(
|
||||||
{ text: "Final without a partial" },
|
{ text: "Final without a partial" },
|
||||||
{ kind: "final" },
|
{ kind: "final" },
|
||||||
@@ -1462,14 +1468,14 @@ describe("mattermost inbound user posts", () => {
|
|||||||
socket.emitClose(1000);
|
socket.emitClose(1000);
|
||||||
await monitor;
|
await monitor;
|
||||||
|
|
||||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||||
const draftStreamOptions = mockState.createMattermostDraftStream.mock.calls.at(0)?.[0] as
|
const draftStreamOptions = mockState.createMattermostDraftStream.mock.calls.at(0)?.[0] as
|
||||||
| { chunkText?: (text: string) => string[] }
|
| { chunkText?: (text: string) => string[] }
|
||||||
| undefined;
|
| undefined;
|
||||||
chunkMarkdownTextWithMode.mockClear();
|
chunkMarkdownTextWithMode.mockClear();
|
||||||
expect(draftStreamOptions?.chunkText?.("first\n\nsecond")).toEqual(["first\n\nsecond"]);
|
expect(draftStreamOptions?.chunkText?.("first\n\nsecond")).toEqual(["first\n\nsecond"]);
|
||||||
expect(chunkMarkdownTextWithMode).toHaveBeenCalledWith("first\n\nsecond", 1234, "newline");
|
expect(chunkMarkdownTextWithMode).toHaveBeenCalledWith("first\n\nsecond", 1234, "newline");
|
||||||
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
|
const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions;
|
||||||
expect(replyOptions?.disableBlockStreaming).toBe(true);
|
expect(replyOptions?.disableBlockStreaming).toBe(true);
|
||||||
expect(replyOptions?.preserveProgressCallbackStartOrder).toBe(true);
|
expect(replyOptions?.preserveProgressCallbackStartOrder).toBe(true);
|
||||||
expect(sameToolUpdateBoundaryCount).toBe(1);
|
expect(sameToolUpdateBoundaryCount).toBe(1);
|
||||||
@@ -1540,14 +1546,13 @@ describe("mattermost inbound user posts", () => {
|
|||||||
const socket = new FakeWebSocket();
|
const socket = new FakeWebSocket();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
mockState.abortController = abortController;
|
mockState.abortController = abortController;
|
||||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||||
await params.replyOptions?.onAssistantMessageStart?.();
|
await params.replyOptions?.onAssistantMessageStart?.();
|
||||||
await params.replyOptions?.onPartialReply?.({ text: "First block" });
|
await params.replyOptions?.onPartialReply?.({ text: "First block" });
|
||||||
await params.replyOptions?.onAssistantMessageStart?.();
|
await params.replyOptions?.onAssistantMessageStart?.();
|
||||||
await params.replyOptions?.onPartialReply?.({ text: "Second block" });
|
await params.replyOptions?.onPartialReply?.({ text: "Second block" });
|
||||||
const dispatcherOptions =
|
const dispatcherOptions =
|
||||||
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
|
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
|
||||||
?.options;
|
|
||||||
await dispatcherOptions?.deliver(
|
await dispatcherOptions?.deliver(
|
||||||
{ text: "[bot] First block\n\nSecond block" },
|
{ text: "[bot] First block\n\nSecond block" },
|
||||||
{ kind: "final" },
|
{ kind: "final" },
|
||||||
@@ -1621,13 +1626,12 @@ describe("mattermost inbound user posts", () => {
|
|||||||
const socket = new FakeWebSocket();
|
const socket = new FakeWebSocket();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
mockState.abortController = abortController;
|
mockState.abortController = abortController;
|
||||||
mockState.dispatchReplyFromConfig.mockImplementation(async (params) => {
|
mockState.dispatchInboundMessage.mockImplementation(async (params) => {
|
||||||
await params.replyOptions?.onAssistantMessageStart?.();
|
await params.replyOptions?.onAssistantMessageStart?.();
|
||||||
await params.replyOptions?.onPartialReply?.({ text: "Only block" });
|
await params.replyOptions?.onPartialReply?.({ text: "Only block" });
|
||||||
await params.replyOptions?.onAssistantMessageStart?.();
|
await params.replyOptions?.onAssistantMessageStart?.();
|
||||||
const dispatcherOptions =
|
const dispatcherOptions =
|
||||||
runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value
|
mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options;
|
||||||
?.options;
|
|
||||||
await dispatcherOptions?.deliver({ text: "Only block" }, { kind: "final" });
|
await dispatcherOptions?.deliver({ text: "Only block" }, { kind: "final" });
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound";
|
|
||||||
// Mattermost plugin module implements monitor behavior.
|
// Mattermost plugin module implements monitor behavior.
|
||||||
|
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
|
import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
|
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import {
|
import {
|
||||||
buildChannelProgressDraftLineForEntry,
|
buildChannelProgressDraftLineForEntry,
|
||||||
createChannelProgressDraftCompositor,
|
createChannelProgressDraftCompositor,
|
||||||
} from "openclaw/plugin-sdk/channel-outbound";
|
} from "openclaw/plugin-sdk/channel-outbound";
|
||||||
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
||||||
|
import {
|
||||||
|
createReplyDispatcherWithTyping,
|
||||||
|
dispatchInboundMessage,
|
||||||
|
finalizeInboundContext,
|
||||||
|
} from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
|
||||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
@@ -430,7 +437,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const to =
|
const to =
|
||||||
kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`;
|
kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`;
|
||||||
const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`;
|
const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`;
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = finalizeInboundContext({
|
||||||
Body: bodyText,
|
Body: bodyText,
|
||||||
BodyForAgent: bodyText,
|
BodyForAgent: bodyText,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
@@ -497,55 +504,48 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
isDirect: kind === "direct",
|
isDirect: kind === "direct",
|
||||||
dmRetryOptions: account.config.dmChannelRetry,
|
dmRetryOptions: account.config.dmChannelRetry,
|
||||||
});
|
});
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
...replyPipeline,
|
||||||
...replyPipeline,
|
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
deliver: async (payload: ReplyPayload) => {
|
||||||
deliver: async (payload: ReplyPayload) => {
|
await deliverMattermostReplyPayload({
|
||||||
await deliverMattermostReplyPayload({
|
core,
|
||||||
core,
|
|
||||||
cfg,
|
|
||||||
payload,
|
|
||||||
to,
|
|
||||||
accountId: account.accountId,
|
|
||||||
agentId: route.agentId,
|
|
||||||
replyToId: resolveMattermostReplyRootId({
|
|
||||||
kind,
|
|
||||||
threadRootId: threadContext.effectiveReplyToId,
|
|
||||||
replyToId: payload.replyToId,
|
|
||||||
}),
|
|
||||||
textLimit,
|
|
||||||
tableMode,
|
|
||||||
sendMessage: sendMessageMattermost,
|
|
||||||
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
|
|
||||||
});
|
|
||||||
runtime.log?.(`delivered button-click reply to ${to}`);
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`);
|
|
||||||
},
|
|
||||||
onReplyStart: typingCallbacks?.onReplyStart,
|
|
||||||
});
|
|
||||||
|
|
||||||
await core.channel.reply.withReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => {
|
|
||||||
markDispatchIdle();
|
|
||||||
},
|
|
||||||
run: () =>
|
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
payload,
|
||||||
replyOptions: {
|
to,
|
||||||
...replyOptions,
|
accountId: account.accountId,
|
||||||
disableBlockStreaming:
|
agentId: route.agentId,
|
||||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
replyToId: resolveMattermostReplyRootId({
|
||||||
onModelSelected,
|
kind,
|
||||||
},
|
threadRootId: threadContext.effectiveReplyToId,
|
||||||
}),
|
replyToId: payload.replyToId,
|
||||||
|
}),
|
||||||
|
textLimit,
|
||||||
|
tableMode,
|
||||||
|
sendMessage: sendMessageMattermost,
|
||||||
|
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
|
||||||
|
});
|
||||||
|
runtime.log?.(`delivered button-click reply to ${to}`);
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
onReplyStart: typingCallbacks?.onReplyStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
log: (msg) => runtime.log?.(msg),
|
log: (msg) => runtime.log?.(msg),
|
||||||
@@ -632,7 +632,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
params.kind === "direct"
|
params.kind === "direct"
|
||||||
? `Mattermost DM from ${params.senderName}`
|
? `Mattermost DM from ${params.senderName}`
|
||||||
: `Mattermost message in ${params.roomLabel} from ${params.senderName}`;
|
: `Mattermost message in ${params.roomLabel} from ${params.senderName}`;
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = finalizeInboundContext({
|
||||||
Body: params.commandText,
|
Body: params.commandText,
|
||||||
BodyForAgent: params.commandText,
|
BodyForAgent: params.commandText,
|
||||||
RawBody: params.commandText,
|
RawBody: params.commandText,
|
||||||
@@ -707,67 +707,60 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
isDirect: params.kind === "direct",
|
isDirect: params.kind === "direct",
|
||||||
dmRetryOptions: account.config.dmChannelRetry,
|
dmRetryOptions: account.config.dmChannelRetry,
|
||||||
});
|
});
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
...replyPipeline,
|
||||||
...replyPipeline,
|
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
// Picker-triggered confirmations should stay immediate.
|
||||||
// Picker-triggered confirmations should stay immediate.
|
deliver: async (payload: ReplyPayload) => {
|
||||||
deliver: async (payload: ReplyPayload) => {
|
const trimmedPayload = {
|
||||||
const trimmedPayload = {
|
...payload,
|
||||||
...payload,
|
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
|
||||||
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if (!shouldDeliverReplies) {
|
if (!shouldDeliverReplies) {
|
||||||
if (trimmedPayload.text) {
|
if (trimmedPayload.text) {
|
||||||
capturedTexts.push(trimmedPayload.text);
|
capturedTexts.push(trimmedPayload.text);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await deliverMattermostReplyPayload({
|
await deliverMattermostReplyPayload({
|
||||||
core,
|
core,
|
||||||
cfg,
|
|
||||||
payload: trimmedPayload,
|
|
||||||
to,
|
|
||||||
accountId: account.accountId,
|
|
||||||
agentId: params.route.agentId,
|
|
||||||
replyToId: resolveMattermostReplyRootId({
|
|
||||||
kind: params.kind,
|
|
||||||
threadRootId: params.effectiveReplyToId,
|
|
||||||
replyToId: trimmedPayload.replyToId,
|
|
||||||
}),
|
|
||||||
textLimit,
|
|
||||||
// The picker path already converts and trims text before capture/delivery.
|
|
||||||
tableMode: "off",
|
|
||||||
sendMessage: sendMessageMattermost,
|
|
||||||
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`);
|
|
||||||
},
|
|
||||||
onReplyStart: typingCallbacks?.onReplyStart,
|
|
||||||
});
|
|
||||||
|
|
||||||
await core.channel.reply.withReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => {
|
|
||||||
markDispatchIdle();
|
|
||||||
},
|
|
||||||
run: () =>
|
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
payload: trimmedPayload,
|
||||||
replyOptions: {
|
to,
|
||||||
...replyOptions,
|
accountId: account.accountId,
|
||||||
disableBlockStreaming:
|
agentId: params.route.agentId,
|
||||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
replyToId: resolveMattermostReplyRootId({
|
||||||
onModelSelected,
|
kind: params.kind,
|
||||||
},
|
threadRootId: params.effectiveReplyToId,
|
||||||
}),
|
replyToId: trimmedPayload.replyToId,
|
||||||
|
}),
|
||||||
|
textLimit,
|
||||||
|
// The picker path already converts and trims text before capture/delivery.
|
||||||
|
tableMode: "off",
|
||||||
|
sendMessage: sendMessageMattermost,
|
||||||
|
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
onReplyStart: typingCallbacks?.onReplyStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return capturedTexts.join("\n\n").trim();
|
return capturedTexts.join("\n\n").trim();
|
||||||
@@ -1296,7 +1289,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
|
|
||||||
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
||||||
const body = core.channel.reply.formatInboundEnvelope({
|
const body = formatInboundEnvelope({
|
||||||
channel: "Mattermost",
|
channel: "Mattermost",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||||
@@ -1312,7 +1305,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
core.channel.reply.formatInboundEnvelope({
|
formatInboundEnvelope({
|
||||||
channel: "Mattermost",
|
channel: "Mattermost",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
@@ -1335,7 +1328,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
BodyForAgent: bodyForAgent,
|
BodyForAgent: bodyForAgent,
|
||||||
InboundHistory: inboundHistory,
|
InboundHistory: inboundHistory,
|
||||||
@@ -1389,10 +1382,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const previewLine = truncateUtf16Safe(bodyText, 200).replace(/\n/g, "\\n");
|
const previewLine = truncateUtf16Safe(bodyText, 200).replace(/\n/g, "\\n");
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
||||||
@@ -1591,11 +1580,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
dmRetryOptions: account.config.dmChannelRetry,
|
dmRetryOptions: account.config.dmChannelRetry,
|
||||||
});
|
});
|
||||||
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
createReplyDispatcherWithTyping({
|
||||||
...replyPipeline,
|
...replyPipeline,
|
||||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
typingCallbacks,
|
typingCallbacks,
|
||||||
deliver: async (payloadEntry: ReplyPayload, info) => {
|
deliver: async (payloadEntry: ReplyPayload, info) => {
|
||||||
if (info.kind === "final") {
|
if (info.kind === "final") {
|
||||||
@@ -1715,12 +1704,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
raw: post,
|
raw: post,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg,
|
||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
updateLastRoute:
|
updateLastRoute:
|
||||||
kind === "direct"
|
kind === "direct"
|
||||||
@@ -1772,131 +1760,124 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
runDispatch: () =>
|
runDispatch: () =>
|
||||||
core.channel.reply.withReplyDispatcher({
|
dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
onSettled: () => {
|
onSettled: () => markDispatchIdle(),
|
||||||
markDispatchIdle();
|
replyOptions: {
|
||||||
},
|
...replyOptions,
|
||||||
run: () =>
|
allowProgressCallbacksWhenSourceDeliverySuppressed: draftToolProgressEnabled
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
? true
|
||||||
ctx: ctxPayload,
|
: undefined,
|
||||||
cfg,
|
preserveProgressCallbackStartOrder: draftPreviewEnabled ? true : undefined,
|
||||||
dispatcher,
|
onObservedReplyDelivery: draftToolProgressEnabled
|
||||||
replyOptions: {
|
? () => draftStream.clear()
|
||||||
...replyOptions,
|
: undefined,
|
||||||
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
disableBlockStreaming: draftPreviewEnabled
|
||||||
draftToolProgressEnabled ? true : undefined,
|
? true
|
||||||
preserveProgressCallbackStartOrder: draftPreviewEnabled
|
: typeof account.blockStreaming === "boolean"
|
||||||
? true
|
? !account.blockStreaming
|
||||||
: undefined,
|
: undefined,
|
||||||
onObservedReplyDelivery: draftToolProgressEnabled
|
...(suppressDefaultToolProgressMessages
|
||||||
? () => draftStream.clear()
|
? { suppressDefaultToolProgressMessages: true }
|
||||||
: undefined,
|
: {}),
|
||||||
disableBlockStreaming: draftPreviewEnabled
|
onModelSelected,
|
||||||
? true
|
onPartialReply: (payloadResult) => {
|
||||||
: typeof account.blockStreaming === "boolean"
|
if (account.streamingMode !== "progress") {
|
||||||
? !account.blockStreaming
|
return updateDraftFromPartial(payloadResult.text);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
onAssistantMessageStart: () => {
|
||||||
|
lastPartialText = "";
|
||||||
|
progressDraft.resetReasoningProgress();
|
||||||
|
if (account.streamingMode === "block") {
|
||||||
|
blockPreviewAssistantMessagePending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (account.streamingMode !== "progress") {
|
||||||
|
progressDraft.reset();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReasoningEnd: () => {
|
||||||
|
// Hidden reasoning has no visible boundary. Only transitions that
|
||||||
|
// actually render text, reasoning, or tools rotate preview posts.
|
||||||
|
lastPartialText = "";
|
||||||
|
progressDraft.resetReasoningProgress();
|
||||||
|
if (
|
||||||
|
account.streamingMode !== "block" &&
|
||||||
|
account.streamingMode !== "progress"
|
||||||
|
) {
|
||||||
|
progressDraft.reset();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReasoningStream: async (payloadResult) => {
|
||||||
|
if (account.streamingMode === "progress") {
|
||||||
|
await progressDraft.pushReasoningProgress(
|
||||||
|
payloadResult.text || "Thinking…",
|
||||||
|
{ snapshot: payloadResult.isReasoningSnapshot === true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lastPartialText) {
|
||||||
|
const boundarySettled = enterBlockPreviewActivity("reasoning");
|
||||||
|
draftStream.update("Thinking…");
|
||||||
|
previewBoundaryController.noteUpdate();
|
||||||
|
await boundarySettled;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToolStart: async (payloadValue) => {
|
||||||
|
if (!draftToolProgressEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boundarySettled = enterBlockPreviewActivity("tool");
|
||||||
|
// Boundary detach and progress staging both happen synchronously before
|
||||||
|
// their first await; agent callbacks may be dispatched fire-and-forget.
|
||||||
|
const progressSettled = progressDraft.pushToolProgress(
|
||||||
|
buildChannelProgressDraftLineForEntry(
|
||||||
|
account.config,
|
||||||
|
{
|
||||||
|
event: "tool",
|
||||||
|
itemId: payloadValue.itemId,
|
||||||
|
toolCallId: payloadValue.toolCallId,
|
||||||
|
name: payloadValue.name,
|
||||||
|
phase: payloadValue.phase,
|
||||||
|
args: payloadValue.args,
|
||||||
|
},
|
||||||
|
payloadValue.detailMode
|
||||||
|
? { detailMode: payloadValue.detailMode }
|
||||||
: undefined,
|
: undefined,
|
||||||
...(suppressDefaultToolProgressMessages
|
),
|
||||||
? { suppressDefaultToolProgressMessages: true }
|
{ startImmediately: true },
|
||||||
: {}),
|
);
|
||||||
onModelSelected,
|
previewBoundaryController.noteUpdate();
|
||||||
onPartialReply: (payloadResult) => {
|
await Promise.all([boundarySettled, progressSettled]);
|
||||||
if (account.streamingMode !== "progress") {
|
},
|
||||||
return updateDraftFromPartial(payloadResult.text);
|
onItemEvent: async (payloadLocal) => {
|
||||||
}
|
if (!draftToolProgressEnabled) {
|
||||||
return undefined;
|
return;
|
||||||
},
|
}
|
||||||
onAssistantMessageStart: () => {
|
const boundarySettled = enterBlockPreviewActivity("tool");
|
||||||
lastPartialText = "";
|
const progressSettled = progressDraft.pushToolProgress(
|
||||||
progressDraft.resetReasoningProgress();
|
buildChannelProgressDraftLineForEntry(account.config, {
|
||||||
if (account.streamingMode === "block") {
|
event: "item",
|
||||||
blockPreviewAssistantMessagePending = true;
|
itemId: payloadLocal.itemId,
|
||||||
return;
|
itemKind: payloadLocal.kind,
|
||||||
}
|
title: payloadLocal.title,
|
||||||
if (account.streamingMode !== "progress") {
|
name: payloadLocal.name,
|
||||||
progressDraft.reset();
|
phase: payloadLocal.phase,
|
||||||
}
|
status: payloadLocal.status,
|
||||||
},
|
summary: payloadLocal.summary,
|
||||||
onReasoningEnd: () => {
|
progressText: payloadLocal.progressText,
|
||||||
// Hidden reasoning has no visible boundary. Only transitions that
|
meta: payloadLocal.meta,
|
||||||
// actually render text, reasoning, or tools rotate preview posts.
|
}),
|
||||||
lastPartialText = "";
|
{ startImmediately: true },
|
||||||
progressDraft.resetReasoningProgress();
|
);
|
||||||
if (
|
previewBoundaryController.noteUpdate();
|
||||||
account.streamingMode !== "block" &&
|
await Promise.all([boundarySettled, progressSettled]);
|
||||||
account.streamingMode !== "progress"
|
},
|
||||||
) {
|
},
|
||||||
progressDraft.reset();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onReasoningStream: async (payloadResult) => {
|
|
||||||
if (account.streamingMode === "progress") {
|
|
||||||
await progressDraft.pushReasoningProgress(
|
|
||||||
payloadResult.text || "Thinking…",
|
|
||||||
{ snapshot: payloadResult.isReasoningSnapshot === true },
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!lastPartialText) {
|
|
||||||
const boundarySettled = enterBlockPreviewActivity("reasoning");
|
|
||||||
draftStream.update("Thinking…");
|
|
||||||
previewBoundaryController.noteUpdate();
|
|
||||||
await boundarySettled;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onToolStart: async (payloadValue) => {
|
|
||||||
if (!draftToolProgressEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const boundarySettled = enterBlockPreviewActivity("tool");
|
|
||||||
// Boundary detach and progress staging both happen synchronously before
|
|
||||||
// their first await; agent callbacks may be dispatched fire-and-forget.
|
|
||||||
const progressSettled = progressDraft.pushToolProgress(
|
|
||||||
buildChannelProgressDraftLineForEntry(
|
|
||||||
account.config,
|
|
||||||
{
|
|
||||||
event: "tool",
|
|
||||||
itemId: payloadValue.itemId,
|
|
||||||
toolCallId: payloadValue.toolCallId,
|
|
||||||
name: payloadValue.name,
|
|
||||||
phase: payloadValue.phase,
|
|
||||||
args: payloadValue.args,
|
|
||||||
},
|
|
||||||
payloadValue.detailMode
|
|
||||||
? { detailMode: payloadValue.detailMode }
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
{ startImmediately: true },
|
|
||||||
);
|
|
||||||
previewBoundaryController.noteUpdate();
|
|
||||||
await Promise.all([boundarySettled, progressSettled]);
|
|
||||||
},
|
|
||||||
onItemEvent: async (payloadLocal) => {
|
|
||||||
if (!draftToolProgressEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const boundarySettled = enterBlockPreviewActivity("tool");
|
|
||||||
const progressSettled = progressDraft.pushToolProgress(
|
|
||||||
buildChannelProgressDraftLineForEntry(account.config, {
|
|
||||||
event: "item",
|
|
||||||
itemId: payloadLocal.itemId,
|
|
||||||
itemKind: payloadLocal.kind,
|
|
||||||
title: payloadLocal.title,
|
|
||||||
name: payloadLocal.name,
|
|
||||||
phase: payloadLocal.phase,
|
|
||||||
status: payloadLocal.status,
|
|
||||||
summary: payloadLocal.summary,
|
|
||||||
progressText: payloadLocal.progressText,
|
|
||||||
meta: payloadLocal.meta,
|
|
||||||
}),
|
|
||||||
{ startImmediately: true },
|
|
||||||
);
|
|
||||||
previewBoundaryController.noteUpdate();
|
|
||||||
await Promise.all([boundarySettled, progressSettled]);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,10 +6,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
import {
|
import {
|
||||||
asDateTimestampMs,
|
asDateTimestampMs,
|
||||||
resolveExpiresAtMsFromDurationMs,
|
resolveExpiresAtMsFromDurationMs,
|
||||||
} from "openclaw/plugin-sdk/number-runtime";
|
} from "openclaw/plugin-sdk/number-runtime";
|
||||||
|
import {
|
||||||
|
createReplyDispatcherWithTyping,
|
||||||
|
dispatchInboundMessage,
|
||||||
|
finalizeInboundContext,
|
||||||
|
} from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||||
@@ -830,7 +836,7 @@ async function handleSlashCommandAsync(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build inbound context — the command text is the body
|
// Build inbound context — the command text is the body
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = finalizeInboundContext({
|
||||||
Body: commandText,
|
Body: commandText,
|
||||||
BodyForAgent: commandText,
|
BodyForAgent: commandText,
|
||||||
RawBody: commandText,
|
RawBody: commandText,
|
||||||
@@ -886,58 +892,51 @@ async function handleSlashCommandAsync(params: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
const humanDelay = resolveHumanDelayConfig(cfg, route.agentId);
|
||||||
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
|
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
|
||||||
isDirect: kind === "direct",
|
isDirect: kind === "direct",
|
||||||
dmRetryOptions: account.config.dmChannelRetry,
|
dmRetryOptions: account.config.dmChannelRetry,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
...replyPipeline,
|
||||||
...replyPipeline,
|
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
||||||
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
|
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
||||||
onDeliverySettled: deliveryBarrier.markDeliverySettled,
|
humanDelay,
|
||||||
humanDelay,
|
deliver: async (payload: ReplyPayload) => {
|
||||||
deliver: async (payload: ReplyPayload) => {
|
await deliverMattermostReplyPayload({
|
||||||
await deliverMattermostReplyPayload({
|
core,
|
||||||
core,
|
|
||||||
cfg,
|
|
||||||
payload,
|
|
||||||
to,
|
|
||||||
accountId: account.accountId,
|
|
||||||
agentId: route.agentId,
|
|
||||||
textLimit,
|
|
||||||
tableMode,
|
|
||||||
sendMessage: sendMessageMattermost,
|
|
||||||
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
|
|
||||||
});
|
|
||||||
runtime.log?.(`delivered slash reply to ${to}`);
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
runtime.error?.(
|
|
||||||
`mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onReplyStart: typingCallbacks?.onReplyStart,
|
|
||||||
});
|
|
||||||
|
|
||||||
await core.channel.reply.withReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => {
|
|
||||||
markDispatchIdle();
|
|
||||||
},
|
|
||||||
run: () =>
|
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
payload,
|
||||||
replyOptions: {
|
to,
|
||||||
...replyOptions,
|
accountId: account.accountId,
|
||||||
disableBlockStreaming:
|
agentId: route.agentId,
|
||||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
textLimit,
|
||||||
onModelSelected,
|
tableMode,
|
||||||
},
|
sendMessage: sendMessageMattermost,
|
||||||
}),
|
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
|
||||||
|
});
|
||||||
|
runtime.log?.(`delivered slash reply to ${to}`);
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(
|
||||||
|
`mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onReplyStart: typingCallbacks?.onReplyStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
|
|||||||
import { asRecord } from "./dreaming-shared.js";
|
import { asRecord } from "./dreaming-shared.js";
|
||||||
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
|
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
|
||||||
|
|
||||||
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
|
function resolveDreamingPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
|
||||||
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
|
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
|
||||||
return asRecord(entry?.config) ?? {};
|
return asRecord(entry?.config) ?? {};
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ function formatPhaseGuide(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatStatus(cfg: OpenClawConfig): string {
|
function formatStatus(cfg: OpenClawConfig): string {
|
||||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
const pluginConfig = resolveDreamingPluginConfig(cfg);
|
||||||
const dreaming = resolveMemoryDreamingConfig({
|
const dreaming = resolveMemoryDreamingConfig({
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import path from "node:path";
|
|||||||
import { expectDefined } from "@openclaw/normalization-core";
|
import { expectDefined } from "@openclaw/normalization-core";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime";
|
import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
import {
|
||||||
import { resolveMemoryCorePluginConfig } from "openclaw/plugin-sdk/memory-core-host-status";
|
resolveMemoryDreamingPluginConfig,
|
||||||
|
resolveSessionTranscriptsDirForAgent,
|
||||||
|
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||||
import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||||
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||||
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||||
@@ -236,7 +238,7 @@ function createHarness(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
|
const pluginConfig = resolveMemoryDreamingPluginConfig(resolvedConfig) ?? {};
|
||||||
const beforeAgentReply = async (
|
const beforeAgentReply = async (
|
||||||
event: { cleanedBody: string },
|
event: { cleanedBody: string },
|
||||||
ctx: { trigger?: string; workspaceDir?: string },
|
ctx: { trigger?: string; workspaceDir?: string },
|
||||||
@@ -434,7 +436,7 @@ describe("memory-core dreaming phases", () => {
|
|||||||
await runDreamingSweepPhases({
|
await runDreamingSweepPhases({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
cfg: testConfig,
|
cfg: testConfig,
|
||||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
|
||||||
logger,
|
logger,
|
||||||
subagent,
|
subagent,
|
||||||
nowMs,
|
nowMs,
|
||||||
@@ -501,7 +503,7 @@ describe("memory-core dreaming phases", () => {
|
|||||||
runDreamingSweepPhases({
|
runDreamingSweepPhases({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
cfg: testConfig,
|
cfg: testConfig,
|
||||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
|
||||||
logger,
|
logger,
|
||||||
subagent,
|
subagent,
|
||||||
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
|
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
|
||||||
@@ -737,7 +739,7 @@ describe("memory-core dreaming phases", () => {
|
|||||||
await runDreamingSweepPhases({
|
await runDreamingSweepPhases({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
cfg: testConfig,
|
cfg: testConfig,
|
||||||
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
|
pluginConfig: resolveMemoryDreamingPluginConfig(testConfig),
|
||||||
logger,
|
logger,
|
||||||
subagent,
|
subagent,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
// Memory Core plugin module implements dreaming behavior.
|
// Memory Core plugin module implements dreaming behavior.
|
||||||
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
||||||
|
import { resolveMemoryDreamingPluginConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS as DEFAULT_MEMORY_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS,
|
DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS as DEFAULT_MEMORY_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS,
|
||||||
DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS as DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS,
|
DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS as DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
MANAGED_MEMORY_DREAMING_CRON_NAME as MANAGED_DREAMING_CRON_NAME,
|
MANAGED_MEMORY_DREAMING_CRON_NAME as MANAGED_DREAMING_CRON_NAME,
|
||||||
MANAGED_MEMORY_DREAMING_CRON_TAG as MANAGED_DREAMING_CRON_TAG,
|
MANAGED_MEMORY_DREAMING_CRON_TAG as MANAGED_DREAMING_CRON_TAG,
|
||||||
MEMORY_DREAMING_SYSTEM_EVENT_TEXT as DREAMING_SYSTEM_EVENT_TEXT,
|
MEMORY_DREAMING_SYSTEM_EVENT_TEXT as DREAMING_SYSTEM_EVENT_TEXT,
|
||||||
resolveMemoryCorePluginConfig,
|
|
||||||
resolveMemoryDeepDreamingConfig,
|
resolveMemoryDeepDreamingConfig,
|
||||||
resolveMemoryDreamingWorkspaces,
|
resolveMemoryDreamingWorkspaces,
|
||||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||||
@@ -550,7 +550,7 @@ async function runShortTermDreamingPromotionIfTriggered(params: {
|
|||||||
let totalCandidates = 0;
|
let totalCandidates = 0;
|
||||||
let totalApplied = 0;
|
let totalApplied = 0;
|
||||||
let failedWorkspaces = 0;
|
let failedWorkspaces = 0;
|
||||||
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
|
const pluginConfig = params.cfg ? resolveMemoryDreamingPluginConfig(params.cfg) : undefined;
|
||||||
const detachNarratives = params.trigger === "cron";
|
const detachNarratives = params.trigger === "cron";
|
||||||
const [
|
const [
|
||||||
{ writeDeepDreamingReport },
|
{ writeDeepDreamingReport },
|
||||||
@@ -793,10 +793,10 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
|||||||
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
|
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
|
||||||
const pluginConfig =
|
const pluginConfig =
|
||||||
params.reason === "startup"
|
params.reason === "startup"
|
||||||
? (resolveMemoryCorePluginConfig(startupCfg) ??
|
? (resolveMemoryDreamingPluginConfig(startupCfg) ??
|
||||||
resolveMemoryCorePluginConfig(api.config) ??
|
resolveMemoryDreamingPluginConfig(api.config) ??
|
||||||
api.pluginConfig)
|
api.pluginConfig)
|
||||||
: resolveMemoryCorePluginConfig(startupCfg);
|
: resolveMemoryDreamingPluginConfig(startupCfg);
|
||||||
const config = resolveShortTermPromotionDreamingConfig({
|
const config = resolveShortTermPromotionDreamingConfig({
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
cfg: startupCfg,
|
cfg: startupCfg,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
readFiniteNumberParam,
|
readFiniteNumberParam,
|
||||||
readPositiveIntegerParam,
|
readPositiveIntegerParam,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
|
resolveMemoryDreamingPluginConfig,
|
||||||
type MemoryCorpusSearchResult,
|
type MemoryCorpusSearchResult,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||||
@@ -18,7 +19,6 @@ import type {
|
|||||||
MemorySearchRuntimeDebug,
|
MemorySearchRuntimeDebug,
|
||||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||||
import {
|
import {
|
||||||
resolveMemoryCorePluginConfig,
|
|
||||||
resolveMemoryDreamingConfig,
|
resolveMemoryDreamingConfig,
|
||||||
resolveMemoryDeepDreamingConfig,
|
resolveMemoryDeepDreamingConfig,
|
||||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||||
@@ -551,7 +551,7 @@ export function createMemorySearchTool(options: {
|
|||||||
mode: citationsMode,
|
mode: citationsMode,
|
||||||
sessionKey: options.agentSessionKey,
|
sessionKey: options.agentSessionKey,
|
||||||
});
|
});
|
||||||
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
|
const pluginConfig = resolveMemoryDreamingPluginConfig(cfg);
|
||||||
const dreamingEnabled = resolveMemoryDreamingConfig({
|
const dreamingEnabled = resolveMemoryDreamingConfig({
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TRUSTED_CLIENT_TOKEN,
|
TRUSTED_CLIENT_TOKEN,
|
||||||
generateSecMsGecToken,
|
generateSecMsGecToken,
|
||||||
} from "node-edge-tts/dist/drm.js";
|
} from "node-edge-tts/dist/drm.js";
|
||||||
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import {
|
import {
|
||||||
assertOkOrThrowProviderError,
|
assertOkOrThrowProviderError,
|
||||||
readProviderJsonResponse,
|
readProviderJsonResponse,
|
||||||
@@ -288,7 +288,7 @@ export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin {
|
|||||||
audioBuffer,
|
audioBuffer,
|
||||||
outputFormat: format,
|
outputFormat: format,
|
||||||
fileExtension,
|
fileExtension,
|
||||||
voiceCompatible: isVoiceCompatibleAudio({ fileName: outputPath }),
|
voiceCompatible: isVoiceMessageCompatibleAudio({ fileName: outputPath }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,22 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|||||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||||
import { chunkMarkdownTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
import { chunkMarkdownTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||||
import { describe, it } from "vitest";
|
import { describe, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig, ReplyPayload } from "../runtime-api.js";
|
import type { OpenClawConfig, ReplyPayload } from "../runtime-api.js";
|
||||||
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
|
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
import { setMSTeamsRuntime } from "./runtime.js";
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||||
|
|
||||||
|
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/** Options msteams passes into core createReplyDispatcherWithTyping (capture seam). */
|
/** Options msteams passes into core createReplyDispatcherWithTyping (capture seam). */
|
||||||
type CapturedDispatcherOptions = {
|
type CapturedDispatcherOptions = {
|
||||||
onReplyStart?: () => Promise<void> | void;
|
onReplyStart?: () => Promise<void> | void;
|
||||||
@@ -230,11 +240,18 @@ const MSTEAMS_TRACE_CASES: readonly MSTeamsTraceCase[] = [
|
|||||||
|
|
||||||
function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) {
|
function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) {
|
||||||
let captured: CapturedDispatcherOptions | undefined;
|
let captured: CapturedDispatcherOptions | undefined;
|
||||||
setMSTeamsRuntime(
|
setMSTeamsRuntime(createTraceRuntimeStub(recorder, () => undefined));
|
||||||
createTraceRuntimeStub(recorder, (options) => {
|
createReplyDispatcherWithTypingMock.mockImplementation((options: CapturedDispatcherOptions) => {
|
||||||
captured = options;
|
captured = options;
|
||||||
}),
|
return {
|
||||||
);
|
dispatcher: {},
|
||||||
|
replyOptions: {},
|
||||||
|
markDispatchIdle: () => {
|
||||||
|
options.typingCallbacks?.onIdle?.();
|
||||||
|
},
|
||||||
|
markRunComplete: () => {},
|
||||||
|
};
|
||||||
|
});
|
||||||
const stream = createRecordingStream(recorder, traceCase.streamWriteFault);
|
const stream = createRecordingStream(recorder, traceCase.streamWriteFault);
|
||||||
const context = createRecordingTurnContext({
|
const context = createRecordingTurnContext({
|
||||||
recorder,
|
recorder,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Msteams plugin module implements feedback invoke behavior.
|
// Msteams plugin module implements feedback invoke behavior.
|
||||||
import path from "node:path";
|
import { recordChannelFeedbackEvent } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
|
||||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import { formatUnknownError } from "./errors.js";
|
import { formatUnknownError } from "./errors.js";
|
||||||
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
|
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
|
||||||
@@ -131,19 +130,12 @@ export async function runMSTeamsFeedbackInvokeHandler(
|
|||||||
hasComment: Boolean(userComment),
|
hasComment: Boolean(userComment),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write feedback event to session transcript
|
|
||||||
try {
|
try {
|
||||||
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
|
await recordChannelFeedbackEvent({
|
||||||
|
cfg: deps.cfg,
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
sessionKey: route.sessionKey,
|
||||||
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
event: feedbackEvent,
|
||||||
const transcriptFile = path.join(storePath, `${safeKey}.jsonl`);
|
|
||||||
await appendRegularFile({
|
|
||||||
filePath: transcriptFile,
|
|
||||||
content: `${JSON.stringify(feedbackEvent)}\n`,
|
|
||||||
rejectSymlinkParents: true,
|
|
||||||
}).catch(() => {
|
|
||||||
// Best effort — transcript dir may not exist yet
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Best effort
|
// Best effort
|
||||||
@@ -181,12 +173,11 @@ export async function runMSTeamsFeedbackInvokeHandler(
|
|||||||
runFeedbackReflection({
|
runFeedbackReflection({
|
||||||
cfg: deps.cfg,
|
cfg: deps.cfg,
|
||||||
app: deps.app,
|
app: deps.app,
|
||||||
appId: deps.appId,
|
|
||||||
conversationRef,
|
conversationRef,
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
conversationId,
|
conversationId,
|
||||||
feedbackMessageId: messageId,
|
conversationKind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
||||||
userComment,
|
userComment,
|
||||||
log: deps.log,
|
log: deps.log,
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
// Msteams plugin module implements feedback reflection prompt behavior.
|
|
||||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
||||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
||||||
|
|
||||||
/** Max chars of the thumbed-down response to include in the reflection prompt. */
|
|
||||||
const MAX_RESPONSE_CHARS = 500;
|
|
||||||
|
|
||||||
type ParsedReflectionResponse = {
|
|
||||||
learning: string;
|
|
||||||
followUp: boolean;
|
|
||||||
userMessage?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildReflectionPrompt(params: {
|
|
||||||
thumbedDownResponse?: string;
|
|
||||||
userComment?: string;
|
|
||||||
}): string {
|
|
||||||
const parts: string[] = ["A user indicated your previous response wasn't helpful."];
|
|
||||||
|
|
||||||
if (params.thumbedDownResponse) {
|
|
||||||
const truncated =
|
|
||||||
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
|
|
||||||
? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...`
|
|
||||||
: params.thumbedDownResponse;
|
|
||||||
parts.push(`\nYour response was:\n> ${truncated}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.userComment) {
|
|
||||||
parts.push(`\nUser's comment: "${params.userComment}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
"\nBriefly reflect: what could you improve? Consider tone, length, " +
|
|
||||||
"accuracy, relevance, and specificity. Reply with a single JSON object " +
|
|
||||||
'only, no markdown or prose, using this exact shape:\n{"learning":"...",' +
|
|
||||||
'"followUp":false,"userMessage":""}\n' +
|
|
||||||
"- learning: a short internal adjustment note (1-2 sentences) for your " +
|
|
||||||
"future behavior in this conversation.\n" +
|
|
||||||
"- followUp: true only if the user needs a direct follow-up message.\n" +
|
|
||||||
"- userMessage: only the exact user-facing message to send; empty string " +
|
|
||||||
"when followUp is false.",
|
|
||||||
);
|
|
||||||
|
|
||||||
return parts.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBooleanLike(value: unknown): boolean | undefined {
|
|
||||||
if (typeof value === "boolean") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const normalized = normalizeOptionalLowercaseString(value);
|
|
||||||
if (normalized === "true" || normalized === "yes") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (normalized === "false" || normalized === "no") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStructuredReflectionValue(value: unknown): ParsedReflectionResponse | null {
|
|
||||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = value as {
|
|
||||||
learning?: unknown;
|
|
||||||
followUp?: unknown;
|
|
||||||
userMessage?: unknown;
|
|
||||||
};
|
|
||||||
const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : undefined;
|
|
||||||
if (!learning) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
learning,
|
|
||||||
followUp: parseBooleanLike(candidate.followUp) ?? false,
|
|
||||||
userMessage:
|
|
||||||
typeof candidate.userMessage === "string" && candidate.userMessage.trim()
|
|
||||||
? candidate.userMessage.trim()
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseReflectionResponse(text: string): ParsedReflectionResponse | null {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
trimmed,
|
|
||||||
...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const candidateText of candidates) {
|
|
||||||
const candidate = candidateText.trim();
|
|
||||||
if (!candidate) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = parseStructuredReflectionValue(JSON.parse(candidate));
|
|
||||||
if (parsed) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to the next parse strategy.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safe fallback: keep the internal learning, but never auto-message the user.
|
|
||||||
return {
|
|
||||||
learning: trimmed,
|
|
||||||
followUp: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
// Msteams plugin module implements feedback reflection store behavior.
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { getMSTeamsRuntime } from "./runtime.js";
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
/** Default cooldown between reflections per session (5 minutes). */
|
|
||||||
export const DEFAULT_COOLDOWN_MS = 300_000;
|
|
||||||
|
|
||||||
/** Tracks last reflection time per session to enforce cooldown. */
|
|
||||||
const lastReflectionBySession = new Map<string, number>();
|
|
||||||
|
|
||||||
/** Maximum cooldown entries before pruning expired ones. */
|
|
||||||
const MAX_COOLDOWN_ENTRIES = 500;
|
|
||||||
const LEARNINGS_NAMESPACE = "feedback-learnings";
|
const LEARNINGS_NAMESPACE = "feedback-learnings";
|
||||||
const MAX_LEARNING_ENTRIES = 10_000;
|
const MAX_LEARNING_ENTRIES = 10_000;
|
||||||
|
|
||||||
@@ -23,59 +14,22 @@ function learningStoreKey(storePath: string, sessionKey: string): string {
|
|||||||
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
|
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLearningStore() {
|
|
||||||
return getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
|
|
||||||
namespace: LEARNINGS_NAMESPACE,
|
|
||||||
maxEntries: MAX_LEARNING_ENTRIES,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prune expired cooldown entries to prevent unbounded memory growth. */
|
|
||||||
function pruneExpiredCooldowns(cooldownMs: number): void {
|
|
||||||
if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, time] of lastReflectionBySession) {
|
|
||||||
if (now - time >= cooldownMs) {
|
|
||||||
lastReflectionBySession.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a reflection is allowed (cooldown not active). */
|
|
||||||
export function isReflectionAllowed(sessionKey: string, cooldownMs?: number): boolean {
|
|
||||||
const cooldown = cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
||||||
const lastTime = lastReflectionBySession.get(sessionKey);
|
|
||||||
if (lastTime == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return Date.now() - lastTime >= cooldown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record that a reflection was run for a session. */
|
|
||||||
export function recordReflectionTime(sessionKey: string, cooldownMs?: number): void {
|
|
||||||
lastReflectionBySession.set(sessionKey, Date.now());
|
|
||||||
pruneExpiredCooldowns(cooldownMs ?? DEFAULT_COOLDOWN_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Store a learning derived from feedback reflection. */
|
|
||||||
export async function storeSessionLearning(params: {
|
export async function storeSessionLearning(params: {
|
||||||
storePath: string;
|
storePath: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
learning: string;
|
learning: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const store = openLearningStore();
|
const store = getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
|
||||||
const key = learningStoreKey(params.storePath, params.sessionKey);
|
namespace: LEARNINGS_NAMESPACE,
|
||||||
const existing = await store.lookup(key);
|
maxEntries: MAX_LEARNING_ENTRIES,
|
||||||
let learnings = existing?.learnings ?? [];
|
|
||||||
learnings.push(params.learning);
|
|
||||||
if (learnings.length > 10) {
|
|
||||||
learnings = learnings.slice(-10);
|
|
||||||
}
|
|
||||||
await store.register(key, {
|
|
||||||
sessionKey: params.sessionKey,
|
|
||||||
learnings,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
});
|
||||||
|
const key = learningStoreKey(params.storePath, params.sessionKey);
|
||||||
|
if (!store.update) {
|
||||||
|
throw new Error("plugin state atomic update is unavailable");
|
||||||
|
}
|
||||||
|
await store.update(key, (existing) => ({
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
learnings: [...(existing?.learnings ?? []), params.learning].slice(-10),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
// Msteams tests cover feedback reflection plugin behavior.
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
|
|
||||||
import { isReflectionAllowed, recordReflectionTime } from "./feedback-reflection-store.js";
|
|
||||||
import { buildFeedbackEvent } from "./feedback-reflection.js";
|
|
||||||
|
|
||||||
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
|
|
||||||
// on the ES2024 String.prototype.isWellFormed() runtime API.
|
|
||||||
const UNPAIRED_SURROGATE_RE =
|
|
||||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
||||||
|
|
||||||
describe("buildFeedbackEvent", () => {
|
|
||||||
it("builds a well-formed custom event", () => {
|
|
||||||
const event = buildFeedbackEvent({
|
|
||||||
messageId: "msg-123",
|
|
||||||
value: "negative",
|
|
||||||
comment: "too verbose",
|
|
||||||
sessionKey: "msteams:user1",
|
|
||||||
agentId: "default",
|
|
||||||
conversationId: "19:abc",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(event.type).toBe("custom");
|
|
||||||
expect(event.event).toBe("feedback");
|
|
||||||
expect(event.value).toBe("negative");
|
|
||||||
expect(event.comment).toBe("too verbose");
|
|
||||||
expect(event.messageId).toBe("msg-123");
|
|
||||||
expect(event.ts).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits comment when not provided", () => {
|
|
||||||
const event = buildFeedbackEvent({
|
|
||||||
messageId: "msg-123",
|
|
||||||
value: "positive",
|
|
||||||
sessionKey: "msteams:user1",
|
|
||||||
agentId: "default",
|
|
||||||
conversationId: "19:abc",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(event.comment).toBeUndefined();
|
|
||||||
expect(event.value).toBe("positive");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildReflectionPrompt", () => {
|
|
||||||
it("includes the thumbed-down response", () => {
|
|
||||||
const prompt = buildReflectionPrompt({
|
|
||||||
thumbedDownResponse: "Here is a long explanation...",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prompt).toContain("previous response wasn't helpful");
|
|
||||||
expect(prompt).toContain("Here is a long explanation...");
|
|
||||||
expect(prompt).toContain("reflect");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("truncates long responses", () => {
|
|
||||||
const longResponse = "x".repeat(600);
|
|
||||||
const prompt = buildReflectionPrompt({
|
|
||||||
thumbedDownResponse: longResponse,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prompt).toContain("...");
|
|
||||||
expect(prompt.length).toBeLessThan(longResponse.length + 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
|
|
||||||
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
|
|
||||||
|
|
||||||
const prompt = buildReflectionPrompt({ thumbedDownResponse });
|
|
||||||
|
|
||||||
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
|
|
||||||
expect(prompt).toContain(`${"a".repeat(499)}...`);
|
|
||||||
expect(prompt).not.toContain("\ud83e");
|
|
||||||
expect(prompt).not.toContain("\udd9e");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
|
|
||||||
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
|
|
||||||
|
|
||||||
const prompt = buildReflectionPrompt({ thumbedDownResponse });
|
|
||||||
|
|
||||||
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
|
|
||||||
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes user comment when provided", () => {
|
|
||||||
const prompt = buildReflectionPrompt({
|
|
||||||
thumbedDownResponse: "Some response",
|
|
||||||
userComment: "Too wordy",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prompt).toContain('User\'s comment: "Too wordy"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("works without optional params", () => {
|
|
||||||
const prompt = buildReflectionPrompt({});
|
|
||||||
expect(prompt).toContain("previous response wasn't helpful");
|
|
||||||
expect(prompt).toContain('"followUp":false');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseReflectionResponse", () => {
|
|
||||||
it("parses strict JSON output", () => {
|
|
||||||
expect(
|
|
||||||
parseReflectionResponse(
|
|
||||||
'{"learning":"Be more direct next time.","followUp":true,"userMessage":"Sorry about that. I will keep it tighter."}',
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
learning: "Be more direct next time.",
|
|
||||||
followUp: true,
|
|
||||||
userMessage: "Sorry about that. I will keep it tighter.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses JSON inside markdown fences", () => {
|
|
||||||
expect(
|
|
||||||
parseReflectionResponse(
|
|
||||||
'```json\n{"learning":"Ask a clarifying question first.","followUp":false,"userMessage":""}\n```',
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
learning: "Ask a clarifying question first.",
|
|
||||||
followUp: false,
|
|
||||||
userMessage: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to internal-only learning when parsing fails", () => {
|
|
||||||
expect(parseReflectionResponse("Be more concise.\nFollow up: yes.")).toEqual({
|
|
||||||
learning: "Be more concise.\nFollow up: yes.",
|
|
||||||
followUp: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reflection cooldown", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows first reflection", () => {
|
|
||||||
expect(isReflectionAllowed("session-first")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks reflection within cooldown", () => {
|
|
||||||
recordReflectionTime("session-blocked");
|
|
||||||
expect(isReflectionAllowed("session-blocked", 60_000)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows reflection after cooldown expires", () => {
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue(0);
|
|
||||||
recordReflectionTime("session-expired");
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue(2);
|
|
||||||
expect(isReflectionAllowed("session-expired", 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks sessions independently", () => {
|
|
||||||
recordReflectionTime("session-tracked-1");
|
|
||||||
expect(isReflectionAllowed("session-tracked-1", 60_000)).toBe(false);
|
|
||||||
expect(isReflectionAllowed("session-tracked-2", 60_000)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps longer custom cooldown entries during pruning", () => {
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue(0);
|
|
||||||
recordReflectionTime("prune-target", 600_000);
|
|
||||||
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue(301_000);
|
|
||||||
for (let index = 0; index <= 500; index += 1) {
|
|
||||||
recordReflectionTime(`prune-session-${index}`, 600_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(isReflectionAllowed("prune-target", 600_000)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
// Msteams plugin module implements feedback reflection behavior.
|
// Msteams plugin module implements feedback reflection behavior.
|
||||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
||||||
import {
|
import {
|
||||||
dispatchReplyFromConfigWithSettledDispatcher,
|
DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS,
|
||||||
type OpenClawConfig,
|
runChannelFeedbackReflection,
|
||||||
} from "../runtime-api.js";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
|
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
|
import type { OpenClawConfig } from "../runtime-api.js";
|
||||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { formatUnknownError } from "./errors.js";
|
import { formatUnknownError } from "./errors.js";
|
||||||
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
|
import { storeSessionLearning } from "./feedback-reflection-store.js";
|
||||||
import {
|
|
||||||
DEFAULT_COOLDOWN_MS,
|
|
||||||
isReflectionAllowed,
|
|
||||||
recordReflectionTime,
|
|
||||||
storeSessionLearning,
|
|
||||||
} from "./feedback-reflection-store.js";
|
|
||||||
import { buildConversationReference } from "./messenger.js";
|
import { buildConversationReference } from "./messenger.js";
|
||||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||||
import { getMSTeamsRuntime } from "./runtime.js";
|
|
||||||
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
|
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
|
||||||
import type { MSTeamsApp } from "./sdk.js";
|
import type { MSTeamsApp } from "./sdk.js";
|
||||||
|
|
||||||
@@ -30,7 +24,6 @@ type FeedbackEvent = {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
reflectionLearning?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildFeedbackEvent(params: {
|
export function buildFeedbackEvent(params: {
|
||||||
@@ -57,173 +50,65 @@ export function buildFeedbackEvent(params: {
|
|||||||
type RunFeedbackReflectionParams = {
|
type RunFeedbackReflectionParams = {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
app: MSTeamsApp;
|
app: MSTeamsApp;
|
||||||
appId: string;
|
|
||||||
conversationRef: StoredConversationReference;
|
conversationRef: StoredConversationReference;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
feedbackMessageId: string;
|
conversationKind: "direct" | "group" | "channel";
|
||||||
thumbedDownResponse?: string;
|
thumbedDownResponse?: string;
|
||||||
userComment?: string;
|
userComment?: string;
|
||||||
log: MSTeamsMonitorLogger;
|
log: MSTeamsMonitorLogger;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildReflectionContext(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
conversationId: string;
|
|
||||||
sessionKey: string;
|
|
||||||
reflectionPrompt: string;
|
|
||||||
}) {
|
|
||||||
const core = getMSTeamsRuntime();
|
|
||||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
|
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
|
||||||
channel: "Teams",
|
|
||||||
from: "system",
|
|
||||||
body: params.reflectionPrompt,
|
|
||||||
envelope: envelopeOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ctxPayload: core.channel.reply.finalizeInboundContext({
|
|
||||||
Body: body,
|
|
||||||
BodyForAgent: params.reflectionPrompt,
|
|
||||||
RawBody: params.reflectionPrompt,
|
|
||||||
CommandBody: params.reflectionPrompt,
|
|
||||||
From: `msteams:system:${params.conversationId}`,
|
|
||||||
To: `conversation:${params.conversationId}`,
|
|
||||||
SessionKey: params.sessionKey,
|
|
||||||
ChatType: "direct" as const,
|
|
||||||
SenderName: "system",
|
|
||||||
SenderId: "system",
|
|
||||||
Provider: "msteams" as const,
|
|
||||||
Surface: "msteams" as const,
|
|
||||||
Timestamp: Date.now(),
|
|
||||||
WasMentioned: true,
|
|
||||||
CommandAuthorized: false,
|
|
||||||
OriginatingChannel: "msteams" as const,
|
|
||||||
OriginatingTo: `conversation:${params.conversationId}`,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createReflectionCaptureDispatcher(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
agentId: string;
|
|
||||||
log: MSTeamsMonitorLogger;
|
|
||||||
}) {
|
|
||||||
const core = getMSTeamsRuntime();
|
|
||||||
let response = "";
|
|
||||||
const noopTypingCallbacks = {
|
|
||||||
onReplyStart: async () => {},
|
|
||||||
onIdle: () => {},
|
|
||||||
onCleanup: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
||||||
deliver: async (payload) => {
|
|
||||||
if (payload.text) {
|
|
||||||
response += (response ? "\n" : "") + payload.text;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
typingCallbacks: noopTypingCallbacks,
|
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
|
||||||
onError: (err) => {
|
|
||||||
params.log.debug?.("reflection reply error", { error: formatUnknownError(err) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispatcher,
|
|
||||||
replyOptions,
|
|
||||||
readResponse: () => response,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendReflectionFollowUp(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
app: MSTeamsApp;
|
|
||||||
conversationRef: StoredConversationReference;
|
|
||||||
userMessage: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const baseRef = buildConversationReference(params.conversationRef);
|
|
||||||
await sendMSTeamsActivityWithReference(
|
|
||||||
params.app,
|
|
||||||
baseRef,
|
|
||||||
{ type: "message", text: params.userMessage },
|
|
||||||
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(params.cfg.channels?.msteams) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a background reflection after negative feedback.
|
* Run a background reflection after negative feedback.
|
||||||
* This is designed to be called fire-and-forget (don't await in the invoke handler).
|
* This is designed to be called fire-and-forget (don't await in the invoke handler).
|
||||||
*/
|
*/
|
||||||
export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> {
|
export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> {
|
||||||
const { cfg, log, sessionKey } = params;
|
const { cfg, log, sessionKey } = params;
|
||||||
const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS;
|
const cooldownMs =
|
||||||
if (!isReflectionAllowed(sessionKey, cooldownMs)) {
|
cfg.channels?.msteams?.feedbackReflectionCooldownMs ??
|
||||||
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
|
DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS;
|
||||||
return;
|
let reflection;
|
||||||
}
|
|
||||||
|
|
||||||
const reflectionPrompt = buildReflectionPrompt({
|
|
||||||
thumbedDownResponse: params.thumbedDownResponse,
|
|
||||||
userComment: params.userComment,
|
|
||||||
});
|
|
||||||
const runtime = getMSTeamsRuntime();
|
|
||||||
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: params.agentId,
|
|
||||||
});
|
|
||||||
const { ctxPayload } = buildReflectionContext({
|
|
||||||
cfg,
|
|
||||||
conversationId: params.conversationId,
|
|
||||||
sessionKey: params.sessionKey,
|
|
||||||
reflectionPrompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
const capture = createReflectionCaptureDispatcher({
|
|
||||||
cfg,
|
|
||||||
agentId: params.agentId,
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dispatchReplyFromConfigWithSettledDispatcher({
|
reflection = await runChannelFeedbackReflection({
|
||||||
ctxPayload,
|
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher: capture.dispatcher,
|
channel: "msteams",
|
||||||
onSettled: () => {},
|
channelLabel: "Teams",
|
||||||
replyOptions: capture.replyOptions,
|
agentId: params.agentId,
|
||||||
|
sessionKey,
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
conversationKind: params.conversationKind,
|
||||||
|
thumbedDownResponse: params.thumbedDownResponse,
|
||||||
|
userComment: params.userComment,
|
||||||
|
cooldownMs,
|
||||||
|
onRecordError: (err) =>
|
||||||
|
log.debug?.("reflection session record failed", { error: formatUnknownError(err) }),
|
||||||
|
onDispatchError: (err) =>
|
||||||
|
log.debug?.("reflection reply error", { error: formatUnknownError(err) }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("reflection dispatch failed", { error: formatUnknownError(err) });
|
log.error("reflection dispatch failed", { error: formatUnknownError(err) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (reflection.status === "cooldown") {
|
||||||
const reflectionResponse = capture.readResponse().trim();
|
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
|
||||||
if (!reflectionResponse) {
|
return;
|
||||||
|
}
|
||||||
|
if (reflection.status === "empty") {
|
||||||
log.debug?.("reflection produced no output");
|
log.debug?.("reflection produced no output");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedReflection = parseReflectionResponse(reflectionResponse);
|
|
||||||
if (!parsedReflection) {
|
|
||||||
log.debug?.("reflection produced no structured output");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recordReflectionTime(sessionKey, cooldownMs);
|
|
||||||
log.info("reflection complete", {
|
log.info("reflection complete", {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
responseLength: reflectionResponse.length,
|
responseLength: reflection.responseLength,
|
||||||
followUp: parsedReflection.followUp,
|
followUp: reflection.followUp,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storeSessionLearning({
|
await storeSessionLearning({
|
||||||
storePath,
|
storePath: reflection.storePath,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey,
|
||||||
learning: parsedReflection.learning,
|
learning: reflection.learning,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) });
|
log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) });
|
||||||
@@ -233,12 +118,10 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
|
|||||||
params.conversationRef.conversation?.conversationType,
|
params.conversationRef.conversation?.conversationType,
|
||||||
);
|
);
|
||||||
const shouldNotify =
|
const shouldNotify =
|
||||||
conversationType === "personal" &&
|
conversationType === "personal" && reflection.followUp && Boolean(reflection.userMessage);
|
||||||
parsedReflection.followUp &&
|
|
||||||
Boolean(parsedReflection.userMessage);
|
|
||||||
|
|
||||||
if (!shouldNotify) {
|
if (!shouldNotify) {
|
||||||
if (parsedReflection.followUp && conversationType !== "personal") {
|
if (reflection.followUp && conversationType !== "personal") {
|
||||||
log.debug?.("skipping reflection follow-up outside direct message", {
|
log.debug?.("skipping reflection follow-up outside direct message", {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
conversationType,
|
conversationType,
|
||||||
@@ -248,12 +131,12 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams)
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendReflectionFollowUp({
|
await sendMSTeamsActivityWithReference(
|
||||||
cfg,
|
params.app,
|
||||||
app: params.app,
|
buildConversationReference(params.conversationRef),
|
||||||
conversationRef: params.conversationRef,
|
{ type: "message", text: reflection.userMessage! },
|
||||||
userMessage: parsedReflection.userMessage!,
|
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(cfg.channels?.msteams) },
|
||||||
});
|
);
|
||||||
log.info("sent reflection follow-up", { sessionKey });
|
log.info("sent reflection follow-up", { sessionKey });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) });
|
log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) });
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
// Msteams tests cover monitor handler.feedback authz plugin behavior.
|
// Msteams tests cover monitor handler.feedback authz plugin behavior.
|
||||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||||
import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js";
|
import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js";
|
||||||
@@ -13,6 +10,14 @@ import type { MSTeamsTurnContext } from "./sdk-types.js";
|
|||||||
const feedbackReflectionMockState = vi.hoisted(() => ({
|
const feedbackReflectionMockState = vi.hoisted(() => ({
|
||||||
runFeedbackReflection: vi.fn(),
|
runFeedbackReflection: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const channelInboundMockState = vi.hoisted(() => ({
|
||||||
|
recordChannelFeedbackEvent: vi.fn(async () => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>()),
|
||||||
|
recordChannelFeedbackEvent: channelInboundMockState.recordChannelFeedbackEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./monitor-handler/message-handler.js", () => ({
|
vi.mock("./monitor-handler/message-handler.js", () => ({
|
||||||
createMSTeamsMessageHandler: () => async () => {},
|
createMSTeamsMessageHandler: () => async () => {},
|
||||||
@@ -57,7 +62,7 @@ function createRuntimeStub(readAllowFromStore: ReturnType<typeof vi.fn>): Plugin
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath: (storePath?: string) => storePath ?? tmpdir(),
|
resolveStorePath: (storePath?: string) => storePath ?? "/tmp",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
@@ -126,41 +131,21 @@ function createFeedbackInvokeContext(params: {
|
|||||||
} as unknown as MSTeamsTurnContext;
|
} as unknown as MSTeamsTurnContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFileMissing(filePath: string) {
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
} catch (caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
|
||||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withFeedbackHandler(params: {
|
async function withFeedbackHandler(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
context: Parameters<typeof createFeedbackInvokeContext>[0];
|
context: Parameters<typeof createFeedbackInvokeContext>[0];
|
||||||
assertResult: (args: { tmpDir: string }) => Promise<void>;
|
assertResult: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
|
const deps = createDeps({ cfg: params.cfg });
|
||||||
try {
|
await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
|
||||||
const deps = createDeps({
|
await params.assertResult();
|
||||||
cfg: {
|
|
||||||
...params.cfg,
|
|
||||||
session: { store: tmpDir },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps);
|
|
||||||
await params.assertResult({ tmpDir });
|
|
||||||
} finally {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("msteams feedback invoke authz", () => {
|
describe("msteams feedback invoke authz", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
feedbackReflectionMockState.runFeedbackReflection.mockReset();
|
feedbackReflectionMockState.runFeedbackReflection.mockReset();
|
||||||
feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined);
|
feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined);
|
||||||
|
channelInboundMockState.recordChannelFeedbackEvent.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records feedback for an allowlisted DM sender", async () => {
|
it("records feedback for an allowlisted DM sender", async () => {
|
||||||
@@ -181,34 +166,22 @@ describe("msteams feedback invoke authz", () => {
|
|||||||
senderName: "Owner",
|
senderName: "Owner",
|
||||||
comment: "allowed feedback",
|
comment: "allowed feedback",
|
||||||
},
|
},
|
||||||
assertResult: async ({ tmpDir }) => {
|
assertResult: async () => {
|
||||||
const transcript = await readFile(
|
expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith({
|
||||||
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
|
cfg: expect.any(Object),
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
const event = JSON.parse(transcript.trim()) as Record<string, unknown>;
|
|
||||||
expect(Object.keys(event).toSorted()).toEqual([
|
|
||||||
"agentId",
|
|
||||||
"comment",
|
|
||||||
"conversationId",
|
|
||||||
"event",
|
|
||||||
"messageId",
|
|
||||||
"sessionKey",
|
|
||||||
"ts",
|
|
||||||
"type",
|
|
||||||
"value",
|
|
||||||
]);
|
|
||||||
expect(typeof event.ts).toBe("number");
|
|
||||||
expect({ ...event, ts: 0 }).toEqual({
|
|
||||||
type: "custom",
|
|
||||||
event: "feedback",
|
|
||||||
ts: 0,
|
|
||||||
messageId: "bot-msg-1",
|
|
||||||
value: "positive",
|
|
||||||
comment: "allowed feedback",
|
|
||||||
sessionKey: "msteams:direct:owner-aad",
|
|
||||||
agentId: "default",
|
agentId: "default",
|
||||||
conversationId: "a:personal-chat",
|
sessionKey: "msteams:direct:owner-aad",
|
||||||
|
event: {
|
||||||
|
type: "custom",
|
||||||
|
event: "feedback",
|
||||||
|
ts: expect.any(Number),
|
||||||
|
messageId: "bot-msg-1",
|
||||||
|
value: "positive",
|
||||||
|
comment: "allowed feedback",
|
||||||
|
sessionKey: "msteams:direct:owner-aad",
|
||||||
|
agentId: "default",
|
||||||
|
conversationId: "a:personal-chat",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -239,35 +212,14 @@ describe("msteams feedback invoke authz", () => {
|
|||||||
senderName: "Owner",
|
senderName: "Owner",
|
||||||
comment: "allowed dm feedback",
|
comment: "allowed dm feedback",
|
||||||
},
|
},
|
||||||
assertResult: async ({ tmpDir }) => {
|
assertResult: async () => {
|
||||||
const transcript = await readFile(
|
expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith(
|
||||||
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
|
expect.objectContaining({
|
||||||
"utf-8",
|
agentId: "default",
|
||||||
|
sessionKey: "msteams:direct:owner-aad",
|
||||||
|
event: expect.objectContaining({ comment: "allowed dm feedback" }),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const event = JSON.parse(transcript.trim()) as Record<string, unknown>;
|
|
||||||
expect(Object.keys(event).toSorted()).toEqual([
|
|
||||||
"agentId",
|
|
||||||
"comment",
|
|
||||||
"conversationId",
|
|
||||||
"event",
|
|
||||||
"messageId",
|
|
||||||
"sessionKey",
|
|
||||||
"ts",
|
|
||||||
"type",
|
|
||||||
"value",
|
|
||||||
]);
|
|
||||||
expect(typeof event.ts).toBe("number");
|
|
||||||
expect({ ...event, ts: 0 }).toEqual({
|
|
||||||
type: "custom",
|
|
||||||
event: "feedback",
|
|
||||||
ts: 0,
|
|
||||||
messageId: "bot-msg-1",
|
|
||||||
value: "positive",
|
|
||||||
comment: "allowed dm feedback",
|
|
||||||
sessionKey: "msteams:direct:owner-aad",
|
|
||||||
agentId: "default",
|
|
||||||
conversationId: "a:personal-chat",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -290,47 +242,41 @@ describe("msteams feedback invoke authz", () => {
|
|||||||
senderName: "Attacker",
|
senderName: "Attacker",
|
||||||
comment: "blocked feedback",
|
comment: "blocked feedback",
|
||||||
},
|
},
|
||||||
assertResult: async ({ tmpDir }) => {
|
assertResult: async () => {
|
||||||
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl"));
|
expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
|
||||||
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {
|
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {
|
||||||
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
|
const deps = createDeps({
|
||||||
try {
|
cfg: {
|
||||||
const deps = createDeps({
|
channels: {
|
||||||
cfg: {
|
msteams: {
|
||||||
session: { store: tmpDir },
|
groupPolicy: "allowlist",
|
||||||
channels: {
|
groupAllowFrom: ["owner-aad"],
|
||||||
msteams: {
|
feedbackReflection: true,
|
||||||
groupPolicy: "allowlist",
|
|
||||||
groupAllowFrom: ["owner-aad"],
|
|
||||||
feedbackReflection: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as OpenClawConfig,
|
},
|
||||||
});
|
} as OpenClawConfig,
|
||||||
|
});
|
||||||
|
|
||||||
await runMSTeamsFeedbackInvokeHandler(
|
await runMSTeamsFeedbackInvokeHandler(
|
||||||
createFeedbackInvokeContext({
|
createFeedbackInvokeContext({
|
||||||
reaction: "dislike",
|
reaction: "dislike",
|
||||||
conversationId: "19:group@thread.tacv2;messageid=bot-msg-1",
|
conversationId: "19:group@thread.tacv2;messageid=bot-msg-1",
|
||||||
conversationType: "groupChat",
|
conversationType: "groupChat",
|
||||||
senderId: "attacker-aad",
|
senderId: "attacker-aad",
|
||||||
senderName: "Attacker",
|
senderName: "Attacker",
|
||||||
teamId: "team-1",
|
teamId: "team-1",
|
||||||
channelName: "General",
|
channelName: "General",
|
||||||
comment: "blocked reflection",
|
comment: "blocked reflection",
|
||||||
}),
|
}),
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expectFileMissing(path.join(tmpDir, "msteams_group_19_group_thread_tacv2.jsonl"));
|
expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled();
|
||||||
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
|
||||||
} finally {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type MSTeamsTestRuntimeOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void {
|
export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void {
|
||||||
|
const recordInboundSession = options.recordInboundSession ?? vi.fn(async () => undefined);
|
||||||
|
const resolveStorePath = options.resolveStorePath ?? (() => "/tmp/msteams-sessions.json");
|
||||||
const runPrepared = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
|
const runPrepared = vi.fn(async (turn: PreparedInboundReply<unknown>) => {
|
||||||
await turn.recordInboundSession({
|
await turn.recordInboundSession({
|
||||||
storePath: turn.storePath,
|
storePath: turn.storePath,
|
||||||
@@ -63,7 +65,16 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
|
|||||||
: (preflightResult ?? {});
|
: (preflightResult ?? {});
|
||||||
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
||||||
if ("runDispatch" in turn) {
|
if ("runDispatch" in turn) {
|
||||||
return await runPrepared(turn);
|
const preparedTurn =
|
||||||
|
"route" in turn
|
||||||
|
? ({
|
||||||
|
...turn,
|
||||||
|
routeSessionKey: turn.route.sessionKey,
|
||||||
|
storePath: resolveStorePath(),
|
||||||
|
recordInboundSession,
|
||||||
|
} as PreparedInboundReply<unknown>)
|
||||||
|
: turn;
|
||||||
|
return await runPrepared(preparedTurn);
|
||||||
}
|
}
|
||||||
throw new Error("msteams test runtime only supports prepared turn dispatch");
|
throw new Error("msteams test runtime only supports prepared turn dispatch");
|
||||||
});
|
});
|
||||||
@@ -124,8 +135,8 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
|
|||||||
resolveHumanDelayConfig: () => undefined,
|
resolveHumanDelayConfig: () => undefined,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
recordInboundSession: options.recordInboundSession ?? vi.fn(async () => undefined),
|
recordInboundSession,
|
||||||
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
|
resolveStorePath,
|
||||||
},
|
},
|
||||||
inbound: {
|
inbound: {
|
||||||
run: run as unknown as PluginRuntime["channel"]["inbound"]["run"],
|
run: run as unknown as PluginRuntime["channel"]["inbound"]["run"],
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
|
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import {
|
||||||
buildChannelInboundEventContext,
|
buildChannelInboundEventContext,
|
||||||
|
createChannelInboundEnvelopeBuilder,
|
||||||
logInboundDrop,
|
logInboundDrop,
|
||||||
resolveInboundMentionDecision,
|
resolveInboundMentionDecision,
|
||||||
resolveInboundSessionEnvelopeContext,
|
|
||||||
resolveInboundSupplementalSenderAllowed,
|
resolveInboundSupplementalSenderAllowed,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import {
|
import {
|
||||||
@@ -788,17 +788,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
quoteSenderName ??= quoteInfo?.sender;
|
quoteSenderName ??= quoteInfo?.sender;
|
||||||
|
|
||||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
const buildEnvelope = createChannelInboundEnvelopeBuilder({ cfg, route });
|
||||||
cfg,
|
const body = buildEnvelope({
|
||||||
agentId: route.agentId,
|
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
});
|
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp,
|
timestamp,
|
||||||
previousTimestamp,
|
|
||||||
envelope: envelopeOptions,
|
|
||||||
body: agentBody,
|
body: agentBody,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
@@ -811,12 +805,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
core.channel.reply.formatAgentEnvelope({
|
buildEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: conversationType,
|
from: conversationType,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
|
previousTimestamp: null,
|
||||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||||
envelope: envelopeOptions,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -858,7 +852,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined;
|
isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined;
|
||||||
const ctxPayload = buildChannelInboundEventContext({
|
const ctxPayload = buildChannelInboundEventContext({
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
finalize: core.channel.reply.finalizeInboundContext,
|
|
||||||
contextVisibility: contextVisibilityMode,
|
contextVisibility: contextVisibilityMode,
|
||||||
supplemental: {
|
supplemental: {
|
||||||
quote: quoteInfo
|
quote: quoteInfo
|
||||||
@@ -975,12 +968,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
raw: activity,
|
raw: activity,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg,
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ vi.mock("../runtime-api.js", () => ({
|
|||||||
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
|
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./runtime.js", () => ({
|
vi.mock("./runtime.js", () => ({
|
||||||
getMSTeamsRuntime: getMSTeamsRuntimeMock,
|
getMSTeamsRuntime: getMSTeamsRuntimeMock,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
// Msteams plugin module implements reply dispatcher behavior.
|
// Msteams plugin module implements reply dispatcher behavior.
|
||||||
import {
|
import {
|
||||||
buildChannelProgressDraftLine,
|
buildChannelProgressDraftLine,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
resolveChannelStreamingPreviewToolProgress,
|
resolveChannelStreamingPreviewToolProgress,
|
||||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||||
} from "openclaw/plugin-sdk/channel-outbound";
|
} from "openclaw/plugin-sdk/channel-outbound";
|
||||||
|
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import {
|
import {
|
||||||
createChannelMessageReplyPipeline,
|
createChannelMessageReplyPipeline,
|
||||||
@@ -295,9 +297,9 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
dispatcher,
|
dispatcher,
|
||||||
replyOptions,
|
replyOptions,
|
||||||
markDispatchIdle: baseMarkDispatchIdle,
|
markDispatchIdle: baseMarkDispatchIdle,
|
||||||
} = core.channel.reply.createReplyDispatcherWithTyping({
|
} = createReplyDispatcherWithTyping({
|
||||||
...replyPipeline,
|
...replyPipeline,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||||
onReplyStart: async () => {
|
onReplyStart: async () => {
|
||||||
await streamController.onReplyStart();
|
await streamController.onReplyStart();
|
||||||
// Always start the typing keepalive loop when typing is enabled and
|
// Always start the typing keepalive loop when typing is enabled and
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import {
|
||||||
|
buildChannelInboundEventContext,
|
||||||
|
resolveChannelInboundRouteEnvelope,
|
||||||
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
// Nextcloud Talk plugin module implements inbound behavior.
|
// Nextcloud Talk plugin module implements inbound behavior.
|
||||||
import {
|
import {
|
||||||
channelIngressRoutes,
|
channelIngressRoutes,
|
||||||
resolveStableChannelMessageIngress,
|
resolveStableChannelMessageIngress,
|
||||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
|
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
|
||||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
||||||
import {
|
import {
|
||||||
normalizeOptionalString,
|
normalizeOptionalString,
|
||||||
normalizeStringEntries,
|
normalizeStringEntries,
|
||||||
@@ -304,7 +307,7 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
|
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||||
cfg: config as OpenClawConfig,
|
cfg: config as OpenClawConfig,
|
||||||
channel: CHANNEL_ID,
|
channel: CHANNEL_ID,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -312,14 +315,10 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
kind: isGroup ? "group" : "direct",
|
kind: isGroup ? "group" : "direct",
|
||||||
id: isGroup ? roomToken : senderId,
|
id: isGroup ? roomToken : senderId,
|
||||||
},
|
},
|
||||||
runtime: core.channel,
|
|
||||||
sessionStore: (config.session as Record<string, unknown> | undefined)?.store as
|
|
||||||
| string
|
|
||||||
| undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
|
const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
|
||||||
const { storePath, body } = buildEnvelope({
|
const body = buildEnvelope({
|
||||||
channel: "Nextcloud Talk",
|
channel: "Nextcloud Talk",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
@@ -329,42 +328,37 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
|
||||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
||||||
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = buildChannelInboundEventContext({
|
||||||
Body: body,
|
channel: CHANNEL_ID,
|
||||||
BodyForAgent: rawBody,
|
accountId: route.accountId,
|
||||||
RawBody: rawBody,
|
messageId: message.messageId,
|
||||||
CommandBody: rawBody,
|
timestamp: message.timestamp,
|
||||||
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
|
from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
|
||||||
To: `nextcloud-talk:${roomToken}`,
|
sender: { id: senderId, name: senderName || undefined },
|
||||||
SessionKey: route.sessionKey,
|
conversation: { kind: isGroup ? "group" : "direct", id: roomToken, label: fromLabel },
|
||||||
AccountId: route.accountId,
|
route: {
|
||||||
ChatType: isGroup ? "group" : "direct",
|
agentId: route.agentId,
|
||||||
ConversationLabel: fromLabel,
|
accountId: route.accountId,
|
||||||
SenderName: senderName || undefined,
|
routeSessionKey: route.sessionKey,
|
||||||
SenderId: senderId,
|
},
|
||||||
GroupSubject: isGroup ? roomName || roomToken : undefined,
|
reply: { to: `nextcloud-talk:${roomToken}`, originatingTo: `nextcloud-talk:${roomToken}` },
|
||||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody },
|
||||||
Provider: CHANNEL_ID,
|
access: {
|
||||||
Surface: CHANNEL_ID,
|
commands: { authorized: commandAuthorized },
|
||||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
mentions: { canDetectMention: isGroup, wasMentioned: isGroup && wasMentioned },
|
||||||
MessageSid: message.messageId,
|
},
|
||||||
Timestamp: message.timestamp,
|
extra: {
|
||||||
OriginatingChannel: CHANNEL_ID,
|
GroupSubject: isGroup ? roomName || roomToken : undefined,
|
||||||
OriginatingTo: `nextcloud-talk:${roomToken}`,
|
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||||
CommandAuthorized: commandAuthorized,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.inbound.dispatchReply({
|
await core.channel.inbound.dispatch({
|
||||||
cfg: config as OpenClawConfig,
|
cfg: config as OpenClawConfig,
|
||||||
channel: CHANNEL_ID,
|
channel: CHANNEL_ID,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
preparePayload: (payload) =>
|
preparePayload: (payload) =>
|
||||||
payload.text === undefined
|
payload.text === undefined
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { dispatchInboundDirectDm as DispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
// Nostr tests cover channel.inbound plugin behavior.
|
// Nostr tests cover channel.inbound plugin behavior.
|
||||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
@@ -7,6 +8,7 @@ import { setNostrRuntime } from "./runtime.js";
|
|||||||
import { buildResolvedNostrAccount } from "./test-fixtures.js";
|
import { buildResolvedNostrAccount } from "./test-fixtures.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
dispatchInboundDirectDm: vi.fn(),
|
||||||
normalizePubkey: vi.fn((value: string) =>
|
normalizePubkey: vi.fn((value: string) =>
|
||||||
value
|
value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({
|
|||||||
startNostrBus: vi.fn(),
|
startNostrBus: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>()),
|
||||||
|
dispatchInboundDirectDm: mocks.dispatchInboundDirectDm,
|
||||||
|
}));
|
||||||
vi.mock("./nostr-bus.js", () => ({
|
vi.mock("./nostr-bus.js", () => ({
|
||||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||||
startNostrBus: mocks.startNostrBus,
|
startNostrBus: mocks.startNostrBus,
|
||||||
@@ -127,6 +133,7 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
|
|||||||
|
|
||||||
describe("nostr inbound gateway path", () => {
|
describe("nostr inbound gateway path", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
mocks.dispatchInboundDirectDm.mockReset();
|
||||||
mocks.normalizePubkey.mockClear();
|
mocks.normalizePubkey.mockClear();
|
||||||
mocks.startNostrBus.mockReset();
|
mocks.startNostrBus.mockReset();
|
||||||
});
|
});
|
||||||
@@ -159,15 +166,19 @@ describe("nostr inbound gateway path", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("routes allowed DMs through the standard reply pipeline", async () => {
|
it("routes allowed DMs through the standard reply pipeline", async () => {
|
||||||
const { harness, cleanup } = await startGatewayHarness({
|
mocks.dispatchInboundDirectDm.mockImplementationOnce(
|
||||||
|
async (params: Parameters<typeof DispatchInboundDirectDm>[0]) => {
|
||||||
|
await params.deliver({ text: "|a|b|" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { cleanup } = await startGatewayHarness({
|
||||||
account: buildResolvedNostrAccount({
|
account: buildResolvedNostrAccount({
|
||||||
publicKey: "bot-pubkey",
|
publicKey: "bot-pubkey",
|
||||||
config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
|
config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
|
||||||
}),
|
}),
|
||||||
cfg: {
|
cfg: {
|
||||||
session: { store: { type: "jsonl" } },
|
|
||||||
commands: { useAccessGroups: true },
|
commands: { useAccessGroups: true },
|
||||||
} as never,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = mockCallArg(mocks.startNostrBus) as {
|
const options = mockCallArg(mocks.startNostrBus) as {
|
||||||
@@ -185,17 +196,18 @@ describe("nostr inbound gateway path", () => {
|
|||||||
createdAt: 1_710_000_000,
|
createdAt: 1_710_000_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(harness.recordInboundSession).toHaveBeenCalledTimes(1);
|
expect(mocks.dispatchInboundDirectDm).toHaveBeenCalledWith(
|
||||||
expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
expect.objectContaining({
|
||||||
const ctx = (
|
channel: "nostr",
|
||||||
mockCallArg(harness.dispatchReplyWithBufferedBlockDispatcher) as {
|
accountId: "default",
|
||||||
ctx?: Record<string, unknown>;
|
peer: { kind: "direct", id: "sender-pubkey" },
|
||||||
}
|
senderId: "sender-pubkey",
|
||||||
).ctx;
|
rawBody: "hello from nostr",
|
||||||
expect(ctx?.BodyForAgent).toBe("hello from nostr");
|
messageId: "event-123",
|
||||||
expect(ctx?.SenderId).toBe("sender-pubkey");
|
timestamp: 1_710_000_000_000,
|
||||||
expect(ctx?.MessageSid).toBe("event-123");
|
commandAuthorized: true,
|
||||||
expect(ctx?.CommandAuthorized).toBe(true);
|
}),
|
||||||
|
);
|
||||||
expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
|
expect(sendReply).toHaveBeenCalledWith("converted:|a|b|");
|
||||||
|
|
||||||
await cleanup.stop();
|
await cleanup.stop();
|
||||||
|
|||||||
@@ -163,11 +163,9 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dispatchInboundDirectDmWithRuntime } =
|
const { dispatchInboundDirectDm } = await import("./inbound-direct-dm-runtime.js");
|
||||||
await import("./inbound-direct-dm-runtime.js");
|
await dispatchInboundDirectDm({
|
||||||
await dispatchInboundDirectDmWithRuntime({
|
|
||||||
cfg: ctx.cfg,
|
cfg: ctx.cfg,
|
||||||
runtime,
|
|
||||||
channel: "nostr",
|
channel: "nostr",
|
||||||
channelLabel: "Nostr",
|
channelLabel: "Nostr",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Nostr plugin module implements inbound direct dm runtime behavior.
|
// Nostr plugin module implements inbound direct dm runtime behavior.
|
||||||
export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/channel-inbound";
|
export { dispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { qaChannelPlugin, setQaChannelRuntime } from "../api.js";
|
|||||||
import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
|
import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
|
||||||
import type { ChannelMessageActionName } from "./runtime-api.js";
|
import type { ChannelMessageActionName } from "./runtime-api.js";
|
||||||
|
|
||||||
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0];
|
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatch"]>[0];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetPluginRuntimeStateForTest();
|
resetPluginRuntimeStateForTest();
|
||||||
@@ -66,7 +66,6 @@ function createMockQaRuntime(params?: {
|
|||||||
onDispatch?: (ctx: Record<string, unknown>) => void;
|
onDispatch?: (ctx: Record<string, unknown>) => void;
|
||||||
toolStarts?: Array<{ name?: string; phase?: string; args?: Record<string, unknown> }>;
|
toolStarts?: Array<{ name?: string; phase?: string; args?: Record<string, unknown> }>;
|
||||||
}): PluginRuntime {
|
}): PluginRuntime {
|
||||||
const sessionUpdatedAt = new Map<string, number>();
|
|
||||||
return createPluginRuntimeMock({
|
return createPluginRuntimeMock({
|
||||||
channel: {
|
channel: {
|
||||||
mentions: {
|
mentions: {
|
||||||
@@ -77,104 +76,24 @@ function createMockQaRuntime(params?: {
|
|||||||
return patterns.some((pattern) => pattern.test(text));
|
return patterns.some((pattern) => pattern.test(text));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
routing: {
|
inbound: {
|
||||||
resolveAgentRoute({
|
async dispatch(turn: QaDispatchTurn) {
|
||||||
accountId,
|
|
||||||
peer,
|
|
||||||
}: {
|
|
||||||
accountId?: string | null;
|
|
||||||
peer?: { kind?: string; id?: string } | null;
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
agentId: "qa-agent",
|
|
||||||
channel: "qa-channel",
|
|
||||||
accountId: accountId ?? "default",
|
|
||||||
sessionKey: `qa-agent:${peer?.kind ?? "direct"}:${peer?.id ?? "default"}`,
|
|
||||||
mainSessionKey: "qa-agent:main",
|
|
||||||
lastRoutePolicy: "session",
|
|
||||||
matchedBy: "default",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
resolveStorePath(_store: string | undefined, { agentId }: { agentId: string }) {
|
|
||||||
return agentId;
|
|
||||||
},
|
|
||||||
readSessionUpdatedAt({ sessionKey }: { sessionKey: string }) {
|
|
||||||
return sessionUpdatedAt.get(sessionKey);
|
|
||||||
},
|
|
||||||
recordInboundSession({ sessionKey }: { sessionKey: string }) {
|
|
||||||
sessionUpdatedAt.set(sessionKey, Date.now());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reply: {
|
|
||||||
resolveEnvelopeFormatOptions() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
formatAgentEnvelope({ body }: { body: string }) {
|
|
||||||
return body;
|
|
||||||
},
|
|
||||||
finalizeInboundContext(ctx: Record<string, unknown>) {
|
|
||||||
return ctx as typeof ctx & { CommandAuthorized: boolean };
|
|
||||||
},
|
|
||||||
async dispatchReplyWithBufferedBlockDispatcher({
|
|
||||||
ctx,
|
|
||||||
dispatcherOptions,
|
|
||||||
replyOptions,
|
|
||||||
}: {
|
|
||||||
ctx: { BodyForAgent?: string; Body?: string };
|
|
||||||
dispatcherOptions: {
|
|
||||||
deliver: (payload: { text: string }, info: { kind: string }) => Promise<void>;
|
|
||||||
};
|
|
||||||
replyOptions?: {
|
|
||||||
onToolStart?: (payload: {
|
|
||||||
name?: string;
|
|
||||||
phase?: string;
|
|
||||||
args?: Record<string, unknown>;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
for (const toolStart of params?.toolStarts ?? []) {
|
for (const toolStart of params?.toolStarts ?? []) {
|
||||||
await replyOptions?.onToolStart?.(toolStart);
|
await turn.replyOptions?.onToolStart?.(toolStart);
|
||||||
}
|
}
|
||||||
params?.onDispatch?.(ctx as Record<string, unknown>);
|
params?.onDispatch?.(turn.ctxPayload as Record<string, unknown>);
|
||||||
await dispatcherOptions.deliver(
|
await turn.delivery.deliver(
|
||||||
{
|
{
|
||||||
text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`,
|
text: `qa-echo: ${turn.ctxPayload.BodyForAgent ?? turn.ctxPayload.Body ?? ""}`,
|
||||||
},
|
},
|
||||||
{ kind: "final" },
|
{ kind: "final" },
|
||||||
);
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
inbound: {
|
|
||||||
async dispatchReply(turn: QaDispatchTurn) {
|
|
||||||
await turn.recordInboundSession({
|
|
||||||
storePath: turn.storePath,
|
|
||||||
sessionKey:
|
|
||||||
typeof turn.ctxPayload.SessionKey === "string"
|
|
||||||
? turn.ctxPayload.SessionKey
|
|
||||||
: turn.routeSessionKey,
|
|
||||||
ctx: turn.ctxPayload,
|
|
||||||
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
admission: turn.admission ?? { kind: "dispatch" as const },
|
admission: turn.admission ?? { kind: "dispatch" as const },
|
||||||
dispatched: true,
|
dispatched: true,
|
||||||
ctxPayload: turn.ctxPayload,
|
ctxPayload: turn.ctxPayload,
|
||||||
routeSessionKey: turn.routeSessionKey,
|
routeSessionKey: turn.route.sessionKey,
|
||||||
dispatchResult: await turn.dispatchReplyWithBufferedBlockDispatcher({
|
dispatchResult: undefined,
|
||||||
ctx: turn.ctxPayload,
|
|
||||||
cfg: turn.cfg,
|
|
||||||
dispatcherOptions: {
|
|
||||||
...turn.dispatcherOptions,
|
|
||||||
deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
|
|
||||||
await turn.delivery.deliver(...args);
|
|
||||||
},
|
|
||||||
onError: turn.delivery.onError,
|
|
||||||
},
|
|
||||||
replyOptions: turn.replyOptions,
|
|
||||||
replyResolver: turn.replyResolver,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -505,7 +424,7 @@ describe("qa-channel plugin", () => {
|
|||||||
expect(ctx.ChatType).toBe("group");
|
expect(ctx.ChatType).toBe("group");
|
||||||
expect(ctx.From).toBe("group:qa-room");
|
expect(ctx.From).toBe("group:qa-room");
|
||||||
expect(ctx.To).toBe("group:qa-room");
|
expect(ctx.To).toBe("group:qa-room");
|
||||||
expect(ctx.SessionKey).toBe("qa-agent:group:group:qa-room");
|
expect(ctx.SessionKey).toBe("agent:main:qa-channel:group:group:qa-room");
|
||||||
expect(ctx.SenderId).toBe("alice");
|
expect(ctx.SenderId).toBe("alice");
|
||||||
expect(ctx.GroupSubject).toBe("QA Room");
|
expect(ctx.GroupSubject).toBe("QA Room");
|
||||||
expect("conversation" in outbound).toBe(true);
|
expect("conversation" in outbound).toBe(true);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function createQaInboundParams(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function firstRunAssembledParams(runtime: ReturnType<typeof createPluginRuntimeMock>) {
|
function firstRunAssembledParams(runtime: ReturnType<typeof createPluginRuntimeMock>) {
|
||||||
const call = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0];
|
const call = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0];
|
||||||
if (!call) {
|
if (!call) {
|
||||||
throw new Error("expected assembled turn call");
|
throw new Error("expected assembled turn call");
|
||||||
}
|
}
|
||||||
@@ -262,7 +262,7 @@ describe("handleQaInbound", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||||
const assembled = firstRunAssembledParams(runtime);
|
const assembled = firstRunAssembledParams(runtime);
|
||||||
expect(assembled.replyPipeline).toEqual({});
|
expect(assembled.replyPipeline).toEqual({});
|
||||||
expect(assembled.ctxPayload.WasMentioned).toBe(true);
|
expect(assembled.ctxPayload.WasMentioned).toBe(true);
|
||||||
@@ -280,7 +280,7 @@ describe("handleQaInbound", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows direct messages from configured senders", async () => {
|
it("allows direct messages from configured senders", async () => {
|
||||||
@@ -295,7 +295,7 @@ describe("handleQaInbound", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||||
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
||||||
expect(ctxPayload?.CommandAuthorized).toBe(true);
|
expect(ctxPayload?.CommandAuthorized).toBe(true);
|
||||||
expect(ctxPayload?.SenderId).toBe("alice");
|
expect(ctxPayload?.SenderId).toBe("alice");
|
||||||
@@ -318,14 +318,14 @@ describe("handleQaInbound", () => {
|
|||||||
expect(assembled.ctxPayload).toMatchObject({
|
expect(assembled.ctxPayload).toMatchObject({
|
||||||
CommandAuthorized: true,
|
CommandAuthorized: true,
|
||||||
CommandSource: "native",
|
CommandSource: "native",
|
||||||
CommandTargetSessionKey: assembled.routeSessionKey,
|
CommandTargetSessionKey: assembled.route.sessionKey,
|
||||||
CommandTurn: {
|
CommandTurn: {
|
||||||
body: "/stop",
|
body: "/stop",
|
||||||
source: "native",
|
source: "native",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice");
|
expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice");
|
||||||
expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.routeSessionKey);
|
expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.route.sessionKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips malformed inline attachment base64 without dropping the message", async () => {
|
it("skips malformed inline attachment base64 without dropping the message", async () => {
|
||||||
@@ -347,7 +347,7 @@ describe("handleQaInbound", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||||
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
||||||
expect(ctxPayload.MediaPath).toBeUndefined();
|
expect(ctxPayload.MediaPath).toBeUndefined();
|
||||||
expect(ctxPayload.MediaPaths).toBeUndefined();
|
expect(ctxPayload.MediaPaths).toBeUndefined();
|
||||||
@@ -380,7 +380,7 @@ describe("handleQaInbound", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||||
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
const ctxPayload = firstRunAssembledParams(runtime).ctxPayload;
|
||||||
expect(ctxPayload.MediaPath).toBeUndefined();
|
expect(ctxPayload.MediaPath).toBeUndefined();
|
||||||
expect(ctxPayload.MediaPaths).toBeUndefined();
|
expect(ctxPayload.MediaPaths).toBeUndefined();
|
||||||
@@ -410,7 +410,7 @@ describe("handleQaInbound", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1);
|
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips configured group messages that miss mention activation", async () => {
|
it("skips configured group messages that miss mention activation", async () => {
|
||||||
@@ -438,6 +438,6 @@ describe("handleQaInbound", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import {
|
||||||
|
buildChannelInboundEventContext,
|
||||||
|
resolveChannelInboundRouteEnvelope,
|
||||||
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
// Qa Channel plugin module implements inbound behavior.
|
// Qa Channel plugin module implements inbound behavior.
|
||||||
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||||
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
|
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
||||||
import {
|
import {
|
||||||
buildAgentMediaPayload,
|
buildAgentMediaPayload,
|
||||||
saveMediaBuffer,
|
saveMediaBuffer,
|
||||||
@@ -219,7 +222,7 @@ export async function handleQaInbound(params: {
|
|||||||
target,
|
target,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
});
|
});
|
||||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||||
cfg: params.config as OpenClawConfig,
|
cfg: params.config as OpenClawConfig,
|
||||||
channel: params.channelId,
|
channel: params.channelId,
|
||||||
accountId: params.account.accountId,
|
accountId: params.account.accountId,
|
||||||
@@ -232,8 +235,6 @@ export async function handleQaInbound(params: {
|
|||||||
: "channel",
|
: "channel",
|
||||||
id: target,
|
id: target,
|
||||||
},
|
},
|
||||||
runtime: runtime.channel,
|
|
||||||
sessionStore: params.config.session?.store,
|
|
||||||
});
|
});
|
||||||
const isGroup = inbound.conversation.kind !== "direct";
|
const isGroup = inbound.conversation.kind !== "direct";
|
||||||
const wasMentioned = isGroup
|
const wasMentioned = isGroup
|
||||||
@@ -286,7 +287,7 @@ export async function handleQaInbound(params: {
|
|||||||
if (access.ingress.admission !== "dispatch") {
|
if (access.ingress.admission !== "dispatch") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { storePath, body } = buildEnvelope({
|
const body = buildEnvelope({
|
||||||
channel: params.channelLabel,
|
channel: params.channelLabel,
|
||||||
from: inbound.senderName || inbound.senderId,
|
from: inbound.senderName || inbound.senderId,
|
||||||
timestamp: inbound.timestamp,
|
timestamp: inbound.timestamp,
|
||||||
@@ -304,65 +305,64 @@ export async function handleQaInbound(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text;
|
const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text;
|
||||||
|
|
||||||
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
const sessionKey = commandTargets?.sessionKey ?? route.sessionKey;
|
||||||
Body: body,
|
const ctxPayload = buildChannelInboundEventContext({
|
||||||
BodyForAgent: inbound.text,
|
channel: params.channelId,
|
||||||
RawBody: inbound.text,
|
accountId: route.accountId ?? params.account.accountId,
|
||||||
CommandBody: commandBody,
|
messageId: inbound.id,
|
||||||
From: target,
|
messageIdFull: inbound.id,
|
||||||
To: target,
|
timestamp: inbound.timestamp,
|
||||||
SessionKey: commandTargets?.sessionKey ?? route.sessionKey,
|
from: target,
|
||||||
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
|
sender: { id: inbound.senderId, name: inbound.senderName },
|
||||||
AccountId: route.accountId ?? params.account.accountId,
|
conversation: {
|
||||||
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group",
|
kind: inbound.conversation.kind === "direct" ? "direct" : "group",
|
||||||
WasMentioned: wasMentioned,
|
id: inbound.conversation.id,
|
||||||
ConversationLabel:
|
label:
|
||||||
inbound.threadTitle ||
|
inbound.threadTitle ||
|
||||||
inbound.conversation.title ||
|
inbound.conversation.title ||
|
||||||
inbound.senderName ||
|
inbound.senderName ||
|
||||||
inbound.conversation.id,
|
inbound.conversation.id,
|
||||||
GroupSubject: isGroup
|
threadId: inbound.threadId,
|
||||||
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
|
nativeChannelId: inbound.conversation.id,
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
agentId: route.agentId,
|
||||||
|
accountId: route.accountId,
|
||||||
|
routeSessionKey: sessionKey,
|
||||||
|
dispatchSessionKey: sessionKey,
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
to: target,
|
||||||
|
originatingTo: target,
|
||||||
|
replyToId: inbound.replyToId,
|
||||||
|
messageThreadId: inbound.threadId,
|
||||||
|
threadParentId: inbound.threadId ? inbound.conversation.id : undefined,
|
||||||
|
},
|
||||||
|
message: { body, bodyForAgent: inbound.text, rawBody: inbound.text, commandBody },
|
||||||
|
access: {
|
||||||
|
commands: { authorized: true },
|
||||||
|
mentions: { canDetectMention: isGroup, wasMentioned: Boolean(wasMentioned) },
|
||||||
|
},
|
||||||
|
command: nativeCommand
|
||||||
|
? { kind: "native", name: nativeCommand.name, body: commandBody, authorized: true }
|
||||||
: undefined,
|
: undefined,
|
||||||
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
|
extra: {
|
||||||
NativeChannelId: inbound.conversation.id,
|
CommandTargetSessionKey: commandTargets?.commandTargetSessionKey,
|
||||||
MessageThreadId: inbound.threadId,
|
GroupSubject: isGroup
|
||||||
ThreadLabel: inbound.threadTitle,
|
? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id
|
||||||
ThreadParentId: inbound.threadId ? inbound.conversation.id : undefined,
|
: undefined,
|
||||||
SenderName: inbound.senderName,
|
GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined,
|
||||||
SenderId: inbound.senderId,
|
ThreadLabel: inbound.threadTitle,
|
||||||
Provider: params.channelId,
|
...mediaPayload,
|
||||||
Surface: params.channelId,
|
},
|
||||||
MessageSid: inbound.id,
|
|
||||||
MessageSidFull: inbound.id,
|
|
||||||
ReplyToId: inbound.replyToId,
|
|
||||||
Timestamp: inbound.timestamp,
|
|
||||||
OriginatingChannel: params.channelId,
|
|
||||||
OriginatingTo: target,
|
|
||||||
CommandAuthorized: true,
|
|
||||||
CommandSource: nativeCommand ? "native" : undefined,
|
|
||||||
CommandTurn: nativeCommand
|
|
||||||
? {
|
|
||||||
kind: "native",
|
|
||||||
source: "native",
|
|
||||||
authorized: true,
|
|
||||||
body: commandBody,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
...mediaPayload,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await runtime.channel.inbound.dispatchReply({
|
await runtime.channel.inbound.dispatch({
|
||||||
cfg: params.config as OpenClawConfig,
|
cfg: params.config as OpenClawConfig,
|
||||||
channel: params.channelId,
|
channel: params.channelId,
|
||||||
accountId: params.account.accountId,
|
accountId: params.account.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
deliver: async (payload, info) => {
|
deliver: async (payload, info) => {
|
||||||
const text =
|
const text =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Qqbot plugin module implements inbound context behavior.
|
// Qqbot plugin module implements inbound context behavior.
|
||||||
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||||
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
import type { EngineAdapters } from "../adapter/index.js";
|
import type { EngineAdapters } from "../adapter/index.js";
|
||||||
import type { QQBotGroupCommandLevel } from "../config/group.js";
|
import type { QQBotGroupCommandLevel } from "../config/group.js";
|
||||||
import type { GroupActivationMode } from "../group/activation.js";
|
import type { GroupActivationMode } from "../group/activation.js";
|
||||||
@@ -69,7 +70,7 @@ export interface InboundContext {
|
|||||||
|
|
||||||
export interface InboundPipelineDeps {
|
export interface InboundPipelineDeps {
|
||||||
account: GatewayAccount;
|
account: GatewayAccount;
|
||||||
cfg: unknown;
|
cfg: OpenClawConfig;
|
||||||
log?: EngineLogger;
|
log?: EngineLogger;
|
||||||
runtime: GatewayPluginRuntime;
|
runtime: GatewayPluginRuntime;
|
||||||
startTyping: (event: QueuedMessage) => Promise<{
|
startTyping: (event: QueuedMessage) => Promise<{
|
||||||
|
|||||||
@@ -421,10 +421,6 @@ export async function dispatchOutbound(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfgWithSession = cfg as { session?: { store?: unknown } };
|
|
||||||
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
|
|
||||||
agentId: routeAgentId,
|
|
||||||
});
|
|
||||||
const dispatchPromise = runtime.channel.inbound.run({
|
const dispatchPromise = runtime.channel.inbound.run({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
accountId: inbound.route.accountId,
|
accountId: inbound.route.accountId,
|
||||||
@@ -438,12 +434,11 @@ export async function dispatchOutbound(
|
|||||||
raw: inbound,
|
raw: inbound,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg: openClawCfg,
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
accountId: inbound.route.accountId,
|
accountId: inbound.route.accountId,
|
||||||
routeSessionKey: inbound.route.sessionKey,
|
route: { agentId: routeAgentId, sessionKey: inbound.route.sessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
onRecordError: (err: unknown) => {
|
onRecordError: (err: unknown) => {
|
||||||
log?.error(
|
log?.error(
|
||||||
@@ -773,7 +768,6 @@ async function buildCtxPayload(
|
|||||||
const commandSource = resolveCommandSource(inbound, runtime, cfg);
|
const commandSource = resolveCommandSource(inbound, runtime, cfg);
|
||||||
const hasImageMedia = inbound.localMediaPaths.length > 0 || inbound.remoteMediaUrls.length > 0;
|
const hasImageMedia = inbound.localMediaPaths.length > 0 || inbound.remoteMediaUrls.length > 0;
|
||||||
return buildChannelInboundEventContext({
|
return buildChannelInboundEventContext({
|
||||||
finalize: runtime.channel.reply.finalizeInboundContext,
|
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
accountId: inbound.route.accountId,
|
accountId: inbound.route.accountId,
|
||||||
messageId: event.messageId,
|
messageId: event.messageId,
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ function buildAllowAccess(): QQBotInboundAccess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDeps(
|
function buildDeps(
|
||||||
cfg: unknown,
|
cfg: StubCfg,
|
||||||
runtime: GatewayPluginRuntime,
|
runtime: GatewayPluginRuntime,
|
||||||
account: GatewayAccount,
|
account: GatewayAccount,
|
||||||
): InboundPipelineDeps {
|
): InboundPipelineDeps {
|
||||||
return {
|
return {
|
||||||
account,
|
account,
|
||||||
cfg,
|
cfg: cfg as InboundPipelineDeps["cfg"],
|
||||||
runtime,
|
runtime,
|
||||||
startTyping: vi.fn(),
|
startTyping: vi.fn(),
|
||||||
adapters: {
|
adapters: {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
* sees directly.
|
* sees directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatInboundEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
type EnvelopeFormatOptions,
|
||||||
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
||||||
import {
|
import {
|
||||||
buildMergedMessageContext,
|
buildMergedMessageContext,
|
||||||
@@ -103,13 +108,13 @@ export function buildAgentBody(input: BuildAgentBodyInput): string {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelopeOpts = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
|
const envelopeOpts = resolveEnvelopeFormatOptions(deps.cfg);
|
||||||
return deps.adapters.history.buildPendingHistoryContext({
|
return deps.adapters.history.buildPendingHistoryContext({
|
||||||
historyMap: deps.groupHistories,
|
historyMap: deps.groupHistories,
|
||||||
historyKey: event.groupOpenid,
|
historyKey: event.groupOpenid,
|
||||||
limit: groupInfo.historyLimit,
|
limit: groupInfo.historyLimit,
|
||||||
currentMessage: base,
|
currentMessage: base,
|
||||||
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, deps, envelopeOpts),
|
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, envelopeOpts),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,19 +144,15 @@ function formatSenderLabelFrom(name: string | undefined, id: string): string {
|
|||||||
return name.includes(id) ? name : `${name} (${id})`;
|
return name.includes(id) ? name : `${name} (${id})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHistoryEntry(
|
function formatHistoryEntry(entry: HistoryEntry, envelopeOpts: unknown): string {
|
||||||
entry: HistoryEntry,
|
|
||||||
deps: InboundPipelineDeps,
|
|
||||||
envelopeOpts: unknown,
|
|
||||||
): string {
|
|
||||||
const attachmentDesc = formatAttachmentTags(entry.attachments);
|
const attachmentDesc = formatAttachmentTags(entry.attachments);
|
||||||
const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body;
|
const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body;
|
||||||
return deps.runtime.channel.reply.formatInboundEnvelope({
|
return formatInboundEnvelope({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
from: entry.sender,
|
from: entry.sender,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
body: bodyWithAttachments,
|
body: bodyWithAttachments,
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
envelope: envelopeOpts,
|
envelope: envelopeOpts as EnvelopeFormatOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
* dispatcher needs. No decisions / gating.
|
* dispatcher needs. No decisions / gating.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatInboundEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
import type { ProcessedAttachments } from "../inbound-attachments.js";
|
import type { ProcessedAttachments } from "../inbound-attachments.js";
|
||||||
import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js";
|
import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js";
|
||||||
@@ -25,16 +30,16 @@ interface BuildBodyInput {
|
|||||||
/** Format the inbound envelope (Web UI body). */
|
/** Format the inbound envelope (Web UI body). */
|
||||||
export function buildBody(input: BuildBodyInput): string {
|
export function buildBody(input: BuildBodyInput): string {
|
||||||
const { event, deps, userContent, isGroupChat, imageUrls } = input;
|
const { event, deps, userContent, isGroupChat, imageUrls } = input;
|
||||||
const envelopeOptions = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
|
const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg as OpenClawConfig);
|
||||||
return deps.runtime.channel.reply.formatInboundEnvelope({
|
return formatInboundEnvelope({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
from: event.senderName ?? event.senderId,
|
from: event.senderName ?? event.senderId,
|
||||||
timestamp: new Date(event.timestamp).getTime(),
|
timestamp: new Date(event.timestamp).getTime(),
|
||||||
body: userContent,
|
body: userContent,
|
||||||
|
...(imageUrls.length > 0 ? { imageUrls } : {}),
|
||||||
chatType: isGroupChat ? "group" : "direct",
|
chatType: isGroupChat ? "group" : "direct",
|
||||||
sender: { id: event.senderId, name: event.senderName },
|
sender: { id: event.senderId, name: event.senderName },
|
||||||
envelope: envelopeOptions,
|
envelope: envelopeOptions,
|
||||||
...(imageUrls.length > 0 ? { imageUrls } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,20 +87,12 @@ export async function dispatchRaftWake(params: {
|
|||||||
bodyForAgent: input.textForAgent,
|
bodyForAgent: input.textForAgent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const storePath = channelRuntime.session.resolveStorePath(ctx.cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
cfg: ctx.cfg,
|
cfg: ctx.cfg,
|
||||||
channel: RAFT_CHANNEL_ID,
|
channel: RAFT_CHANNEL_ID,
|
||||||
accountId: ctx.accountId,
|
accountId: ctx.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: channelRuntime.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
// Raft's bridge only transports wake hints. The agent owns CLI delivery
|
// Raft's bridge only transports wake hints. The agent owns CLI delivery
|
||||||
// after it reads the pending Raft messages, so OpenClaw must not emit a
|
// after it reads the pending Raft messages, so OpenClaw must not emit a
|
||||||
// duplicate synthetic reply through the channel dispatcher.
|
// duplicate synthetic reply through the channel dispatcher.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
dispatchInboundDirectDmWithRuntime,
|
dispatchInboundDirectDm,
|
||||||
recordChannelBotPairLoopAndCheckSuppression,
|
recordChannelBotPairLoopAndCheckSuppression,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||||
@@ -235,9 +235,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await dispatchInboundDirectDmWithRuntime({
|
await dispatchInboundDirectDm({
|
||||||
cfg: ctx.cfg,
|
cfg: ctx.cfg,
|
||||||
runtime,
|
|
||||||
channel: "reef",
|
channel: "reef",
|
||||||
channelLabel: "Reef",
|
channelLabel: "Reef",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@@ -293,9 +292,8 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
|
|||||||
async (notice) => {
|
async (notice) => {
|
||||||
let resendText = "";
|
let resendText = "";
|
||||||
let dispatchFailure: Error | undefined;
|
let dispatchFailure: Error | undefined;
|
||||||
await dispatchInboundDirectDmWithRuntime({
|
await dispatchInboundDirectDm({
|
||||||
cfg: ctx.cfg,
|
cfg: ctx.cfg,
|
||||||
runtime,
|
|
||||||
channel: "reef",
|
channel: "reef",
|
||||||
channelLabel: "Reef",
|
channelLabel: "Reef",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
|
|||||||
@@ -79,6 +79,38 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
|
||||||
|
"openclaw/plugin-sdk/channel-inbound",
|
||||||
|
);
|
||||||
|
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
runChannelInboundEvent: (params: RunParams) => {
|
||||||
|
const resolveTurn = params.adapter.resolveTurn;
|
||||||
|
return actual.runChannelInboundEvent({
|
||||||
|
...params,
|
||||||
|
adapter: {
|
||||||
|
...params.adapter,
|
||||||
|
resolveTurn: async (input, eventClass, preflight) => {
|
||||||
|
const resolved = await resolveTurn(input, eventClass, preflight);
|
||||||
|
if (!("route" in resolved) || !("runDispatch" in resolved)) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
const { route, ...turn } = resolved;
|
||||||
|
return {
|
||||||
|
...turn,
|
||||||
|
routeSessionKey: route.sessionKey,
|
||||||
|
storePath: "/tmp/openclaw/signal-sessions.json",
|
||||||
|
recordInboundSession: recordInboundSessionMock,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||||
"openclaw/plugin-sdk/conversation-runtime",
|
"openclaw/plugin-sdk/conversation-runtime",
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
resolveChannelGroupRequireMention,
|
resolveChannelGroupRequireMention,
|
||||||
} from "openclaw/plugin-sdk/channel-policy";
|
} from "openclaw/plugin-sdk/channel-policy";
|
||||||
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
|
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
|
||||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
|
||||||
import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import {
|
import {
|
||||||
createInternalHookEvent,
|
createInternalHookEvent,
|
||||||
@@ -536,12 +535,11 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
raw: entry,
|
raw: entry,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg: deps.cfg,
|
||||||
channel: "signal",
|
channel: "signal",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
updateLastRoute: !entry.isGroup
|
updateLastRoute: !entry.isGroup
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Slack-private authored text placement after block compilation.
|
// Slack-private authored text placement after block compilation.
|
||||||
import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
import type { LegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||||
|
|
||||||
export type SlackAuthoredTextPlacement = "none" | "blocks" | "outside-blocks";
|
export type SlackAuthoredTextPlacement = "none" | "blocks" | "outside-blocks";
|
||||||
@@ -10,7 +10,7 @@ function normalizeComparableSlackText(text: string): string {
|
|||||||
|
|
||||||
function isSlackAuthoredTextRepresentedInInteractive(
|
function isSlackAuthoredTextRepresentedInInteractive(
|
||||||
text: string,
|
text: string,
|
||||||
interactive?: InteractiveReply,
|
interactive?: LegacyInteractiveReply,
|
||||||
): boolean {
|
): boolean {
|
||||||
return isSlackAuthoredTextRepresentedInFragments(
|
return isSlackAuthoredTextRepresentedInFragments(
|
||||||
text,
|
text,
|
||||||
@@ -43,7 +43,7 @@ function isSlackAuthoredTextRepresentedInFragments(
|
|||||||
/** Resolve placement from producer facts, before accessibility text changes the payload text. */
|
/** Resolve placement from producer facts, before accessibility text changes the payload text. */
|
||||||
export function resolveSlackAuthoredTextPlacement(params: {
|
export function resolveSlackAuthoredTextPlacement(params: {
|
||||||
text?: string;
|
text?: string;
|
||||||
interactive?: InteractiveReply;
|
interactive?: LegacyInteractiveReply;
|
||||||
renderedInBlocks?: boolean;
|
renderedInBlocks?: boolean;
|
||||||
renderedTextFragments?: readonly string[];
|
renderedTextFragments?: readonly string[];
|
||||||
}): SlackAuthoredTextPlacement {
|
}): SlackAuthoredTextPlacement {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
import type { Block, KnownBlock } from "@slack/web-api";
|
import type { Block, KnownBlock } from "@slack/web-api";
|
||||||
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||||
import {
|
import {
|
||||||
reduceInteractiveReply,
|
reduceLegacyInteractiveReply,
|
||||||
resolveMessagePresentationButtonAction,
|
resolveMessagePresentationButtonAction,
|
||||||
resolveMessagePresentationOptionAction,
|
resolveMessagePresentationOptionAction,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import type {
|
import type {
|
||||||
InteractiveReply,
|
LegacyInteractiveReply,
|
||||||
MessagePresentation,
|
MessagePresentation,
|
||||||
MessagePresentationAction,
|
MessagePresentationAction,
|
||||||
MessagePresentationButtonsBlock,
|
MessagePresentationButtonsBlock,
|
||||||
@@ -228,7 +228,7 @@ export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackB
|
|||||||
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
|
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
|
||||||
*/
|
*/
|
||||||
export function buildSlackInteractiveBlocks(
|
export function buildSlackInteractiveBlocks(
|
||||||
interactive?: InteractiveReply,
|
interactive?: LegacyInteractiveReply,
|
||||||
options: SlackBlockRenderOptions = {},
|
options: SlackBlockRenderOptions = {},
|
||||||
): SlackBlock[] {
|
): SlackBlock[] {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@@ -236,7 +236,7 @@ export function buildSlackInteractiveBlocks(
|
|||||||
buttonIndex: options.buttonIndexOffset ?? 0,
|
buttonIndex: options.buttonIndexOffset ?? 0,
|
||||||
selectIndex: options.selectIndexOffset ?? 0,
|
selectIndex: options.selectIndexOffset ?? 0,
|
||||||
};
|
};
|
||||||
return reduceInteractiveReply(interactive, initialState, (state, block) => {
|
return reduceLegacyInteractiveReply(interactive, initialState, (state, block) => {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
const trimmed = block.text.trim();
|
const trimmed = block.text.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// Drives the real dispatch wiring (dispatchPreparedSlackMessage → deliverSlackPayload
|
// Drives the real dispatch wiring (dispatchPreparedSlackMessage → deliverSlackPayload
|
||||||
// → native stream / draft preview / preview finalize / deliverReplies → sendMessageSlack)
|
// → native stream / draft preview / preview finalize / deliverReplies → sendMessageSlack)
|
||||||
// with the core agent turn mocked at the dispatchReplyWithBufferedBlockDispatcher seam:
|
// with the core agent turn mocked at the channel-inbound dispatch seam:
|
||||||
// the scripted steps stand in for the reply dispatcher callbacks (typing, partials,
|
// the scripted steps stand in for the reply dispatcher callbacks (typing, partials,
|
||||||
// tool progress, per-payload deliver). OUT events are the Slack Web API calls observed
|
// tool progress, per-payload deliver). OUT events are the Slack Web API calls observed
|
||||||
// at a recording WebClient stand-in. Native streaming runs through the REAL
|
// at a recording WebClient stand-in. Native streaming runs through the REAL
|
||||||
@@ -90,33 +90,35 @@ const traceState = vi.hoisted(
|
|||||||
// deliver/typing/replyOptions wiring (dedupe, thread plan, native stream ladder,
|
// deliver/typing/replyOptions wiring (dedupe, thread plan, native stream ladder,
|
||||||
// draft preview, preview finalize, deliverReplies chunking, sendMessageSlack)
|
// draft preview, preview finalize, deliverReplies chunking, sendMessageSlack)
|
||||||
// stays the real production code.
|
// stays the real production code.
|
||||||
vi.mock("./monitor/reply.runtime.js", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("./monitor/reply.runtime.js")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||||
|
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
dispatchReplyWithBufferedBlockDispatcher: async (params: {
|
dispatchChannelInboundTurn: async (params: DispatchParams) => {
|
||||||
dispatcherOptions: unknown;
|
|
||||||
replyOptions?: unknown;
|
|
||||||
}) => {
|
|
||||||
traceState.turn = {
|
traceState.turn = {
|
||||||
options: params.dispatcherOptions as CapturedDispatcherOptions,
|
options: {
|
||||||
|
...params.dispatcherOptions,
|
||||||
|
deliver: params.delivery.deliver,
|
||||||
|
onError: params.delivery.onError,
|
||||||
|
} as CapturedDispatcherOptions,
|
||||||
replyOptions: (params.replyOptions ?? {}) as CapturedReplyOptions,
|
replyOptions: (params.replyOptions ?? {}) as CapturedReplyOptions,
|
||||||
};
|
};
|
||||||
traceState.turnStarted?.resolve();
|
traceState.turnStarted?.resolve();
|
||||||
if (!traceState.turnOutcome) {
|
if (!traceState.turnOutcome) {
|
||||||
throw new Error("trace turn outcome gate not initialized");
|
throw new Error("trace turn outcome gate not initialized");
|
||||||
}
|
}
|
||||||
return await traceState.turnOutcome.promise;
|
return {
|
||||||
|
admission: { kind: "dispatch" },
|
||||||
|
dispatched: true,
|
||||||
|
ctxPayload: params.ctxPayload,
|
||||||
|
routeSessionKey: params.route.sessionKey,
|
||||||
|
dispatchResult: await traceState.turnOutcome.promise,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session-store recording is not wire behavior; keep the turn hermetic.
|
|
||||||
vi.mock("./monitor/conversation.runtime.js", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("./monitor/conversation.runtime.js")>();
|
|
||||||
return { ...actual, recordInboundSession: async () => {} };
|
|
||||||
});
|
|
||||||
|
|
||||||
// send.ts/actions.ts build their own WebClient from tokens; route every client
|
// send.ts/actions.ts build their own WebClient from tokens; route every client
|
||||||
// resolution to the scenario's recording client so all wire calls are captured.
|
// resolution to the scenario's recording client so all wire calls are captured.
|
||||||
vi.mock("./client.js", async (importOriginal) => {
|
vi.mock("./client.js", async (importOriginal) => {
|
||||||
@@ -138,8 +140,7 @@ vi.mock("./client.js", async (importOriginal) => {
|
|||||||
import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js";
|
import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js";
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.doUnmock("./monitor/reply.runtime.js");
|
vi.doUnmock("openclaw/plugin-sdk/channel-inbound");
|
||||||
vi.doUnmock("./monitor/conversation.runtime.js");
|
|
||||||
vi.doUnmock("./client.js");
|
vi.doUnmock("./client.js");
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
|||||||
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
|
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
|
||||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||||
import {
|
import {
|
||||||
normalizeInteractiveReply,
|
normalizeLegacyInteractiveReply,
|
||||||
normalizeMessagePresentation,
|
normalizeMessagePresentation,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
||||||
@@ -88,7 +88,7 @@ export async function handleSlackMessageAction(params: {
|
|||||||
});
|
});
|
||||||
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
|
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
|
||||||
const presentation = normalizeMessagePresentation(actionParams.presentation);
|
const presentation = normalizeMessagePresentation(actionParams.presentation);
|
||||||
const interactive = normalizeInteractiveReply(actionParams.interactive);
|
const interactive = normalizeLegacyInteractiveReply(actionParams.interactive);
|
||||||
const hasStructuredContent = Boolean(presentation || interactive?.blocks.length);
|
const hasStructuredContent = Boolean(presentation || interactive?.blocks.length);
|
||||||
const resolution = resolveSlackReplyBlockResolution(
|
const resolution = resolveSlackReplyBlockResolution(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -295,23 +295,16 @@ vi.mock("./monitor/config.runtime.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("./monitor/reply.runtime.js", async () => {
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||||
const actual = await vi.importActual<typeof import("./monitor/reply.runtime.js")>(
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||||
"./monitor/reply.runtime.js",
|
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
|
||||||
);
|
type ReplyResolver = NonNullable<DispatchParams["replyResolver"]>;
|
||||||
type BufferedDispatchParams = Parameters<
|
|
||||||
typeof actual.dispatchReplyWithBufferedBlockDispatcher
|
|
||||||
>[0];
|
|
||||||
type ReplyResolver = NonNullable<BufferedDispatchParams["replyResolver"]>;
|
|
||||||
const replyResolver: ReplyResolver = (...args) =>
|
const replyResolver: ReplyResolver = (...args) =>
|
||||||
slackTestState.replyMock(...args) as ReturnType<ReplyResolver>;
|
slackTestState.replyMock(...args) as ReturnType<ReplyResolver>;
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
dispatchReplyWithBufferedBlockDispatcher: (params: BufferedDispatchParams) =>
|
dispatchChannelInboundTurn: (params: DispatchParams) =>
|
||||||
actual.dispatchReplyWithBufferedBlockDispatcher({
|
actual.dispatchChannelInboundTurn({ ...params, replyResolver }),
|
||||||
...params,
|
|
||||||
replyResolver,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,7 +342,6 @@ vi.mock("./monitor/conversation.runtime.js", async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
readChannelAllowFromStore: (...args: unknown[]) =>
|
readChannelAllowFromStore: (...args: unknown[]) =>
|
||||||
slackTestState.readAllowFromStoreMock(...args),
|
slackTestState.readAllowFromStoreMock(...args),
|
||||||
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
||||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||||
slackTestState.upsertPairingRequestMock(...args),
|
slackTestState.upsertPairingRequestMock(...args),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
export {
|
export {
|
||||||
buildPluginBindingResolvedText,
|
buildPluginBindingResolvedText,
|
||||||
parsePluginBindingApprovalCustomId,
|
parsePluginBindingApprovalCustomId,
|
||||||
recordInboundSession,
|
|
||||||
resolveConversationLabel,
|
resolveConversationLabel,
|
||||||
resolvePluginConversationBindingApproval,
|
resolvePluginConversationBindingApproval,
|
||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Slack tests cover dispatch.preview fallback plugin behavior.
|
// Slack tests cover dispatch.preview fallback plugin behavior.
|
||||||
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const FINAL_REPLY_TEXT = "final answer";
|
const FINAL_REPLY_TEXT = "final answer";
|
||||||
@@ -13,7 +14,6 @@ const finalizeSlackPreviewEditMock = vi.fn(async () => {});
|
|||||||
const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim());
|
const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim());
|
||||||
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||||||
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||||||
const recordInboundSessionMock = vi.fn(async () => undefined);
|
|
||||||
const recordSlackThreadParticipationMock = vi.fn();
|
const recordSlackThreadParticipationMock = vi.fn();
|
||||||
const updateLastRouteMock = vi.fn(async () => {});
|
const updateLastRouteMock = vi.fn(async () => {});
|
||||||
const appendSlackStreamMock = vi.fn(async () => {});
|
const appendSlackStreamMock = vi.fn(async () => {});
|
||||||
@@ -518,10 +518,6 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
|
|||||||
removeAckReactionAfterReply: () => {},
|
removeAckReactionAfterReply: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../conversation.runtime.js", () => ({
|
|
||||||
recordInboundSession: recordInboundSessionMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
|
||||||
return {
|
return {
|
||||||
@@ -1018,361 +1014,134 @@ vi.mock("../replies.js", () => ({
|
|||||||
resolveSlackThreadTs: () => mockedReplyThreadTs,
|
resolveSlackThreadTs: () => mockedReplyThreadTs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../reply.runtime.js", () => ({
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||||
createReplyDispatcherWithTyping: (params: {
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||||
transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null;
|
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
|
||||||
beforeDeliver?: (
|
return {
|
||||||
payload: TestReplyPayload,
|
...actual,
|
||||||
info: { kind: TestReplyDispatchKind },
|
dispatchChannelInboundTurn: async (params: DispatchParams) => {
|
||||||
) => Promise<TestReplyPayload | null> | TestReplyPayload | null;
|
capturedReplyOptions = params.replyOptions as typeof capturedReplyOptions;
|
||||||
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>;
|
if (mockedReplyOptionEvents.length > 0) {
|
||||||
}) => ({
|
for (const entry of mockedReplyOptionEvents) {
|
||||||
dispatcher: {
|
if (entry.kind === "item") {
|
||||||
deliver: async (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => {
|
await params.replyOptions?.onItemEvent?.({
|
||||||
const transformed = params.transformReplyPayload
|
kind: entry.itemKind,
|
||||||
? params.transformReplyPayload(payload)
|
itemId: entry.itemId,
|
||||||
|
toolCallId: entry.toolCallId,
|
||||||
|
progressText: entry.progressText,
|
||||||
|
summary: entry.summary,
|
||||||
|
title: entry.title,
|
||||||
|
name: entry.name,
|
||||||
|
phase: entry.phase,
|
||||||
|
status: entry.status,
|
||||||
|
meta: entry.meta,
|
||||||
|
});
|
||||||
|
} else if (entry.kind === "command_output") {
|
||||||
|
await params.replyOptions?.onCommandOutput?.({
|
||||||
|
itemId: entry.itemId,
|
||||||
|
toolCallId: entry.toolCallId,
|
||||||
|
phase: entry.phase,
|
||||||
|
title: entry.title,
|
||||||
|
name: entry.name,
|
||||||
|
status: entry.status,
|
||||||
|
exitCode: entry.exitCode,
|
||||||
|
});
|
||||||
|
} else if (entry.kind === "tool_start") {
|
||||||
|
await params.replyOptions?.onToolStart?.({
|
||||||
|
itemId: entry.itemId,
|
||||||
|
toolCallId: entry.toolCallId,
|
||||||
|
name: entry.name,
|
||||||
|
phase: entry.phase,
|
||||||
|
args: entry.args,
|
||||||
|
detailMode: entry.detailMode,
|
||||||
|
});
|
||||||
|
} else if (entry.kind === "patch") {
|
||||||
|
await params.replyOptions?.onPatchSummary?.({
|
||||||
|
itemId: entry.itemId,
|
||||||
|
toolCallId: entry.toolCallId,
|
||||||
|
phase: entry.phase,
|
||||||
|
title: entry.title,
|
||||||
|
name: entry.name,
|
||||||
|
added: entry.added,
|
||||||
|
modified: entry.modified,
|
||||||
|
deleted: entry.deleted,
|
||||||
|
summary: entry.summary,
|
||||||
|
});
|
||||||
|
} else if (entry.kind === "plan") {
|
||||||
|
await params.replyOptions?.onPlanUpdate?.({
|
||||||
|
phase: entry.phase,
|
||||||
|
explanation: entry.explanation,
|
||||||
|
steps: entry.steps,
|
||||||
|
});
|
||||||
|
} else if (entry.kind === "concurrent_items") {
|
||||||
|
await Promise.all(
|
||||||
|
entry.progressTexts.map((progressText) =>
|
||||||
|
Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (entry.kind === "assistant_start") {
|
||||||
|
await params.replyOptions?.onAssistantMessageStart?.();
|
||||||
|
} else if (entry.kind === "reasoning") {
|
||||||
|
await params.replyOptions?.onReasoningStream?.({
|
||||||
|
text: entry.text,
|
||||||
|
isReasoningSnapshot: entry.isReasoningSnapshot,
|
||||||
|
});
|
||||||
|
} else if (entry.kind === "reasoning_end") {
|
||||||
|
await params.replyOptions?.onReasoningEnd?.();
|
||||||
|
} else {
|
||||||
|
await params.replyOptions?.onPartialReply?.({ text: entry.text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const progressText of mockedProgressEvents) {
|
||||||
|
await params.replyOptions?.onItemEvent?.({ progressText });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of mockedDispatchSequence) {
|
||||||
|
if (entry.kind === "queued_followup") {
|
||||||
|
await params.replyOptions?.onQueuedFollowupAdmitted?.();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.kind === "item") {
|
||||||
|
await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const payload = entry.payload as ReplyPayload;
|
||||||
|
const transformed = params.dispatcherOptions?.transformReplyPayload
|
||||||
|
? params.dispatcherOptions.transformReplyPayload(payload)
|
||||||
: payload;
|
: payload;
|
||||||
if (!transformed) {
|
if (!transformed) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
const deliverPayload = params.beforeDeliver
|
const deliverPayload = params.dispatcherOptions?.beforeDeliver
|
||||||
? await params.beforeDeliver(transformed, info)
|
? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind })
|
||||||
: transformed;
|
: transformed;
|
||||||
if (!deliverPayload) {
|
if (!deliverPayload) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
mockedQueuedDispatchCounts[info.kind] += 1;
|
mockedQueuedDispatchCounts[entry.kind] += 1;
|
||||||
await params.deliver(deliverPayload, info);
|
try {
|
||||||
},
|
await params.delivery.deliver(deliverPayload, { kind: entry.kind });
|
||||||
|
} catch (error) {
|
||||||
|
if (!mockedDispatcherCapturesDeliveryErrors) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
mockedQueuedDispatchCounts[entry.kind] -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
admission: { kind: "dispatch" } as const,
|
||||||
|
dispatched: true as const,
|
||||||
|
ctxPayload: params.ctxPayload,
|
||||||
|
routeSessionKey: params.route.sessionKey,
|
||||||
|
dispatchResult: {
|
||||||
|
queuedFinal: false,
|
||||||
|
counts: { ...mockedQueuedDispatchCounts },
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
replyOptions: {},
|
};
|
||||||
markDispatchIdle: () => {},
|
});
|
||||||
}),
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher: async (params: {
|
|
||||||
dispatcherOptions: {
|
|
||||||
transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null;
|
|
||||||
beforeDeliver?: (
|
|
||||||
payload: TestReplyPayload,
|
|
||||||
info: { kind: TestReplyDispatchKind },
|
|
||||||
) => Promise<TestReplyPayload | null> | TestReplyPayload | null;
|
|
||||||
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>;
|
|
||||||
};
|
|
||||||
replyOptions?: {
|
|
||||||
disableBlockStreaming?: boolean;
|
|
||||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
|
||||||
suppressTyping?: boolean;
|
|
||||||
suppressDefaultToolProgressMessages?: boolean;
|
|
||||||
onItemEvent?: (payload: {
|
|
||||||
kind?: string;
|
|
||||||
itemId?: string;
|
|
||||||
toolCallId?: string;
|
|
||||||
progressText?: string;
|
|
||||||
summary?: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
phase?: string;
|
|
||||||
status?: string;
|
|
||||||
meta?: string;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onCommandOutput?: (payload: {
|
|
||||||
itemId?: string;
|
|
||||||
toolCallId?: string;
|
|
||||||
phase?: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
status?: string;
|
|
||||||
exitCode?: number | null;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onToolStart?: (payload: {
|
|
||||||
itemId?: string;
|
|
||||||
toolCallId?: string;
|
|
||||||
name: string;
|
|
||||||
phase?: string;
|
|
||||||
args?: Record<string, unknown>;
|
|
||||||
detailMode?: "explain" | "raw";
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onPatchSummary?: (payload: {
|
|
||||||
itemId?: string;
|
|
||||||
toolCallId?: string;
|
|
||||||
phase?: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
added?: string[];
|
|
||||||
modified?: string[];
|
|
||||||
deleted?: string[];
|
|
||||||
summary?: string;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onPlanUpdate?: (payload: {
|
|
||||||
phase?: string;
|
|
||||||
explanation?: string;
|
|
||||||
steps?: Array<{
|
|
||||||
step: string;
|
|
||||||
status: "pending" | "in_progress" | "completed";
|
|
||||||
}>;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onAssistantMessageStart?: () => Promise<void> | void;
|
|
||||||
onReasoningEnd?: () => Promise<void> | void;
|
|
||||||
onReasoningStream?: (payload?: {
|
|
||||||
text?: string;
|
|
||||||
isReasoningSnapshot?: boolean;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onPartialReply?: (payload: { text: string }) => Promise<void> | void;
|
|
||||||
onQueuedFollowupAdmitted?: () => Promise<void> | void;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
capturedReplyOptions = params.replyOptions;
|
|
||||||
if (mockedReplyOptionEvents.length > 0) {
|
|
||||||
for (const entry of mockedReplyOptionEvents) {
|
|
||||||
if (entry.kind === "item") {
|
|
||||||
await params.replyOptions?.onItemEvent?.({
|
|
||||||
kind: entry.itemKind,
|
|
||||||
itemId: entry.itemId,
|
|
||||||
toolCallId: entry.toolCallId,
|
|
||||||
progressText: entry.progressText,
|
|
||||||
summary: entry.summary,
|
|
||||||
title: entry.title,
|
|
||||||
name: entry.name,
|
|
||||||
phase: entry.phase,
|
|
||||||
status: entry.status,
|
|
||||||
meta: entry.meta,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "command_output") {
|
|
||||||
await params.replyOptions?.onCommandOutput?.({
|
|
||||||
itemId: entry.itemId,
|
|
||||||
toolCallId: entry.toolCallId,
|
|
||||||
phase: entry.phase,
|
|
||||||
title: entry.title,
|
|
||||||
name: entry.name,
|
|
||||||
status: entry.status,
|
|
||||||
exitCode: entry.exitCode,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "tool_start") {
|
|
||||||
await params.replyOptions?.onToolStart?.({
|
|
||||||
itemId: entry.itemId,
|
|
||||||
toolCallId: entry.toolCallId,
|
|
||||||
name: entry.name,
|
|
||||||
phase: entry.phase,
|
|
||||||
args: entry.args,
|
|
||||||
detailMode: entry.detailMode,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "patch") {
|
|
||||||
await params.replyOptions?.onPatchSummary?.({
|
|
||||||
itemId: entry.itemId,
|
|
||||||
toolCallId: entry.toolCallId,
|
|
||||||
phase: entry.phase,
|
|
||||||
title: entry.title,
|
|
||||||
name: entry.name,
|
|
||||||
added: entry.added,
|
|
||||||
modified: entry.modified,
|
|
||||||
deleted: entry.deleted,
|
|
||||||
summary: entry.summary,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "plan") {
|
|
||||||
await params.replyOptions?.onPlanUpdate?.({
|
|
||||||
phase: entry.phase,
|
|
||||||
explanation: entry.explanation,
|
|
||||||
steps: entry.steps,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "concurrent_items") {
|
|
||||||
await Promise.all(
|
|
||||||
entry.progressTexts.map((progressText) =>
|
|
||||||
Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (entry.kind === "assistant_start") {
|
|
||||||
await params.replyOptions?.onAssistantMessageStart?.();
|
|
||||||
} else if (entry.kind === "reasoning") {
|
|
||||||
await params.replyOptions?.onReasoningStream?.({
|
|
||||||
text: entry.text,
|
|
||||||
isReasoningSnapshot: entry.isReasoningSnapshot,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "reasoning_end") {
|
|
||||||
await params.replyOptions?.onReasoningEnd?.();
|
|
||||||
} else {
|
|
||||||
await params.replyOptions?.onPartialReply?.({ text: entry.text });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const progressText of mockedProgressEvents) {
|
|
||||||
await params.replyOptions?.onItemEvent?.({ progressText });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const entry of mockedDispatchSequence) {
|
|
||||||
if (entry.kind === "queued_followup") {
|
|
||||||
await params.replyOptions?.onQueuedFollowupAdmitted?.();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.kind === "item") {
|
|
||||||
await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const transformed = params.dispatcherOptions.transformReplyPayload
|
|
||||||
? params.dispatcherOptions.transformReplyPayload(entry.payload)
|
|
||||||
: entry.payload;
|
|
||||||
if (!transformed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const deliverPayload = params.dispatcherOptions.beforeDeliver
|
|
||||||
? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind })
|
|
||||||
: transformed;
|
|
||||||
if (!deliverPayload) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
mockedQueuedDispatchCounts[entry.kind] += 1;
|
|
||||||
try {
|
|
||||||
await params.dispatcherOptions.deliver(deliverPayload, { kind: entry.kind });
|
|
||||||
} catch (error) {
|
|
||||||
if (!mockedDispatcherCapturesDeliveryErrors) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
mockedQueuedDispatchCounts[entry.kind] -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
queuedFinal: false,
|
|
||||||
counts: { ...mockedQueuedDispatchCounts },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
dispatchInboundMessage: async (params: {
|
|
||||||
replyOptions?: {
|
|
||||||
disableBlockStreaming?: boolean;
|
|
||||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
|
||||||
suppressTyping?: boolean;
|
|
||||||
suppressDefaultToolProgressMessages?: boolean;
|
|
||||||
onAssistantMessageStart?: () => Promise<void> | void;
|
|
||||||
onReasoningEnd?: () => Promise<void> | void;
|
|
||||||
onReasoningStream?: (payload?: {
|
|
||||||
text?: string;
|
|
||||||
isReasoningSnapshot?: boolean;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onItemEvent?: (payload: {
|
|
||||||
kind?: string;
|
|
||||||
itemId?: string;
|
|
||||||
progressText?: string;
|
|
||||||
summary?: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
phase?: string;
|
|
||||||
status?: string;
|
|
||||||
meta?: string;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onToolStart?: (payload: {
|
|
||||||
itemId?: string;
|
|
||||||
toolCallId?: string;
|
|
||||||
name: string;
|
|
||||||
phase?: string;
|
|
||||||
args?: Record<string, unknown>;
|
|
||||||
detailMode?: "explain" | "raw";
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onPatchSummary?: (payload: {
|
|
||||||
itemId?: string;
|
|
||||||
toolCallId?: string;
|
|
||||||
phase?: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
added?: string[];
|
|
||||||
modified?: string[];
|
|
||||||
deleted?: string[];
|
|
||||||
summary?: string;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onPlanUpdate?: (payload: {
|
|
||||||
phase?: string;
|
|
||||||
explanation?: string;
|
|
||||||
steps?: Array<{
|
|
||||||
step: string;
|
|
||||||
status: "pending" | "in_progress" | "completed";
|
|
||||||
}>;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
onPartialReply?: (payload: { text: string }) => Promise<void> | void;
|
|
||||||
onQueuedFollowupAdmitted?: () => Promise<void> | void;
|
|
||||||
};
|
|
||||||
dispatcher: {
|
|
||||||
deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise<void>;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
capturedReplyOptions = params.replyOptions;
|
|
||||||
if (mockedReplyOptionEvents.length > 0) {
|
|
||||||
for (const entry of mockedReplyOptionEvents) {
|
|
||||||
if (entry.kind === "item") {
|
|
||||||
await params.replyOptions?.onItemEvent?.({
|
|
||||||
kind: entry.itemKind,
|
|
||||||
itemId: entry.itemId,
|
|
||||||
progressText: entry.progressText,
|
|
||||||
summary: entry.summary,
|
|
||||||
title: entry.title,
|
|
||||||
name: entry.name,
|
|
||||||
phase: entry.phase,
|
|
||||||
status: entry.status,
|
|
||||||
meta: entry.meta,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "tool_start") {
|
|
||||||
await params.replyOptions?.onToolStart?.({
|
|
||||||
itemId: entry.itemId,
|
|
||||||
toolCallId: entry.toolCallId,
|
|
||||||
name: entry.name,
|
|
||||||
phase: entry.phase,
|
|
||||||
args: entry.args,
|
|
||||||
detailMode: entry.detailMode,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "patch") {
|
|
||||||
await params.replyOptions?.onPatchSummary?.({
|
|
||||||
itemId: entry.itemId,
|
|
||||||
toolCallId: entry.toolCallId,
|
|
||||||
phase: entry.phase,
|
|
||||||
title: entry.title,
|
|
||||||
name: entry.name,
|
|
||||||
added: entry.added,
|
|
||||||
modified: entry.modified,
|
|
||||||
deleted: entry.deleted,
|
|
||||||
summary: entry.summary,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "plan") {
|
|
||||||
await params.replyOptions?.onPlanUpdate?.({
|
|
||||||
phase: entry.phase,
|
|
||||||
explanation: entry.explanation,
|
|
||||||
steps: entry.steps,
|
|
||||||
});
|
|
||||||
} else if (entry.kind === "concurrent_items") {
|
|
||||||
await Promise.all(
|
|
||||||
entry.progressTexts.map((progressText) =>
|
|
||||||
Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (entry.kind === "partial") {
|
|
||||||
await params.replyOptions?.onPartialReply?.({ text: entry.text });
|
|
||||||
} else if (entry.kind === "assistant_start") {
|
|
||||||
await params.replyOptions?.onAssistantMessageStart?.();
|
|
||||||
} else if (entry.kind === "reasoning") {
|
|
||||||
await params.replyOptions?.onReasoningStream?.({
|
|
||||||
text: entry.text,
|
|
||||||
isReasoningSnapshot: entry.isReasoningSnapshot,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await params.replyOptions?.onReasoningEnd?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const progressText of mockedProgressEvents) {
|
|
||||||
await params.replyOptions?.onItemEvent?.({ progressText });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const entry of mockedDispatchSequence) {
|
|
||||||
if (entry.kind === "queued_followup") {
|
|
||||||
await params.replyOptions?.onQueuedFollowupAdmitted?.();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.kind === "item") {
|
|
||||||
await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await params.dispatcher.deliver(entry.payload, { kind: entry.kind });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
queuedFinal: false,
|
|
||||||
counts: { ...mockedQueuedDispatchCounts },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./preview-finalize.js", () => ({
|
vi.mock("./preview-finalize.js", () => ({
|
||||||
finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock,
|
finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock,
|
||||||
@@ -1392,7 +1161,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
|||||||
normalizeSlackOutboundTextMock.mockClear();
|
normalizeSlackOutboundTextMock.mockClear();
|
||||||
postMessageMock.mockClear();
|
postMessageMock.mockClear();
|
||||||
chatUpdateMock.mockClear();
|
chatUpdateMock.mockClear();
|
||||||
recordInboundSessionMock.mockReset();
|
|
||||||
recordSlackThreadParticipationMock.mockReset();
|
recordSlackThreadParticipationMock.mockReset();
|
||||||
updateLastRouteMock.mockReset();
|
updateLastRouteMock.mockReset();
|
||||||
appendSlackStreamMock.mockReset();
|
appendSlackStreamMock.mockReset();
|
||||||
@@ -1490,168 +1258,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
|||||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS });
|
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes accepted Slack bot messages through the shared bot loop guard", async () => {
|
|
||||||
const base = {
|
|
||||||
cfg: {
|
|
||||||
channels: {
|
|
||||||
defaults: {
|
|
||||||
botLoopProtection: {
|
|
||||||
maxEventsPerWindow: 1,
|
|
||||||
windowSeconds: 60,
|
|
||||||
cooldownSeconds: 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
accountConfig: { allowBots: true },
|
|
||||||
message: {
|
|
||||||
channel: "C_LOOP_SLACK",
|
|
||||||
bot_id: "B_OTHER",
|
|
||||||
user: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await dispatchPreparedSlackMessage(
|
|
||||||
createPreparedSlackMessage({
|
|
||||||
...base,
|
|
||||||
message: {
|
|
||||||
...base.message,
|
|
||||||
ts: "900.001",
|
|
||||||
event_ts: "900.001",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await dispatchPreparedSlackMessage(
|
|
||||||
createPreparedSlackMessage({
|
|
||||||
...base,
|
|
||||||
message: {
|
|
||||||
...base.message,
|
|
||||||
ts: "900.002",
|
|
||||||
event_ts: "900.002",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores Slack status reactions when bot loop protection drops a turn", async () => {
|
|
||||||
const base = {
|
|
||||||
cfg: {
|
|
||||||
messages: {
|
|
||||||
statusReactions: { enabled: true },
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
defaults: {
|
|
||||||
botLoopProtection: {
|
|
||||||
maxEventsPerWindow: 1,
|
|
||||||
windowSeconds: 60,
|
|
||||||
cooldownSeconds: 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
accountConfig: { allowBots: true },
|
|
||||||
message: {
|
|
||||||
channel: "C_LOOP_SLACK_STATUS",
|
|
||||||
bot_id: "B_OTHER",
|
|
||||||
user: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await dispatchPreparedSlackMessage(
|
|
||||||
createPreparedSlackMessage({
|
|
||||||
...base,
|
|
||||||
message: {
|
|
||||||
...base.message,
|
|
||||||
ts: "910.001",
|
|
||||||
event_ts: "910.001",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const value of Object.values(statusReactionControllerMock)) {
|
|
||||||
value.mockClear();
|
|
||||||
}
|
|
||||||
|
|
||||||
await dispatchPreparedSlackMessage(
|
|
||||||
createPreparedSlackMessage({
|
|
||||||
...base,
|
|
||||||
ackReactionMessageTs: "910.002",
|
|
||||||
ackReactionPromise: Promise.resolve(true),
|
|
||||||
message: {
|
|
||||||
...base.message,
|
|
||||||
ts: "910.002",
|
|
||||||
event_ts: "910.002",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(statusReactionControllerMock.setQueued).toHaveBeenCalledTimes(1);
|
|
||||||
expect(statusReactionControllerMock.restoreInitial).toHaveBeenCalledTimes(1);
|
|
||||||
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("layers Slack channel bot loop overrides over account settings field-by-field", async () => {
|
|
||||||
const base = {
|
|
||||||
cfg: {
|
|
||||||
channels: {
|
|
||||||
defaults: {
|
|
||||||
botLoopProtection: {
|
|
||||||
maxEventsPerWindow: 20,
|
|
||||||
windowSeconds: 1,
|
|
||||||
cooldownSeconds: 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
accountConfig: {
|
|
||||||
allowBots: true,
|
|
||||||
botLoopProtection: {
|
|
||||||
windowSeconds: 120,
|
|
||||||
cooldownSeconds: 240,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channelConfig: {
|
|
||||||
botLoopProtection: {
|
|
||||||
maxEventsPerWindow: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
channel: "C_LOOP_SLACK_LAYERED",
|
|
||||||
bot_id: "B_OTHER_LAYERED",
|
|
||||||
user: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await dispatchPreparedSlackMessage(
|
|
||||||
createPreparedSlackMessage({
|
|
||||||
...base,
|
|
||||||
message: {
|
|
||||||
...base.message,
|
|
||||||
ts: "900.001",
|
|
||||||
event_ts: "900.001",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await dispatchPreparedSlackMessage(
|
|
||||||
createPreparedSlackMessage({
|
|
||||||
...base,
|
|
||||||
message: {
|
|
||||||
...base.message,
|
|
||||||
ts: "961.001",
|
|
||||||
event_ts: "961.001",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates non-main DM last-route metadata on the prepared direct session", async () => {
|
it("updates non-main DM last-route metadata on the prepared direct session", async () => {
|
||||||
mockedPinnedMainDmOwner = "U2";
|
mockedPinnedMainDmOwner = "U2";
|
||||||
await dispatchPreparedSlackMessage(
|
await dispatchPreparedSlackMessage(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type StatusReactionAdapter,
|
type StatusReactionAdapter,
|
||||||
} from "openclaw/plugin-sdk/channel-feedback";
|
} from "openclaw/plugin-sdk/channel-feedback";
|
||||||
import {
|
import {
|
||||||
dispatchChannelInboundReply,
|
dispatchChannelInboundTurn,
|
||||||
type InboundReplyRecordOptions,
|
type InboundReplyRecordOptions,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import {
|
import {
|
||||||
@@ -89,7 +89,6 @@ import { resolveSlackThreadTargets } from "../../threading.js";
|
|||||||
import type { SlackMessageEvent } from "../../types.js";
|
import type { SlackMessageEvent } from "../../types.js";
|
||||||
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
|
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
|
||||||
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
|
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
|
||||||
import { recordInboundSession } from "../conversation.runtime.js";
|
|
||||||
import { escapeSlackMrkdwn } from "../mrkdwn.js";
|
import { escapeSlackMrkdwn } from "../mrkdwn.js";
|
||||||
import {
|
import {
|
||||||
createSlackReplyDeliveryPlan,
|
createSlackReplyDeliveryPlan,
|
||||||
@@ -98,7 +97,6 @@ import {
|
|||||||
resolveDeliveredSlackReplyThreadTs,
|
resolveDeliveredSlackReplyThreadTs,
|
||||||
resolveSlackThreadTs,
|
resolveSlackThreadTs,
|
||||||
} from "../replies.js";
|
} from "../replies.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../reply.runtime.js";
|
|
||||||
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
|
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
|
||||||
import { resolveSlackTimestampMs } from "./timestamp.js";
|
import { resolveSlackTimestampMs } from "./timestamp.js";
|
||||||
import type { PreparedSlackMessage } from "./types.js";
|
import type { PreparedSlackMessage } from "./types.js";
|
||||||
@@ -2074,16 +2072,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
let queuedFinal = false;
|
let queuedFinal = false;
|
||||||
let counts: Partial<Record<ReplyDispatchKind, number>> = {};
|
let counts: Partial<Record<ReplyDispatchKind, number>> = {};
|
||||||
try {
|
try {
|
||||||
const turnResult = await dispatchChannelInboundReply({
|
const turnResult = await dispatchChannelInboundTurn({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath: prepared.turn.storePath,
|
|
||||||
ctxPayload: prepared.ctxPayload,
|
ctxPayload: prepared.ctxPayload,
|
||||||
recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
...replyPipeline,
|
...replyPipeline,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// Slack plugin module implements reply behavior.
|
|
||||||
export { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/channel-send-result";
|
} from "openclaw/plugin-sdk/channel-send-result";
|
||||||
import {
|
import {
|
||||||
normalizeMessagePresentation,
|
normalizeMessagePresentation,
|
||||||
resolveInteractiveTextFallback,
|
resolveLegacyInteractiveTextFallback,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||||
import {
|
import {
|
||||||
@@ -299,7 +299,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...ctx.payload,
|
...ctx.payload,
|
||||||
text:
|
text:
|
||||||
resolveInteractiveTextFallback({
|
resolveLegacyInteractiveTextFallback({
|
||||||
text: ctx.payload.text,
|
text: ctx.payload.text,
|
||||||
interactive: ctx.payload.interactive,
|
interactive: ctx.payload.interactive,
|
||||||
}) ?? "",
|
}) ?? "",
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ function createRuntime() {
|
|||||||
messageSid: string;
|
messageSid: string;
|
||||||
accountSid: string;
|
accountSid: string;
|
||||||
}) => unknown;
|
}) => unknown;
|
||||||
resolveTurn: (ingested: unknown) => Promise<{ routeSessionKey: string }>;
|
resolveTurn: (
|
||||||
|
ingested: unknown,
|
||||||
|
) => Promise<{ route: { agentId: string; sessionKey: string } }>;
|
||||||
};
|
};
|
||||||
}) => void
|
}) => void
|
||||||
>();
|
>();
|
||||||
@@ -172,6 +174,6 @@ describe("dispatchSmsInboundEvent", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(turn.routeSessionKey).toBe("agent:main:sms:direct:+15551234567");
|
expect(turn.route.sessionKey).toBe("agent:main:sms:direct:+15551234567");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -170,23 +170,12 @@ export async function dispatchSmsInboundEvent(params: {
|
|||||||
To: params.msg.to,
|
To: params.msg.to,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const storePath = params.channelRuntime.session.resolveStorePath(
|
|
||||||
params.cfg.session?.store,
|
|
||||||
{
|
|
||||||
agentId: route.agentId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: CHANNEL_ID,
|
channel: CHANNEL_ID,
|
||||||
accountId: params.account.accountId,
|
accountId: params.account.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey },
|
||||||
routeSessionKey: sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: params.channelRuntime.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
durable: () => ({
|
durable: () => ({
|
||||||
to: from,
|
to: from,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () =
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const dispatchReplyWithBufferedBlockDispatcher: Mock<
|
export const dispatchReplyWithBufferedBlockDispatcher: Mock<
|
||||||
() => Promise<{ counts: Record<string, number> }>
|
(_params: unknown) => Promise<{ counts: Record<string, number> }>
|
||||||
> = vi.fn().mockResolvedValue({ counts: {} });
|
> = vi.fn().mockResolvedValue({ counts: {} });
|
||||||
export const finalizeInboundContextMock: Mock<
|
export const finalizeInboundContextMock: Mock<
|
||||||
(ctx: Record<string, unknown>) => Record<string, unknown>
|
(ctx: Record<string, unknown>) => Record<string, unknown>
|
||||||
@@ -152,7 +152,7 @@ vi.mock("./runtime.js", () => ({
|
|||||||
kind: "message",
|
kind: "message",
|
||||||
canStartAgentTurn: true,
|
canStartAgentTurn: true,
|
||||||
});
|
});
|
||||||
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
|
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: resolved.ctxPayload,
|
ctx: resolved.ctxPayload,
|
||||||
cfg: mockRuntimeConfig,
|
cfg: mockRuntimeConfig,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
@@ -166,7 +166,7 @@ vi.mock("./runtime.js", () => ({
|
|||||||
dispatched: true,
|
dispatched: true,
|
||||||
dispatchResult,
|
dispatchResult,
|
||||||
ctxPayload: resolved.ctxPayload,
|
ctxPayload: resolved.ctxPayload,
|
||||||
routeSessionKey: resolved.routeSessionKey,
|
routeSessionKey: resolved.route.sessionKey,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
buildContext: buildChannelInboundEventContextMock,
|
buildContext: buildChannelInboundEventContextMock,
|
||||||
|
|||||||
@@ -125,20 +125,15 @@ export async function dispatchSynologyChatInboundEvent(params: {
|
|||||||
CommandAuthorized: params.msg.commandAuthorized,
|
CommandAuthorized: params.msg.commandAuthorized,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, {
|
|
||||||
agentId: resolved.route.agentId,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
cfg: currentCfg,
|
cfg: currentCfg,
|
||||||
channel: CHANNEL_ID,
|
channel: CHANNEL_ID,
|
||||||
accountId: params.account.accountId,
|
accountId: params.account.accountId,
|
||||||
agentId: resolved.route.agentId,
|
route: {
|
||||||
routeSessionKey: resolved.route.sessionKey,
|
agentId: resolved.route.agentId,
|
||||||
storePath,
|
sessionKey: resolved.route.sessionKey,
|
||||||
|
},
|
||||||
ctxPayload: msgCtx,
|
ctxPayload: msgCtx,
|
||||||
recordInboundSession: resolved.rt.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
durable: () => ({
|
durable: () => ({
|
||||||
to: sendUserId,
|
to: sendUserId,
|
||||||
|
|||||||
@@ -95,12 +95,14 @@ export async function runTelegramDispatchTurn(params: {
|
|||||||
raw: context,
|
raw: context,
|
||||||
}),
|
}),
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg: params.cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId: context.route.accountId,
|
accountId: context.route.accountId,
|
||||||
routeSessionKey: context.route.sessionKey,
|
route: {
|
||||||
storePath: context.turn.storePath,
|
agentId: context.route.agentId,
|
||||||
|
sessionKey: context.route.sessionKey,
|
||||||
|
},
|
||||||
ctxPayload: context.ctxPayload,
|
ctxPayload: context.ctxPayload,
|
||||||
recordInboundSession: context.turn.recordInboundSession,
|
|
||||||
record: context.turn.record,
|
record: context.turn.record,
|
||||||
runDispatch: () =>
|
runDispatch: () =>
|
||||||
params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
|||||||
@@ -165,6 +165,43 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||||
|
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[0];
|
||||||
|
type TestTurn = {
|
||||||
|
storePath: string;
|
||||||
|
recordInboundSession: Parameters<
|
||||||
|
typeof actual.runPreparedInboundReply
|
||||||
|
>[0]["recordInboundSession"];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
runChannelInboundEvent: (params: RunParams) => {
|
||||||
|
const resolveTurn = params.adapter.resolveTurn;
|
||||||
|
return actual.runChannelInboundEvent({
|
||||||
|
...params,
|
||||||
|
adapter: {
|
||||||
|
...params.adapter,
|
||||||
|
resolveTurn: async (input, eventClass, preflight) => {
|
||||||
|
const resolved = await resolveTurn(input, eventClass, preflight);
|
||||||
|
if (!("route" in resolved) || !("runDispatch" in resolved)) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
const { route, ...turn } = resolved;
|
||||||
|
const testTurn = (params.raw as { turn: TestTurn }).turn;
|
||||||
|
return {
|
||||||
|
...turn,
|
||||||
|
routeSessionKey: route.sessionKey,
|
||||||
|
storePath: testTurn.storePath,
|
||||||
|
recordInboundSession: testTurn.recordInboundSession,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
|
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Telegram plugin module implements button types behavior.
|
// Telegram plugin module implements button types behavior.
|
||||||
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||||
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
import { reduceLegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
import {
|
import {
|
||||||
isMessagePresentationInteractiveBlock,
|
isMessagePresentationInteractiveBlock,
|
||||||
normalizeMessagePresentation,
|
normalizeMessagePresentation,
|
||||||
normalizeInteractiveReply,
|
normalizeLegacyInteractiveReply,
|
||||||
resolveMessagePresentationButtonAction,
|
resolveMessagePresentationButtonAction,
|
||||||
type InteractiveReply,
|
type LegacyInteractiveReply,
|
||||||
type MessagePresentation,
|
type MessagePresentation,
|
||||||
type MessagePresentationButton,
|
type MessagePresentationButton,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
@@ -100,9 +100,9 @@ function chunkInteractiveButtons(
|
|||||||
* @deprecated Use buildTelegramPresentationButtons with MessagePresentation.
|
* @deprecated Use buildTelegramPresentationButtons with MessagePresentation.
|
||||||
*/
|
*/
|
||||||
function buildTelegramInteractiveButtons(
|
function buildTelegramInteractiveButtons(
|
||||||
interactive?: InteractiveReply,
|
interactive?: LegacyInteractiveReply,
|
||||||
): TelegramInlineButtons | undefined {
|
): TelegramInlineButtons | undefined {
|
||||||
const rows = reduceInteractiveReply(
|
const rows = reduceLegacyInteractiveReply(
|
||||||
interactive,
|
interactive,
|
||||||
[] as TelegramInlineButton[][],
|
[] as TelegramInlineButton[][],
|
||||||
(state, block) => {
|
(state, block) => {
|
||||||
@@ -159,7 +159,7 @@ export function resolveTelegramInlineButtons(params: {
|
|||||||
}): TelegramInlineButtons | undefined {
|
}): TelegramInlineButtons | undefined {
|
||||||
return (
|
return (
|
||||||
params.buttons ??
|
params.buttons ??
|
||||||
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) ??
|
buildTelegramInteractiveButtons(normalizeLegacyInteractiveReply(params.interactive)) ??
|
||||||
buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation))
|
buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Telegram plugin module implements interactive fallback behavior.
|
// Telegram plugin module implements interactive fallback behavior.
|
||||||
import {
|
import {
|
||||||
adaptMessagePresentationForChannel,
|
adaptMessagePresentationForChannel,
|
||||||
interactiveReplyToPresentation,
|
legacyInteractiveReplyToPresentation,
|
||||||
isMessagePresentationInteractiveBlock,
|
isMessagePresentationInteractiveBlock,
|
||||||
normalizeMessagePresentation,
|
normalizeMessagePresentation,
|
||||||
normalizeInteractiveReply,
|
normalizeLegacyInteractiveReply,
|
||||||
renderMessagePresentationFallbackText,
|
renderMessagePresentationFallbackText,
|
||||||
resolveInteractiveTextFallback,
|
resolveLegacyInteractiveTextFallback,
|
||||||
type MessagePresentation,
|
type MessagePresentation,
|
||||||
type MessagePresentationInteractiveBlock,
|
type MessagePresentationInteractiveBlock,
|
||||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||||
@@ -122,7 +122,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
|
|||||||
capabilities: TELEGRAM_PRESENTATION_CAPABILITIES,
|
capabilities: TELEGRAM_PRESENTATION_CAPABILITIES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const interactive = normalizeInteractiveReply(payload.interactive);
|
const interactive = normalizeLegacyInteractiveReply(payload.interactive);
|
||||||
const existingButtons = resolveTelegramInlineButtons({
|
const existingButtons = resolveTelegramInlineButtons({
|
||||||
buttons: telegramData?.buttons,
|
buttons: telegramData?.buttons,
|
||||||
interactive,
|
interactive,
|
||||||
@@ -141,7 +141,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload):
|
|||||||
presentation: { ...presentation, blocks: fallbackBlocks },
|
presentation: { ...presentation, blocks: fallbackBlocks },
|
||||||
});
|
});
|
||||||
const currentText =
|
const currentText =
|
||||||
resolveInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
|
resolveLegacyInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
|
||||||
const hasFallback =
|
const hasFallback =
|
||||||
fallbackText.length > 0 &&
|
fallbackText.length > 0 &&
|
||||||
(currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`));
|
(currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`));
|
||||||
@@ -168,8 +168,8 @@ export function resolveTelegramInteractiveTextFallback(params: {
|
|||||||
interactive?: unknown;
|
interactive?: unknown;
|
||||||
presentation?: unknown;
|
presentation?: unknown;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const interactive = normalizeInteractiveReply(params.interactive);
|
const interactive = normalizeLegacyInteractiveReply(params.interactive);
|
||||||
const text = resolveInteractiveTextFallback({
|
const text = resolveLegacyInteractiveTextFallback({
|
||||||
text: params.text ?? undefined,
|
text: params.text ?? undefined,
|
||||||
interactive,
|
interactive,
|
||||||
});
|
});
|
||||||
@@ -189,7 +189,7 @@ export function resolveTelegramInteractiveTextFallback(params: {
|
|||||||
if (!interactive) {
|
if (!interactive) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
const interactivePresentation = interactiveReplyToPresentation(interactive);
|
const interactivePresentation = legacyInteractiveReplyToPresentation(interactive);
|
||||||
if (!interactivePresentation) {
|
if (!interactivePresentation) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Telegram plugin module implements voice behavior.
|
// Telegram plugin module implements voice behavior.
|
||||||
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||||
|
|
||||||
function resolveTelegramVoiceDecision(opts: {
|
function resolveTelegramVoiceDecision(opts: {
|
||||||
wantsVoice: boolean;
|
wantsVoice: boolean;
|
||||||
@@ -9,7 +9,7 @@ function resolveTelegramVoiceDecision(opts: {
|
|||||||
if (!opts.wantsVoice) {
|
if (!opts.wantsVoice) {
|
||||||
return { useVoice: false };
|
return { useVoice: false };
|
||||||
}
|
}
|
||||||
if (isVoiceCompatibleAudio(opts)) {
|
if (isVoiceMessageCompatibleAudio(opts)) {
|
||||||
return { useVoice: true };
|
return { useVoice: true };
|
||||||
}
|
}
|
||||||
const contentType = opts.contentType ?? "unknown";
|
const contentType = opts.contentType ?? "unknown";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Tlon plugin entrypoint registers its OpenClaw integration.
|
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
|
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||||
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||||
@@ -502,7 +503,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
bodyWithAttachments = mediaLines + "\n" + messageText;
|
bodyWithAttachments = mediaLines + "\n" + messageText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
|
||||||
channel: "Tlon",
|
channel: "Tlon",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -559,10 +560,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
cfg,
|
cfg,
|
||||||
route.agentId,
|
route.agentId,
|
||||||
).responsePrefix;
|
).responsePrefix;
|
||||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
const humanDelay = resolveHumanDelayConfig(cfg, route.agentId);
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
const deliveryTarget = isGroup ? groupChannel : senderShip;
|
const deliveryTarget = isGroup ? groupChannel : senderShip;
|
||||||
|
|
||||||
const prepareReplyPayload = (payload: ReplyPayload): ReplyPayload => {
|
const prepareReplyPayload = (payload: ReplyPayload): ReplyPayload => {
|
||||||
@@ -596,17 +594,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
|||||||
runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
|
runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
await core.channel.inbound.dispatchReply({
|
await core.channel.inbound.dispatch({
|
||||||
channel: "tlon",
|
channel: "tlon",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
cfg,
|
cfg,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
preparePayload: prepareReplyPayload,
|
preparePayload: prepareReplyPayload,
|
||||||
durable: deliveryTarget
|
durable: deliveryTarget
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* resolves agent routes, and handles replies.
|
* resolves agent routes, and handles replies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
@@ -75,11 +76,10 @@ async function processTwitchMessage(params: {
|
|||||||
});
|
});
|
||||||
const senderId = message.userId ?? message.username;
|
const senderId = message.userId ?? message.username;
|
||||||
const fromLabel = message.displayName ?? message.username;
|
const fromLabel = message.displayName ?? message.username;
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = createChannelInboundEnvelopeBuilder({ cfg, route })({
|
||||||
channel: "Twitch",
|
channel: "Twitch",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: input.timestamp,
|
timestamp: input.timestamp,
|
||||||
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
|
||||||
body: input.rawText,
|
body: input.rawText,
|
||||||
});
|
});
|
||||||
const ctxPayload = core.channel.inbound.buildContext({
|
const ctxPayload = core.channel.inbound.buildContext({
|
||||||
@@ -113,9 +113,6 @@ async function processTwitchMessage(params: {
|
|||||||
commandBody: input.textForCommands,
|
commandBody: input.textForCommands,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
@@ -125,13 +122,8 @@ async function processTwitchMessage(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
durable: () => ({
|
durable: () => ({
|
||||||
to: `twitch:channel:${message.channel}`,
|
to: `twitch:channel:${message.channel}`,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type AckReactionHandle,
|
type AckReactionHandle,
|
||||||
} from "openclaw/plugin-sdk/channel-feedback";
|
} from "openclaw/plugin-sdk/channel-feedback";
|
||||||
import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound";
|
import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
|
||||||
import {
|
import {
|
||||||
createInternalHookEvent,
|
createInternalHookEvent,
|
||||||
deriveInboundMessageHookContext,
|
deriveInboundMessageHookContext,
|
||||||
@@ -544,12 +543,11 @@ export async function processMessage(params: {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
resolveTurn: () => ({
|
resolveTurn: () => ({
|
||||||
|
cfg: params.cfg,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
accountId: params.route.accountId,
|
accountId: params.route.accountId,
|
||||||
routeSessionKey: params.route.sessionKey,
|
route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey },
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession,
|
|
||||||
record: {
|
record: {
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
params.replyLogger.warn(
|
params.replyLogger.warn(
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export {
|
|||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
resolveClientIp,
|
resolveClientIp,
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
|
||||||
resolveOpenProviderRuntimeGroupPolicy,
|
resolveOpenProviderRuntimeGroupPolicy,
|
||||||
resolveWebhookPath,
|
resolveWebhookPath,
|
||||||
resolveWebhookTargetWithAuthOrRejectSync,
|
resolveWebhookTargetWithAuthOrRejectSync,
|
||||||
|
|||||||
@@ -106,14 +106,6 @@ function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean):
|
|||||||
describe("Zalo polling media replies", () => {
|
describe("Zalo polling media replies", () => {
|
||||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||||
const recordInboundSessionMock = vi.fn(async () => undefined);
|
const recordInboundSessionMock = vi.fn(async () => undefined);
|
||||||
const resolveAgentRouteMock = vi.fn(() => ({
|
|
||||||
agentId: "main",
|
|
||||||
channel: "zalo",
|
|
||||||
accountId: "acct-zalo-polling-media",
|
|
||||||
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
|
||||||
mainSessionKey: "agent:main:main",
|
|
||||||
matchedBy: "default",
|
|
||||||
}));
|
|
||||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -143,10 +135,6 @@ describe("Zalo polling media replies", () => {
|
|||||||
);
|
);
|
||||||
setLifecycleRuntimeCore(
|
setLifecycleRuntimeCore(
|
||||||
{
|
{
|
||||||
routing: {
|
|
||||||
resolveAgentRoute:
|
|
||||||
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
||||||
},
|
|
||||||
reply: {
|
reply: {
|
||||||
finalizeInboundContext:
|
finalizeInboundContext:
|
||||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||||
|
|||||||
@@ -21,14 +21,6 @@ describe("Zalo reply-once lifecycle", () => {
|
|||||||
const recordInboundSessionMock = vi.fn(
|
const recordInboundSessionMock = vi.fn(
|
||||||
async (_input: { sessionKey?: string; ctx?: Record<string, unknown> }) => undefined,
|
async (_input: { sessionKey?: string; ctx?: Record<string, unknown> }) => undefined,
|
||||||
);
|
);
|
||||||
const resolveAgentRouteMock = vi.fn(() => ({
|
|
||||||
agentId: "main",
|
|
||||||
channel: "zalo",
|
|
||||||
accountId: "acct-zalo-lifecycle",
|
|
||||||
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
|
||||||
mainSessionKey: "agent:main:main",
|
|
||||||
matchedBy: "default",
|
|
||||||
}));
|
|
||||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -38,10 +30,6 @@ describe("Zalo reply-once lifecycle", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetLifecycleTestState();
|
await resetLifecycleTestState();
|
||||||
setLifecycleRuntimeCore({
|
setLifecycleRuntimeCore({
|
||||||
routing: {
|
|
||||||
resolveAgentRoute:
|
|
||||||
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
||||||
},
|
|
||||||
reply: {
|
reply: {
|
||||||
finalizeInboundContext:
|
finalizeInboundContext:
|
||||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||||
@@ -60,10 +48,17 @@ describe("Zalo reply-once lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function createReplyOnceMonitorSetup() {
|
function createReplyOnceMonitorSetup() {
|
||||||
return createLifecycleMonitorSetup({
|
const setup = createLifecycleMonitorSetup({
|
||||||
accountId: "acct-zalo-lifecycle",
|
accountId: "acct-zalo-lifecycle",
|
||||||
dmPolicy: "open",
|
dmPolicy: "open",
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
...setup,
|
||||||
|
config: {
|
||||||
|
...setup.config,
|
||||||
|
session: { dmScope: "per-channel-peer" as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireRecordInboundSessionArgs() {
|
function requireRecordInboundSessionArgs() {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// Zalo plugin module implements monitor behavior.
|
// Zalo plugin module implements monitor behavior.
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||||
import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound";
|
import {
|
||||||
|
formatInboundMediaUnavailableText,
|
||||||
|
resolveChannelInboundRouteEnvelope,
|
||||||
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||||
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
||||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
|
||||||
import {
|
import {
|
||||||
deliverTextOrMediaReply,
|
deliverTextOrMediaReply,
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
type OutboundReplyPayload,
|
type OutboundReplyPayload,
|
||||||
} from "openclaw/plugin-sdk/reply-payload";
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
import { sleepWithAbort, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
|
import { sleepWithAbort, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
|
||||||
@@ -568,7 +570,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
|||||||
const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
|
const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
|
||||||
const agentBody = agentBodyOverride ?? rawBody;
|
const agentBody = agentBodyOverride ?? rawBody;
|
||||||
|
|
||||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -576,8 +578,6 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
|||||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||||
id: chatId,
|
id: chatId,
|
||||||
},
|
},
|
||||||
runtime: core.channel,
|
|
||||||
sessionStore: config.session?.store,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -591,7 +591,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
|||||||
|
|
||||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
const timestamp = resolveZaloTimestampMs(date);
|
const timestamp = resolveZaloTimestampMs(date);
|
||||||
const { storePath, body } = buildEnvelope({
|
const body = buildEnvelope({
|
||||||
channel: "Zalo",
|
channel: "Zalo",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -673,17 +673,12 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await core.channel.inbound.dispatchReply({
|
await core.channel.inbound.dispatch({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
agentId: route.agentId,
|
route: { agentId: route.agentId, sessionKey: route.sessionKey },
|
||||||
routeSessionKey: route.sessionKey,
|
|
||||||
storePath,
|
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher:
|
|
||||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
||||||
delivery: {
|
delivery: {
|
||||||
preparePayload: (payload) =>
|
preparePayload: (payload) =>
|
||||||
prepareZaloDurableReplyPayload({
|
prepareZaloDurableReplyPayload({
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export {
|
|||||||
isNumericTargetId,
|
isNumericTargetId,
|
||||||
sendPayloadWithChunkedTextAndMedia,
|
sendPayloadWithChunkedTextAndMedia,
|
||||||
} from "./runtime-support.js";
|
} from "./runtime-support.js";
|
||||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./runtime-support.js";
|
|
||||||
export { waitForAbortSignal } from "./runtime-support.js";
|
export { waitForAbortSignal } from "./runtime-support.js";
|
||||||
export {
|
export {
|
||||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user