mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +00:00
refactor(channels): route inbound turns through kernel
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
|||||||
resolveEnvelopeFormatOptions,
|
resolveEnvelopeFormatOptions,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||||
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
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 { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
@@ -270,83 +270,97 @@ export async function dispatchDiscordComponentEvent(params: {
|
|||||||
startId: params.replyToId,
|
startId: params.replyToId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await runPreparedInboundReplyTurn({
|
await runInboundReplyTurn({
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId,
|
accountId,
|
||||||
routeSessionKey: sessionKey,
|
raw: interaction,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession,
|
id: interaction.id,
|
||||||
record: {
|
rawText: ctxPayload.RawBody ?? "",
|
||||||
updateLastRoute: interactionCtx.isDirectMessage
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
? {
|
textForCommands: ctxPayload.CommandBody,
|
||||||
sessionKey: route.mainSessionKey,
|
raw: interaction,
|
||||||
channel: "discord",
|
}),
|
||||||
to:
|
resolveTurn: () => ({
|
||||||
resolveDiscordComponentOriginatingTo(interactionCtx) ??
|
channel: "discord",
|
||||||
`user:${interactionCtx.userId}`,
|
accountId,
|
||||||
accountId,
|
routeSessionKey: sessionKey,
|
||||||
mainDmOwnerPin: pinnedMainDmOwner
|
storePath,
|
||||||
? {
|
ctxPayload,
|
||||||
ownerRecipient: pinnedMainDmOwner,
|
recordInboundSession,
|
||||||
senderRecipient: interactionCtx.userId,
|
record: {
|
||||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
updateLastRoute: interactionCtx.isDirectMessage
|
||||||
logVerbose(
|
? {
|
||||||
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
sessionKey: route.mainSessionKey,
|
||||||
);
|
channel: "discord",
|
||||||
},
|
to:
|
||||||
}
|
resolveDiscordComponentOriginatingTo(interactionCtx) ??
|
||||||
: undefined,
|
`user:${interactionCtx.userId}`,
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onRecordError: (err) => {
|
|
||||||
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runDispatch: () =>
|
|
||||||
dispatchReplyWithBufferedBlockDispatcher({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg: ctx.cfg,
|
|
||||||
replyOptions: { onModelSelected },
|
|
||||||
dispatcherOptions: {
|
|
||||||
...replyPipeline,
|
|
||||||
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
|
|
||||||
deliver: async (payload) => {
|
|
||||||
const replyToId = replyReference.use();
|
|
||||||
await deliverDiscordReply({
|
|
||||||
cfg: ctx.cfg,
|
|
||||||
replies: [payload],
|
|
||||||
target: deliverTarget,
|
|
||||||
token,
|
|
||||||
accountId,
|
|
||||||
rest: interaction.client.rest,
|
|
||||||
runtime,
|
|
||||||
replyToId,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
|
|
||||||
cfg: ctx.cfg,
|
|
||||||
discordConfig: ctx.discordConfig,
|
|
||||||
accountId,
|
accountId,
|
||||||
}),
|
mainDmOwnerPin: pinnedMainDmOwner
|
||||||
tableMode,
|
? {
|
||||||
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
|
ownerRecipient: pinnedMainDmOwner,
|
||||||
mediaLocalRoots,
|
senderRecipient: interactionCtx.userId,
|
||||||
});
|
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||||
replyReference.markSent();
|
logVerbose(
|
||||||
},
|
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||||
onReplyStart: async () => {
|
);
|
||||||
try {
|
},
|
||||||
const { sendTyping } = await loadTypingRuntime();
|
}
|
||||||
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
|
: undefined,
|
||||||
} catch (err) {
|
}
|
||||||
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
|
: undefined,
|
||||||
}
|
onRecordError: (err) => {
|
||||||
},
|
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
|
||||||
onError: (err) => {
|
|
||||||
logError(`discord component dispatch failed: ${String(err)}`);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
runDispatch: () =>
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
replyOptions: { onModelSelected },
|
||||||
|
dispatcherOptions: {
|
||||||
|
...replyPipeline,
|
||||||
|
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
|
||||||
|
deliver: async (payload) => {
|
||||||
|
const replyToId = replyReference.use();
|
||||||
|
await deliverDiscordReply({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
replies: [payload],
|
||||||
|
target: deliverTarget,
|
||||||
|
token,
|
||||||
|
accountId,
|
||||||
|
rest: interaction.client.rest,
|
||||||
|
runtime,
|
||||||
|
replyToId,
|
||||||
|
replyToMode,
|
||||||
|
textLimit,
|
||||||
|
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
discordConfig: ctx.discordConfig,
|
||||||
|
accountId,
|
||||||
|
}),
|
||||||
|
tableMode,
|
||||||
|
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
|
||||||
|
mediaLocalRoots,
|
||||||
|
});
|
||||||
|
replyReference.markSent();
|
||||||
|
},
|
||||||
|
onReplyStart: async () => {
|
||||||
|
try {
|
||||||
|
const { sendTyping } = await loadTypingRuntime();
|
||||||
|
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
logError(`discord component dispatch failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
|
|||||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import {
|
import {
|
||||||
hasFinalInboundReplyDispatch,
|
hasFinalInboundReplyDispatch,
|
||||||
runPreparedInboundReplyTurn,
|
runInboundReplyTurn,
|
||||||
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
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";
|
||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||||
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
|
|
||||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||||
@@ -480,109 +479,135 @@ export async function processDiscordMessage(
|
|||||||
await settleDispatchBeforeStart();
|
await settleDispatchBeforeStart();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preparedResult = await runPreparedInboundReplyTurn({
|
const preparedResult = await runInboundReplyTurn({
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: persistedSessionKey,
|
raw: ctx,
|
||||||
storePath: turn.storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession,
|
id: message.id,
|
||||||
record: turn.record,
|
timestamp: message.timestamp ? Date.parse(message.timestamp) : undefined,
|
||||||
onPreDispatchFailure: settleDispatchBeforeStart,
|
rawText: text,
|
||||||
runDispatch: () =>
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
dispatchInboundMessage({
|
textForCommands: ctxPayload.CommandBody,
|
||||||
ctx: ctxPayload,
|
raw: message,
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions: {
|
|
||||||
...replyOptions,
|
|
||||||
abortSignal,
|
|
||||||
skillFilter: channelConfig?.skills,
|
|
||||||
sourceReplyDeliveryMode,
|
|
||||||
disableBlockStreaming: sourceRepliesAreToolOnly
|
|
||||||
? true
|
|
||||||
: (draftPreview.disableBlockStreamingForDraft ??
|
|
||||||
(typeof resolvedBlockStreamingEnabled === "boolean"
|
|
||||||
? !resolvedBlockStreamingEnabled
|
|
||||||
: undefined)),
|
|
||||||
onPartialReply: draftPreview.draftStream
|
|
||||||
? (payload) => draftPreview.updateFromPartial(payload.text)
|
|
||||||
: undefined,
|
|
||||||
onAssistantMessageStart: draftPreview.draftStream
|
|
||||||
? draftPreview.handleAssistantMessageBoundary
|
|
||||||
: undefined,
|
|
||||||
onReasoningEnd: draftPreview.draftStream
|
|
||||||
? draftPreview.handleAssistantMessageBoundary
|
|
||||||
: undefined,
|
|
||||||
onModelSelected,
|
|
||||||
suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled
|
|
||||||
? true
|
|
||||||
: undefined,
|
|
||||||
onReasoningStream: async () => {
|
|
||||||
await statusReactions.setThinking();
|
|
||||||
},
|
|
||||||
onToolStart: async (payload) => {
|
|
||||||
if (isProcessAborted(abortSignal)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await statusReactions.setTool(payload.name);
|
|
||||||
draftPreview.pushToolProgress(
|
|
||||||
payload.name ? `tool: ${payload.name}` : "tool running",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onItemEvent: async (payload) => {
|
|
||||||
draftPreview.pushToolProgress(
|
|
||||||
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPlanUpdate: async (payload) => {
|
|
||||||
if (payload.phase !== "update") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
draftPreview.pushToolProgress(
|
|
||||||
payload.explanation ?? payload.steps?.[0] ?? "planning",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onApprovalEvent: async (payload) => {
|
|
||||||
if (payload.phase !== "requested") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
draftPreview.pushToolProgress(
|
|
||||||
payload.command ? `approval: ${payload.command}` : "approval requested",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onCommandOutput: async (payload) => {
|
|
||||||
if (payload.phase !== "end") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
draftPreview.pushToolProgress(
|
|
||||||
payload.name
|
|
||||||
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
|
|
||||||
: payload.title,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPatchSummary: async (payload) => {
|
|
||||||
if (payload.phase !== "end") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
draftPreview.pushToolProgress(payload.summary ?? payload.title ?? "patch applied");
|
|
||||||
},
|
|
||||||
onCompactionStart: async () => {
|
|
||||||
if (isProcessAborted(abortSignal)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await statusReactions.setCompacting();
|
|
||||||
},
|
|
||||||
onCompactionEnd: async () => {
|
|
||||||
if (isProcessAborted(abortSignal)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statusReactions.cancelPending();
|
|
||||||
await statusReactions.setThinking();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
resolveTurn: () => ({
|
||||||
|
channel: "discord",
|
||||||
|
accountId: route.accountId,
|
||||||
|
routeSessionKey: persistedSessionKey,
|
||||||
|
storePath: turn.storePath,
|
||||||
|
ctxPayload,
|
||||||
|
recordInboundSession,
|
||||||
|
record: turn.record,
|
||||||
|
history: {
|
||||||
|
isGroup: isGuildMessage,
|
||||||
|
historyKey: messageChannelId,
|
||||||
|
historyMap: guildHistories,
|
||||||
|
limit: historyLimit,
|
||||||
|
},
|
||||||
|
onPreDispatchFailure: settleDispatchBeforeStart,
|
||||||
|
runDispatch: () =>
|
||||||
|
dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
abortSignal,
|
||||||
|
skillFilter: channelConfig?.skills,
|
||||||
|
sourceReplyDeliveryMode,
|
||||||
|
disableBlockStreaming: sourceRepliesAreToolOnly
|
||||||
|
? true
|
||||||
|
: (draftPreview.disableBlockStreamingForDraft ??
|
||||||
|
(typeof resolvedBlockStreamingEnabled === "boolean"
|
||||||
|
? !resolvedBlockStreamingEnabled
|
||||||
|
: undefined)),
|
||||||
|
onPartialReply: draftPreview.draftStream
|
||||||
|
? (payload) => draftPreview.updateFromPartial(payload.text)
|
||||||
|
: undefined,
|
||||||
|
onAssistantMessageStart: draftPreview.draftStream
|
||||||
|
? draftPreview.handleAssistantMessageBoundary
|
||||||
|
: undefined,
|
||||||
|
onReasoningEnd: draftPreview.draftStream
|
||||||
|
? draftPreview.handleAssistantMessageBoundary
|
||||||
|
: undefined,
|
||||||
|
onModelSelected,
|
||||||
|
suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
onReasoningStream: async () => {
|
||||||
|
await statusReactions.setThinking();
|
||||||
|
},
|
||||||
|
onToolStart: async (payload) => {
|
||||||
|
if (isProcessAborted(abortSignal)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await statusReactions.setTool(payload.name);
|
||||||
|
draftPreview.pushToolProgress(
|
||||||
|
payload.name ? `tool: ${payload.name}` : "tool running",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onItemEvent: async (payload) => {
|
||||||
|
draftPreview.pushToolProgress(
|
||||||
|
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPlanUpdate: async (payload) => {
|
||||||
|
if (payload.phase !== "update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draftPreview.pushToolProgress(
|
||||||
|
payload.explanation ?? payload.steps?.[0] ?? "planning",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onApprovalEvent: async (payload) => {
|
||||||
|
if (payload.phase !== "requested") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draftPreview.pushToolProgress(
|
||||||
|
payload.command ? `approval: ${payload.command}` : "approval requested",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCommandOutput: async (payload) => {
|
||||||
|
if (payload.phase !== "end") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draftPreview.pushToolProgress(
|
||||||
|
payload.name
|
||||||
|
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
|
||||||
|
: payload.title,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPatchSummary: async (payload) => {
|
||||||
|
if (payload.phase !== "end") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draftPreview.pushToolProgress(
|
||||||
|
payload.summary ?? payload.title ?? "patch applied",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCompactionStart: async () => {
|
||||||
|
if (isProcessAborted(abortSignal)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await statusReactions.setCompacting();
|
||||||
|
},
|
||||||
|
onCompactionEnd: async () => {
|
||||||
|
if (isProcessAborted(abortSignal)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusReactions.cancelPending();
|
||||||
|
await statusReactions.setThinking();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
if (!preparedResult.dispatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
dispatchResult = preparedResult.dispatchResult;
|
dispatchResult = preparedResult.dispatchResult;
|
||||||
if (isProcessAborted(abortSignal)) {
|
if (isProcessAborted(abortSignal)) {
|
||||||
dispatchAborted = true;
|
dispatchAborted = true;
|
||||||
@@ -646,27 +671,14 @@ export async function processDiscordMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
|
const finalDispatchResult = dispatchResult;
|
||||||
if (isGuildMessage) {
|
if (!finalDispatchResult || !hasFinalInboundReplyDispatch(finalDispatchResult)) {
|
||||||
clearHistoryEntriesIfEnabled({
|
|
||||||
historyMap: guildHistories,
|
|
||||||
historyKey: messageChannelId,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
const finalCount = dispatchResult.counts.final;
|
const finalCount = finalDispatchResult.counts.final;
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isGuildMessage) {
|
|
||||||
clearHistoryEntriesIfEnabled({
|
|
||||||
historyMap: guildHistories,
|
|
||||||
historyKey: messageChannelId,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,39 @@ describe("broadcast dispatch", () => {
|
|||||||
saveMediaBuffer: mockSaveMediaBuffer,
|
saveMediaBuffer: mockSaveMediaBuffer,
|
||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
|
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
|
||||||
|
const input = await params.adapter.ingest(params.raw);
|
||||||
|
if (!input) {
|
||||||
|
return {
|
||||||
|
admission: { kind: "drop" as const, reason: "ingest-null" },
|
||||||
|
dispatched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const eventClass = {
|
||||||
|
kind: "message" as const,
|
||||||
|
canStartAgentTurn: true,
|
||||||
|
};
|
||||||
|
const turn = await params.adapter.resolveTurn(input, eventClass, {});
|
||||||
|
if (!("runDispatch" in turn)) {
|
||||||
|
throw new Error("feishu broadcast test runtime only supports prepared turns");
|
||||||
|
}
|
||||||
|
await turn.recordInboundSession({
|
||||||
|
storePath: turn.storePath,
|
||||||
|
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
||||||
|
ctx: turn.ctxPayload,
|
||||||
|
groupResolution: turn.record?.groupResolution,
|
||||||
|
createIfMissing: turn.record?.createIfMissing,
|
||||||
|
updateLastRoute: turn.record?.updateLastRoute,
|
||||||
|
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
admission: { kind: "dispatch" as const },
|
||||||
|
dispatched: true,
|
||||||
|
ctxPayload: turn.ctxPayload,
|
||||||
|
routeSessionKey: turn.routeSessionKey,
|
||||||
|
dispatchResult: await turn.runDispatch(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
runPrepared: vi.fn(
|
runPrepared: vi.fn(
|
||||||
async (turn: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
|
async (turn: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
|
||||||
await turn.recordInboundSession({
|
await turn.recordInboundSession({
|
||||||
|
|||||||
@@ -198,6 +198,16 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
|
|||||||
buildPairingReply: vi.fn(),
|
buildPairingReply: vi.fn(),
|
||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
|
run: vi.fn(async (params) => {
|
||||||
|
const input = await params.adapter.ingest(params.raw);
|
||||||
|
const turn = await params.adapter.resolveTurn(input, {
|
||||||
|
kind: "message",
|
||||||
|
canStartAgentTurn: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dispatchResult: await turn.runDispatch(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
runPrepared: vi.fn(async (params) => ({
|
runPrepared: vi.fn(async (params) => ({
|
||||||
dispatchResult: await params.runDispatch(),
|
dispatchResult: await params.runDispatch(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1312,31 +1312,46 @@ export async function handleFeishuMessage(params: {
|
|||||||
log(
|
log(
|
||||||
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
||||||
);
|
);
|
||||||
await core.channel.turn.runPrepared({
|
await core.channel.turn.run({
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: agentSessionKey,
|
raw: ctx,
|
||||||
storePath: agentStorePath,
|
adapter: {
|
||||||
ctxPayload: agentCtx,
|
ingest: () => ({
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
id: ctx.messageId,
|
||||||
record: agentRecord,
|
timestamp: messageCreateTimeMs,
|
||||||
onPreDispatchFailure: () =>
|
rawText: ctx.content,
|
||||||
core.channel.reply.settleReplyDispatcher({
|
textForAgent: agentCtx.BodyForAgent,
|
||||||
dispatcher,
|
textForCommands: agentCtx.CommandBody,
|
||||||
onSettled: () => markDispatchIdle(),
|
raw: ctx,
|
||||||
}),
|
}),
|
||||||
runDispatch: () =>
|
resolveTurn: () => ({
|
||||||
core.channel.reply.withReplyDispatcher({
|
channel: "feishu",
|
||||||
dispatcher,
|
accountId: route.accountId,
|
||||||
onSettled: () => markDispatchIdle(),
|
routeSessionKey: agentSessionKey,
|
||||||
run: () =>
|
storePath: agentStorePath,
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
ctxPayload: agentCtx,
|
||||||
ctx: agentCtx,
|
recordInboundSession: core.channel.session.recordInboundSession,
|
||||||
cfg,
|
record: agentRecord,
|
||||||
|
onPreDispatchFailure: () =>
|
||||||
|
core.channel.reply.settleReplyDispatcher({
|
||||||
dispatcher,
|
dispatcher,
|
||||||
replyOptions,
|
onSettled: () => markDispatchIdle(),
|
||||||
|
}),
|
||||||
|
runDispatch: () =>
|
||||||
|
core.channel.reply.withReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
run: () =>
|
||||||
|
core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: agentCtx,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
||||||
@@ -1356,24 +1371,39 @@ export async function handleFeishuMessage(params: {
|
|||||||
log(
|
log(
|
||||||
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
|
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
|
||||||
);
|
);
|
||||||
await core.channel.turn.runPrepared({
|
await core.channel.turn.run({
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: agentSessionKey,
|
raw: ctx,
|
||||||
storePath: agentStorePath,
|
adapter: {
|
||||||
ctxPayload: agentCtx,
|
ingest: () => ({
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
id: ctx.messageId,
|
||||||
record: agentRecord,
|
timestamp: messageCreateTimeMs,
|
||||||
runDispatch: () =>
|
rawText: ctx.content,
|
||||||
core.channel.reply.withReplyDispatcher({
|
textForAgent: agentCtx.BodyForAgent,
|
||||||
dispatcher: noopDispatcher,
|
textForCommands: agentCtx.CommandBody,
|
||||||
run: () =>
|
raw: ctx,
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
}),
|
||||||
ctx: agentCtx,
|
resolveTurn: () => ({
|
||||||
cfg,
|
channel: "feishu",
|
||||||
|
accountId: route.accountId,
|
||||||
|
routeSessionKey: agentSessionKey,
|
||||||
|
storePath: agentStorePath,
|
||||||
|
ctxPayload: agentCtx,
|
||||||
|
recordInboundSession: core.channel.session.recordInboundSession,
|
||||||
|
record: agentRecord,
|
||||||
|
runDispatch: () =>
|
||||||
|
core.channel.reply.withReplyDispatcher({
|
||||||
dispatcher: noopDispatcher,
|
dispatcher: noopDispatcher,
|
||||||
|
run: () =>
|
||||||
|
core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: agentCtx,
|
||||||
|
cfg,
|
||||||
|
dispatcher: noopDispatcher,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1445,49 +1475,66 @@ export async function handleFeishuMessage(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
||||||
const { dispatchResult } = await core.channel.turn.runPrepared({
|
const turnResult = await core.channel.turn.run({
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
raw: ctx,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
id: ctx.messageId,
|
||||||
record: {
|
timestamp: messageCreateTimeMs,
|
||||||
onRecordError: (err) => {
|
rawText: ctx.content,
|
||||||
log(
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
|
textForCommands: ctxPayload.CommandBody,
|
||||||
);
|
raw: ctx,
|
||||||
},
|
|
||||||
},
|
|
||||||
onPreDispatchFailure: () =>
|
|
||||||
core.channel.reply.settleReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => markDispatchIdle(),
|
|
||||||
}),
|
}),
|
||||||
runDispatch: () =>
|
resolveTurn: () => ({
|
||||||
core.channel.reply.withReplyDispatcher({
|
channel: "feishu",
|
||||||
dispatcher,
|
accountId: route.accountId,
|
||||||
onSettled: () => {
|
routeSessionKey: route.sessionKey,
|
||||||
markDispatchIdle();
|
storePath,
|
||||||
|
ctxPayload,
|
||||||
|
recordInboundSession: core.channel.session.recordInboundSession,
|
||||||
|
record: {
|
||||||
|
onRecordError: (err) => {
|
||||||
|
log(
|
||||||
|
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
run: () =>
|
history: {
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
isGroup,
|
||||||
ctx: ctxPayload,
|
historyKey,
|
||||||
cfg,
|
historyMap: chatHistories,
|
||||||
|
limit: historyLimit,
|
||||||
|
},
|
||||||
|
onPreDispatchFailure: () =>
|
||||||
|
core.channel.reply.settleReplyDispatcher({
|
||||||
dispatcher,
|
dispatcher,
|
||||||
replyOptions,
|
onSettled: () => markDispatchIdle(),
|
||||||
|
}),
|
||||||
|
runDispatch: () =>
|
||||||
|
core.channel.reply.withReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => {
|
||||||
|
markDispatchIdle();
|
||||||
|
},
|
||||||
|
run: () =>
|
||||||
|
core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { queuedFinal, counts } = dispatchResult;
|
if (!turnResult.dispatched) {
|
||||||
|
return;
|
||||||
if (isGroup && historyKey && chatHistories) {
|
|
||||||
clearHistoryEntriesIfEnabled({
|
|
||||||
historyMap: chatHistories,
|
|
||||||
historyKey,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const { dispatchResult } = turnResult;
|
||||||
|
const { queuedFinal, counts } = dispatchResult;
|
||||||
|
|
||||||
log(
|
log(
|
||||||
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
||||||
|
|||||||
@@ -134,6 +134,26 @@ function createTestRuntime(overrides?: {
|
|||||||
recordInboundSession,
|
recordInboundSession,
|
||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
|
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
|
||||||
|
const input = await params.adapter.ingest(params.raw);
|
||||||
|
if (!input) {
|
||||||
|
return {
|
||||||
|
admission: { kind: "drop" as const, reason: "ingest-null" },
|
||||||
|
dispatched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const eventClass = {
|
||||||
|
kind: "message" as const,
|
||||||
|
canStartAgentTurn: true,
|
||||||
|
};
|
||||||
|
const turn = await params.adapter.resolveTurn(input, eventClass, {});
|
||||||
|
if (!("runDispatch" in turn)) {
|
||||||
|
throw new Error("feishu comment test runtime only supports prepared turns");
|
||||||
|
}
|
||||||
|
return await runPrepared(
|
||||||
|
turn as Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0],
|
||||||
|
);
|
||||||
|
}) as unknown as PluginRuntime["channel"]["turn"]["run"],
|
||||||
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
|
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
|||||||
@@ -241,42 +241,58 @@ export async function handleFeishuCommentEvent(
|
|||||||
`feishu[${account.accountId}]: dispatching drive comment to agent ` +
|
`feishu[${account.accountId}]: dispatching drive comment to agent ` +
|
||||||
`(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
|
`(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
|
||||||
);
|
);
|
||||||
const { dispatchResult } = await core.channel.turn.runPrepared({
|
const turnResult = await core.channel.turn.run({
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: commentSessionKey,
|
raw: turn,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
id: turn.messageId,
|
||||||
record: {
|
timestamp: parseTimestampMs(turn.timestamp),
|
||||||
onRecordError: (err) => {
|
rawText: ctxPayload.RawBody ?? "",
|
||||||
error(
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
|
textForCommands: ctxPayload.CommandBody,
|
||||||
);
|
raw: turn,
|
||||||
},
|
}),
|
||||||
},
|
resolveTurn: () => ({
|
||||||
onPreDispatchFailure: async () => {
|
channel: "feishu",
|
||||||
dispatchSettledBeforeStart = true;
|
accountId: route.accountId,
|
||||||
await core.channel.reply.settleReplyDispatcher({
|
routeSessionKey: commentSessionKey,
|
||||||
dispatcher,
|
storePath,
|
||||||
onSettled: () => {
|
ctxPayload,
|
||||||
markRunComplete();
|
recordInboundSession: core.channel.session.recordInboundSession,
|
||||||
markDispatchIdle();
|
record: {
|
||||||
|
onRecordError: (err) => {
|
||||||
|
error(
|
||||||
|
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
onPreDispatchFailure: async () => {
|
||||||
},
|
dispatchSettledBeforeStart = true;
|
||||||
runDispatch: () =>
|
await core.channel.reply.settleReplyDispatcher({
|
||||||
core.channel.reply.withReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
run: () =>
|
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg: effectiveCfg,
|
|
||||||
dispatcher,
|
dispatcher,
|
||||||
replyOptions,
|
onSettled: () => {
|
||||||
|
markRunComplete();
|
||||||
|
markDispatchIdle();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
runDispatch: () =>
|
||||||
|
core.channel.reply.withReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
run: () =>
|
||||||
|
core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: effectiveCfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
|
||||||
const queuedFinal = dispatchResult?.queuedFinal ?? false;
|
const queuedFinal = dispatchResult?.queuedFinal ?? false;
|
||||||
const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
|
const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
|
||||||
log(
|
log(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
|
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
|
||||||
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||||
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
@@ -435,59 +435,74 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await runPreparedInboundReplyTurn({
|
await runInboundReplyTurn({
|
||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
accountId: decision.route.accountId,
|
accountId: decision.route.accountId,
|
||||||
routeSessionKey: decision.route.sessionKey,
|
raw: decision,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession,
|
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
|
||||||
record: {
|
timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined,
|
||||||
updateLastRoute:
|
rawText: ctxPayload.RawBody ?? "",
|
||||||
!decision.isGroup && updateTarget
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
? {
|
textForCommands: ctxPayload.CommandBody,
|
||||||
sessionKey: decision.route.mainSessionKey,
|
raw: decision,
|
||||||
channel: "imessage",
|
|
||||||
to: updateTarget,
|
|
||||||
accountId: decision.route.accountId,
|
|
||||||
mainDmOwnerPin:
|
|
||||||
pinnedMainDmOwner && decision.senderNormalized
|
|
||||||
? {
|
|
||||||
ownerRecipient: pinnedMainDmOwner,
|
|
||||||
senderRecipient: decision.senderNormalized,
|
|
||||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
|
||||||
logVerbose(
|
|
||||||
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onRecordError: (err) => {
|
|
||||||
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
history: {
|
|
||||||
isGroup: decision.isGroup,
|
|
||||||
historyKey: decision.historyKey,
|
|
||||||
historyMap: groupHistories,
|
|
||||||
limit: historyLimit,
|
|
||||||
},
|
|
||||||
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
|
|
||||||
runDispatch: () =>
|
|
||||||
dispatchInboundMessage({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions: {
|
|
||||||
disableBlockStreaming:
|
|
||||||
typeof accountInfo.config.blockStreaming === "boolean"
|
|
||||||
? !accountInfo.config.blockStreaming
|
|
||||||
: undefined,
|
|
||||||
onModelSelected,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
resolveTurn: () => ({
|
||||||
|
channel: "imessage",
|
||||||
|
accountId: decision.route.accountId,
|
||||||
|
routeSessionKey: decision.route.sessionKey,
|
||||||
|
storePath,
|
||||||
|
ctxPayload,
|
||||||
|
recordInboundSession,
|
||||||
|
record: {
|
||||||
|
updateLastRoute:
|
||||||
|
!decision.isGroup && updateTarget
|
||||||
|
? {
|
||||||
|
sessionKey: decision.route.mainSessionKey,
|
||||||
|
channel: "imessage",
|
||||||
|
to: updateTarget,
|
||||||
|
accountId: decision.route.accountId,
|
||||||
|
mainDmOwnerPin:
|
||||||
|
pinnedMainDmOwner && decision.senderNormalized
|
||||||
|
? {
|
||||||
|
ownerRecipient: pinnedMainDmOwner,
|
||||||
|
senderRecipient: decision.senderNormalized,
|
||||||
|
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||||
|
logVerbose(
|
||||||
|
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onRecordError: (err) => {
|
||||||
|
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
isGroup: decision.isGroup,
|
||||||
|
historyKey: decision.historyKey,
|
||||||
|
historyMap: groupHistories,
|
||||||
|
limit: historyLimit,
|
||||||
|
},
|
||||||
|
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
|
||||||
|
runDispatch: () =>
|
||||||
|
dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof accountInfo.config.blockStreaming === "boolean"
|
||||||
|
? !accountInfo.config.blockStreaming
|
||||||
|
: undefined,
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export async function monitorLineProvider(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const core = getLineRuntime();
|
const core = getLineRuntime();
|
||||||
const { dispatchResult } = await core.channel.turn.run({
|
const turnResult = await core.channel.turn.run({
|
||||||
channel: "line",
|
channel: "line",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
raw: ctx,
|
raw: ctx,
|
||||||
@@ -316,6 +316,7 @@ export async function monitorLineProvider(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
|
||||||
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
|
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
|
||||||
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
|
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,28 @@ export function createMatrixHandlerTestHarness(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const run = vi.fn(
|
||||||
|
async (params: Parameters<MatrixMonitorHandlerParams["core"]["channel"]["turn"]["run"]>[0]) => {
|
||||||
|
const input = await params.adapter.ingest(params.raw);
|
||||||
|
if (!input) {
|
||||||
|
return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
|
||||||
|
}
|
||||||
|
const eventClass = (await params.adapter.classify?.(input)) ?? {
|
||||||
|
kind: "message" as const,
|
||||||
|
canStartAgentTurn: true,
|
||||||
|
};
|
||||||
|
const preflightResult = await params.adapter.preflight?.(input, eventClass);
|
||||||
|
const preflight =
|
||||||
|
preflightResult && "kind" in preflightResult
|
||||||
|
? { admission: preflightResult }
|
||||||
|
: (preflightResult ?? {});
|
||||||
|
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
||||||
|
if ("runDispatch" in turn) {
|
||||||
|
return await runPrepared(turn);
|
||||||
|
}
|
||||||
|
throw new Error("matrix test helper only supports prepared turn dispatch");
|
||||||
|
},
|
||||||
|
);
|
||||||
const dmPolicy = options.dmPolicy ?? "open";
|
const dmPolicy = options.dmPolicy ?? "open";
|
||||||
const allowFrom = options.allowFrom ?? (dmPolicy === "open" ? ["*"] : []);
|
const allowFrom = options.allowFrom ?? (dmPolicy === "open" ? ["*"] : []);
|
||||||
const cfgForHandler =
|
const cfgForHandler =
|
||||||
@@ -229,6 +251,7 @@ export function createMatrixHandlerTestHarness(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
|
run,
|
||||||
runPrepared,
|
runPrepared,
|
||||||
},
|
},
|
||||||
reactions: {
|
reactions: {
|
||||||
|
|||||||
@@ -1829,106 +1829,127 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
onIdle: typingCallbacks.onIdle,
|
onIdle: typingCallbacks.onIdle,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { dispatchResult } = await core.channel.turn.runPrepared({
|
const turnResult = await core.channel.turn.run({
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
accountId: _route.accountId,
|
accountId: _route.accountId,
|
||||||
routeSessionKey: _route.sessionKey,
|
raw: event,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
id: _messageId,
|
||||||
record: {
|
rawText: bodyText,
|
||||||
updateLastRoute: isDirectMessage
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
? {
|
textForCommands: ctxPayload.CommandBody,
|
||||||
sessionKey: _route.mainSessionKey,
|
raw: event,
|
||||||
channel: "matrix",
|
}),
|
||||||
to: `room:${roomId}`,
|
resolveTurn: () => ({
|
||||||
accountId: _route.accountId,
|
channel: "matrix",
|
||||||
|
accountId: _route.accountId,
|
||||||
|
routeSessionKey: _route.sessionKey,
|
||||||
|
storePath,
|
||||||
|
ctxPayload,
|
||||||
|
recordInboundSession: core.channel.session.recordInboundSession,
|
||||||
|
record: {
|
||||||
|
updateLastRoute: isDirectMessage
|
||||||
|
? {
|
||||||
|
sessionKey: _route.mainSessionKey,
|
||||||
|
channel: "matrix",
|
||||||
|
to: `room:${roomId}`,
|
||||||
|
accountId: _route.accountId,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onRecordError: (err) => {
|
||||||
|
logger.warn("failed updating session meta", {
|
||||||
|
error: String(err),
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onPreDispatchFailure: () =>
|
||||||
|
core.channel.reply.settleReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => {
|
||||||
|
markRunComplete();
|
||||||
|
markDispatchIdle();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
runDispatch: async () => {
|
||||||
|
if (
|
||||||
|
sharedDmContextNotice &&
|
||||||
|
markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)
|
||||||
|
) {
|
||||||
|
client
|
||||||
|
.sendMessage(roomId, {
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: sharedDmContextNotice,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logVerboseMessage(
|
||||||
|
`matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
: undefined,
|
|
||||||
onRecordError: (err) => {
|
return await core.channel.reply.withReplyDispatcher({
|
||||||
logger.warn("failed updating session meta", {
|
dispatcher,
|
||||||
error: String(err),
|
onSettled: () => {
|
||||||
storePath,
|
markDispatchIdle();
|
||||||
sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
|
},
|
||||||
});
|
run: async () => {
|
||||||
},
|
try {
|
||||||
},
|
return await core.channel.reply.dispatchReplyFromConfig({
|
||||||
onPreDispatchFailure: () =>
|
ctx: ctxPayload,
|
||||||
core.channel.reply.settleReplyDispatcher({
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
onSettled: () => {
|
replyOptions: {
|
||||||
markRunComplete();
|
...replyOptions,
|
||||||
markDispatchIdle();
|
skillFilter: roomConfig?.skills,
|
||||||
|
// Keep block streaming enabled when explicitly requested, even
|
||||||
|
// with draft previews on. The draft remains the live preview
|
||||||
|
// for the current assistant block, while block deliveries
|
||||||
|
// finalize completed blocks into their own preserved events.
|
||||||
|
disableBlockStreaming: !blockStreamingEnabled,
|
||||||
|
onPartialReply: draftStream
|
||||||
|
? (payload) => {
|
||||||
|
latestDraftFullText = payload.text ?? "";
|
||||||
|
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
|
||||||
|
updateDraftFromLatestFullText();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onBlockReplyQueued: draftStream
|
||||||
|
? (payload, context) => {
|
||||||
|
if (payload.isCompactionNotice === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueDraftBlockBoundary(payload, context);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
// Reset draft boundary bookkeeping on assistant message
|
||||||
|
// boundaries so post-tool blocks stream from a fresh
|
||||||
|
// cumulative payload (payload.text resets upstream).
|
||||||
|
onAssistantMessageStart: draftStream
|
||||||
|
? () => {
|
||||||
|
resetDraftBlockOffsets();
|
||||||
|
resetPreviewToolProgress();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
...buildPreviewToolProgressReplyOptions(),
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
markRunComplete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
runDispatch: async () => {
|
|
||||||
if (sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)) {
|
|
||||||
client
|
|
||||||
.sendMessage(roomId, {
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: sharedDmContextNotice,
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
logVerboseMessage(
|
|
||||||
`matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return await core.channel.reply.withReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => {
|
|
||||||
markDispatchIdle();
|
|
||||||
},
|
|
||||||
run: async () => {
|
|
||||||
try {
|
|
||||||
return await core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions: {
|
|
||||||
...replyOptions,
|
|
||||||
skillFilter: roomConfig?.skills,
|
|
||||||
// Keep block streaming enabled when explicitly requested, even
|
|
||||||
// with draft previews on. The draft remains the live preview
|
|
||||||
// for the current assistant block, while block deliveries
|
|
||||||
// finalize completed blocks into their own preserved events.
|
|
||||||
disableBlockStreaming: !blockStreamingEnabled,
|
|
||||||
onPartialReply: draftStream
|
|
||||||
? (payload) => {
|
|
||||||
latestDraftFullText = payload.text ?? "";
|
|
||||||
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
|
|
||||||
updateDraftFromLatestFullText();
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onBlockReplyQueued: draftStream
|
|
||||||
? (payload, context) => {
|
|
||||||
if (payload.isCompactionNotice === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
queueDraftBlockBoundary(payload, context);
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
// Reset draft boundary bookkeeping on assistant message
|
|
||||||
// boundaries so post-tool blocks stream from a fresh
|
|
||||||
// cumulative payload (payload.text resets upstream).
|
|
||||||
onAssistantMessageStart: draftStream
|
|
||||||
? () => {
|
|
||||||
resetDraftBlockOffsets();
|
|
||||||
resetPreviewToolProgress();
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
...buildPreviewToolProgressReplyOptions(),
|
|
||||||
onModelSelected,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
markRunComplete();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!turnResult.dispatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { dispatchResult } = turnResult;
|
||||||
const { queuedFinal, counts } = dispatchResult;
|
const { queuedFinal, counts } = dispatchResult;
|
||||||
if (finalReplyDeliveryFailed) {
|
if (finalReplyDeliveryFailed) {
|
||||||
if (retryableReplyDeliveryFailed) {
|
if (retryableReplyDeliveryFailed) {
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ import {
|
|||||||
buildAgentMediaPayload,
|
buildAgentMediaPayload,
|
||||||
buildModelsProviderData,
|
buildModelsProviderData,
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
|
||||||
createChannelPairingController,
|
createChannelPairingController,
|
||||||
createChannelReplyPipeline,
|
createChannelReplyPipeline,
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
@@ -1721,74 +1720,95 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
let dispatchSettledBeforeStart = false;
|
let dispatchSettledBeforeStart = false;
|
||||||
try {
|
try {
|
||||||
await core.channel.turn.runPrepared({
|
await core.channel.turn.run({
|
||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
raw: post,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
id: post.id ?? `${to}:${Date.now()}`,
|
||||||
record: {
|
timestamp: post.create_at ?? undefined,
|
||||||
updateLastRoute:
|
rawText,
|
||||||
kind === "direct"
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
? {
|
textForCommands: ctxPayload.CommandBody,
|
||||||
sessionKey: route.mainSessionKey,
|
raw: post,
|
||||||
channel: "mattermost",
|
}),
|
||||||
to,
|
resolveTurn: () => ({
|
||||||
accountId: route.accountId,
|
channel: "mattermost",
|
||||||
}
|
accountId: route.accountId,
|
||||||
: undefined,
|
routeSessionKey: route.sessionKey,
|
||||||
onRecordError: (err) => {
|
storePath,
|
||||||
logVerboseMessage(
|
ctxPayload,
|
||||||
`mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`,
|
recordInboundSession: core.channel.session.recordInboundSession,
|
||||||
);
|
record: {
|
||||||
},
|
updateLastRoute:
|
||||||
},
|
kind === "direct"
|
||||||
onPreDispatchFailure: async () => {
|
? {
|
||||||
dispatchSettledBeforeStart = true;
|
sessionKey: route.mainSessionKey,
|
||||||
await core.channel.reply.settleReplyDispatcher({
|
channel: "mattermost",
|
||||||
dispatcher,
|
to,
|
||||||
onSettled: () => {
|
accountId: route.accountId,
|
||||||
markRunComplete();
|
|
||||||
markDispatchIdle();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
runDispatch: () =>
|
|
||||||
core.channel.reply.withReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => {
|
|
||||||
markDispatchIdle();
|
|
||||||
},
|
|
||||||
run: () =>
|
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions: {
|
|
||||||
...replyOptions,
|
|
||||||
disableBlockStreaming: true,
|
|
||||||
onModelSelected,
|
|
||||||
onPartialReply: (payload) => {
|
|
||||||
updateDraftFromPartial(payload.text);
|
|
||||||
},
|
|
||||||
onAssistantMessageStart: () => {
|
|
||||||
lastPartialText = "";
|
|
||||||
},
|
|
||||||
onReasoningEnd: () => {
|
|
||||||
lastPartialText = "";
|
|
||||||
},
|
|
||||||
onReasoningStream: async () => {
|
|
||||||
if (!lastPartialText) {
|
|
||||||
draftStream.update("Thinking…");
|
|
||||||
}
|
}
|
||||||
},
|
: undefined,
|
||||||
onToolStart: async (payload) => {
|
onRecordError: (err) => {
|
||||||
draftStream.update(buildMattermostToolStatusText(payload));
|
logVerboseMessage(
|
||||||
},
|
`mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
isGroup: Boolean(historyKey),
|
||||||
|
historyKey: historyKey ?? undefined,
|
||||||
|
historyMap: channelHistories,
|
||||||
|
limit: historyLimit,
|
||||||
|
},
|
||||||
|
onPreDispatchFailure: async () => {
|
||||||
|
dispatchSettledBeforeStart = true;
|
||||||
|
await core.channel.reply.settleReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => {
|
||||||
|
markRunComplete();
|
||||||
|
markDispatchIdle();
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
runDispatch: () =>
|
||||||
|
core.channel.reply.withReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => {
|
||||||
|
markDispatchIdle();
|
||||||
|
},
|
||||||
|
run: () =>
|
||||||
|
core.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
disableBlockStreaming: true,
|
||||||
|
onModelSelected,
|
||||||
|
onPartialReply: (payload) => {
|
||||||
|
updateDraftFromPartial(payload.text);
|
||||||
|
},
|
||||||
|
onAssistantMessageStart: () => {
|
||||||
|
lastPartialText = "";
|
||||||
|
},
|
||||||
|
onReasoningEnd: () => {
|
||||||
|
lastPartialText = "";
|
||||||
|
},
|
||||||
|
onReasoningStream: async () => {
|
||||||
|
if (!lastPartialText) {
|
||||||
|
draftStream.update("Thinking…");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToolStart: async (payload) => {
|
||||||
|
draftStream.update(buildMattermostToolStatusText(payload));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -1800,13 +1820,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
markRunComplete();
|
markRunComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (historyKey) {
|
|
||||||
clearHistoryEntriesIfEnabled({
|
|
||||||
historyMap: channelHistories,
|
|
||||||
historyKey,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (replayResult === "duplicate") {
|
if (replayResult === "duplicate") {
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const run = vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
|
||||||
|
const input = await params.adapter.ingest(params.raw);
|
||||||
|
if (!input) {
|
||||||
|
return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
|
||||||
|
}
|
||||||
|
const eventClass = (await params.adapter.classify?.(input)) ?? {
|
||||||
|
kind: "message" as const,
|
||||||
|
canStartAgentTurn: true,
|
||||||
|
};
|
||||||
|
const preflightResult = await params.adapter.preflight?.(input, eventClass);
|
||||||
|
const preflight =
|
||||||
|
preflightResult && "kind" in preflightResult
|
||||||
|
? { admission: preflightResult }
|
||||||
|
: (preflightResult ?? {});
|
||||||
|
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
||||||
|
if ("runDispatch" in turn) {
|
||||||
|
return await runPrepared(turn);
|
||||||
|
}
|
||||||
|
throw new Error("msteams test runtime only supports prepared turn dispatch");
|
||||||
|
});
|
||||||
setMSTeamsRuntime({
|
setMSTeamsRuntime({
|
||||||
logging: { shouldLogVerbose: () => false },
|
logging: { shouldLogVerbose: () => false },
|
||||||
system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() },
|
system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() },
|
||||||
@@ -90,6 +110,7 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
|
|||||||
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
|
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
|
||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
|
run: run as unknown as PluginRuntime["channel"]["turn"]["run"],
|
||||||
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
|
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
recordPendingHistoryEntryIfEnabled,
|
recordPendingHistoryEntryIfEnabled,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
@@ -840,33 +839,57 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||||
try {
|
try {
|
||||||
const { dispatchResult } = await core.channel.turn.runPrepared({
|
const turnResult = await core.channel.turn.run({
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
raw: context,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession: core.channel.session.recordInboundSession,
|
id: activity.id ?? `${teamsFrom}:${Date.now()}`,
|
||||||
record: {
|
timestamp: timestamp?.getTime(),
|
||||||
onRecordError: (err) => {
|
rawText: rawBody,
|
||||||
logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`);
|
textForAgent: bodyForAgent,
|
||||||
},
|
textForCommands: commandBody,
|
||||||
},
|
raw: activity,
|
||||||
onPreDispatchFailure: () =>
|
|
||||||
core.channel.reply.settleReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => markDispatchIdle(),
|
|
||||||
}),
|
}),
|
||||||
runDispatch: () =>
|
resolveTurn: () => ({
|
||||||
dispatchReplyFromConfigWithSettledDispatcher({
|
channel: "msteams",
|
||||||
cfg,
|
accountId: route.accountId,
|
||||||
|
routeSessionKey: route.sessionKey,
|
||||||
|
storePath,
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
dispatcher,
|
recordInboundSession: core.channel.session.recordInboundSession,
|
||||||
onSettled: () => markDispatchIdle(),
|
record: {
|
||||||
replyOptions,
|
onRecordError: (err) => {
|
||||||
configOverride,
|
logVerboseMessage(
|
||||||
|
`msteams: failed updating session meta: ${formatUnknownError(err)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
isGroup: isRoomish,
|
||||||
|
historyKey,
|
||||||
|
historyMap: conversationHistories,
|
||||||
|
limit: historyLimit,
|
||||||
|
},
|
||||||
|
onPreDispatchFailure: () =>
|
||||||
|
core.channel.reply.settleReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
}),
|
||||||
|
runDispatch: () =>
|
||||||
|
dispatchReplyFromConfigWithSettledDispatcher({
|
||||||
|
cfg,
|
||||||
|
ctxPayload,
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
replyOptions,
|
||||||
|
configOverride,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
|
||||||
const queuedFinal = dispatchResult?.queuedFinal ?? false;
|
const queuedFinal = dispatchResult?.queuedFinal ?? false;
|
||||||
const counts = resolveInboundReplyDispatchCounts(dispatchResult);
|
const counts = resolveInboundReplyDispatchCounts(dispatchResult);
|
||||||
const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult);
|
const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult);
|
||||||
@@ -874,26 +897,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
log.info("dispatch complete", { queuedFinal, counts });
|
log.info("dispatch complete", { queuedFinal, counts });
|
||||||
|
|
||||||
if (!hasFinalResponse) {
|
if (!hasFinalResponse) {
|
||||||
if (isRoomish && historyKey) {
|
|
||||||
clearHistoryEntriesIfEnabled({
|
|
||||||
historyMap: conversationHistories,
|
|
||||||
historyKey,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const finalCount = counts.final;
|
const finalCount = counts.final;
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||||
);
|
);
|
||||||
if (isRoomish && historyKey) {
|
|
||||||
clearHistoryEntriesIfEnabled({
|
|
||||||
historyMap: conversationHistories,
|
|
||||||
historyKey,
|
|
||||||
limit: historyLimit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("dispatch failed", { error: formatUnknownError(err) });
|
log.error("dispatch failed", { error: formatUnknownError(err) });
|
||||||
runtime.error?.(`msteams dispatch failed: ${formatUnknownError(err)}`);
|
runtime.error?.(`msteams dispatch failed: ${formatUnknownError(err)}`);
|
||||||
|
|||||||
@@ -69,9 +69,24 @@ function makeRuntime(): GatewayPluginRuntime {
|
|||||||
recordInboundSession: vi.fn(async () => undefined),
|
recordInboundSession: vi.fn(async () => undefined),
|
||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
runPrepared: vi.fn(async (rawParams: unknown) => {
|
run: vi.fn(async (rawParams: unknown) => {
|
||||||
const params = rawParams as { runDispatch: () => Promise<unknown> };
|
const params = rawParams as {
|
||||||
return { dispatchResult: await params.runDispatch() };
|
raw: unknown;
|
||||||
|
adapter: {
|
||||||
|
ingest: (raw: unknown) => unknown;
|
||||||
|
resolveTurn: (...args: unknown[]) => unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const input = await params.adapter.ingest(params.raw);
|
||||||
|
const turn = (await params.adapter.resolveTurn(
|
||||||
|
input,
|
||||||
|
{
|
||||||
|
kind: "message",
|
||||||
|
canStartAgentTurn: true,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)) as { runDispatch: () => Promise<unknown> };
|
||||||
|
return { dispatchResult: await turn.runDispatch() };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
|
|||||||
@@ -141,9 +141,24 @@ function makeRuntime(params: {
|
|||||||
recordInboundSession: vi.fn(async () => undefined),
|
recordInboundSession: vi.fn(async () => undefined),
|
||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
runPrepared: vi.fn(async (rawParams: unknown) => {
|
run: vi.fn(async (rawParams: unknown) => {
|
||||||
const params = rawParams as { runDispatch: () => Promise<unknown> };
|
const params = rawParams as {
|
||||||
return { dispatchResult: await params.runDispatch() };
|
raw: unknown;
|
||||||
|
adapter: {
|
||||||
|
ingest: (raw: unknown) => unknown;
|
||||||
|
resolveTurn: (...args: unknown[]) => unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const input = await params.adapter.ingest(params.raw);
|
||||||
|
const turn = (await params.adapter.resolveTurn(
|
||||||
|
input,
|
||||||
|
{
|
||||||
|
kind: "message",
|
||||||
|
canStartAgentTurn: true,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)) as { runDispatch: () => Promise<unknown> };
|
||||||
|
return { dispatchResult: await turn.runDispatch() };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* Separated from gateway.ts for testability and to keep handleMessage thin.
|
* Separated from gateway.ts for testability and to keep handleMessage thin.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import {
|
import {
|
||||||
parseAndSendMediaTags,
|
parseAndSendMediaTags,
|
||||||
sendPlainReply,
|
sendPlainReply,
|
||||||
@@ -224,238 +225,256 @@ export async function dispatchOutbound(
|
|||||||
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
|
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
|
||||||
agentId,
|
agentId,
|
||||||
});
|
});
|
||||||
const dispatchPromise = runtime.channel.turn.runPrepared({
|
const dispatchPromise = runtime.channel.turn.run({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
accountId: inbound.route.accountId,
|
accountId: inbound.route.accountId,
|
||||||
routeSessionKey: inbound.route.sessionKey,
|
raw: inbound,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
|
||||||
record: {
|
rawText: ctxPayload.RawBody ?? "",
|
||||||
onRecordError: (err: unknown) => {
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
log?.error(
|
textForCommands: ctxPayload.CommandBody,
|
||||||
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
|
raw: inbound,
|
||||||
);
|
}),
|
||||||
},
|
resolveTurn: () => ({
|
||||||
},
|
channel: "qqbot",
|
||||||
runDispatch: () =>
|
accountId: inbound.route.accountId,
|
||||||
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
routeSessionKey: inbound.route.sessionKey,
|
||||||
ctx: ctxPayload,
|
storePath,
|
||||||
cfg,
|
ctxPayload,
|
||||||
dispatcherOptions: {
|
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||||
responsePrefix: messagesConfig.responsePrefix,
|
record: {
|
||||||
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
|
onRecordError: (err: unknown) => {
|
||||||
hasResponse = true;
|
log?.error(
|
||||||
|
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runDispatch: () =>
|
||||||
|
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
responsePrefix: messagesConfig.responsePrefix,
|
||||||
|
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
|
||||||
|
hasResponse = true;
|
||||||
|
|
||||||
// ---- Tool deliver ----
|
// ---- Tool deliver ----
|
||||||
if (info.kind === "tool") {
|
if (info.kind === "tool") {
|
||||||
toolDeliverCount++;
|
toolDeliverCount++;
|
||||||
const toolText = (payload.text ?? "").trim();
|
const toolText = (payload.text ?? "").trim();
|
||||||
if (toolText) {
|
if (toolText) {
|
||||||
toolTexts.push(toolText);
|
toolTexts.push(toolText);
|
||||||
}
|
}
|
||||||
if (payload.mediaUrls?.length) {
|
if (payload.mediaUrls?.length) {
|
||||||
toolMediaUrls.push(...payload.mediaUrls);
|
toolMediaUrls.push(...payload.mediaUrls);
|
||||||
}
|
}
|
||||||
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
|
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
|
||||||
toolMediaUrls.push(payload.mediaUrl);
|
toolMediaUrls.push(payload.mediaUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasBlockResponse && toolMediaUrls.length > 0) {
|
if (hasBlockResponse && toolMediaUrls.length > 0) {
|
||||||
const urlsToSend = [...toolMediaUrls];
|
const urlsToSend = [...toolMediaUrls];
|
||||||
toolMediaUrls.length = 0;
|
toolMediaUrls.length = 0;
|
||||||
for (const mediaUrl of urlsToSend) {
|
for (const mediaUrl of urlsToSend) {
|
||||||
try {
|
try {
|
||||||
await sendMedia({
|
await sendMedia({
|
||||||
to: qualifiedTarget,
|
to: qualifiedTarget,
|
||||||
text: "",
|
text: "",
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToId: event.messageId,
|
replyToId: event.messageId,
|
||||||
account,
|
account,
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (toolFallbackSent) {
|
if (toolFallbackSent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (toolOnlyTimeoutId) {
|
if (toolOnlyTimeoutId) {
|
||||||
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
|
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
|
||||||
clearTimeout(toolOnlyTimeoutId);
|
clearTimeout(toolOnlyTimeoutId);
|
||||||
toolRenewalCount++;
|
toolRenewalCount++;
|
||||||
} else {
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolOnlyTimeoutId = setTimeout(async () => {
|
||||||
|
if (!hasBlockResponse && !toolFallbackSent) {
|
||||||
|
toolFallbackSent = true;
|
||||||
|
try {
|
||||||
|
await sendToolFallback();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, TOOL_ONLY_TIMEOUT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
toolOnlyTimeoutId = setTimeout(async () => {
|
// ---- Block deliver ----
|
||||||
if (!hasBlockResponse && !toolFallbackSent) {
|
hasBlockResponse = true;
|
||||||
toolFallbackSent = true;
|
inbound.typing.keepAlive?.stop();
|
||||||
try {
|
if (timeoutId) {
|
||||||
await sendToolFallback();
|
clearTimeout(timeoutId);
|
||||||
} catch {}
|
timeoutId = null;
|
||||||
}
|
}
|
||||||
}, TOOL_ONLY_TIMEOUT);
|
if (toolOnlyTimeoutId) {
|
||||||
return;
|
clearTimeout(toolOnlyTimeoutId);
|
||||||
}
|
toolOnlyTimeoutId = null;
|
||||||
|
|
||||||
// ---- Block deliver ----
|
|
||||||
hasBlockResponse = true;
|
|
||||||
inbound.typing.keepAlive?.stop();
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = null;
|
|
||||||
}
|
|
||||||
if (toolOnlyTimeoutId) {
|
|
||||||
clearTimeout(toolOnlyTimeoutId);
|
|
||||||
toolOnlyTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streamingController && !streamingController.isTerminalPhase) {
|
|
||||||
try {
|
|
||||||
await streamingController.onDeliver(payload);
|
|
||||||
} catch (err) {
|
|
||||||
log?.error(
|
|
||||||
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const replyPreview = (payload.text ?? "").trim();
|
|
||||||
if (
|
|
||||||
event.type === "group" &&
|
|
||||||
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
|
|
||||||
) {
|
|
||||||
log?.info(
|
|
||||||
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streamingController.shouldFallbackToStatic) {
|
|
||||||
log?.info("Streaming API unavailable, falling back to static for this deliver");
|
|
||||||
} else {
|
|
||||||
recordOutbound();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const quoteRef = event.msgIdx;
|
|
||||||
let quoteRefUsed = false;
|
|
||||||
const consumeQuoteRef = (): string | undefined => {
|
|
||||||
if (quoteRef && !quoteRefUsed) {
|
|
||||||
quoteRefUsed = true;
|
|
||||||
return quoteRef;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
let replyText = payload.text ?? "";
|
|
||||||
const deliverEvent = {
|
|
||||||
type: event.type,
|
|
||||||
senderId: event.senderId,
|
|
||||||
messageId: event.messageId,
|
|
||||||
channelId: event.channelId,
|
|
||||||
groupOpenid: event.groupOpenid,
|
|
||||||
msgIdx: event.msgIdx,
|
|
||||||
};
|
|
||||||
const deliverActx = { account, qualifiedTarget, log };
|
|
||||||
|
|
||||||
// 1. Media tags
|
|
||||||
const mediaResult = await parseAndSendMediaTags(
|
|
||||||
replyText,
|
|
||||||
deliverEvent,
|
|
||||||
deliverActx,
|
|
||||||
sendWithRetry,
|
|
||||||
consumeQuoteRef,
|
|
||||||
deliverDeps,
|
|
||||||
);
|
|
||||||
if (mediaResult.handled) {
|
|
||||||
recordOutbound();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replyText = mediaResult.normalizedText;
|
|
||||||
|
|
||||||
// 2. Structured payload (QQBOT_PAYLOAD:)
|
|
||||||
const handled = await handleStructuredPayload(
|
|
||||||
replyCtx,
|
|
||||||
replyText,
|
|
||||||
recordOutbound,
|
|
||||||
replyDeps,
|
|
||||||
);
|
|
||||||
if (handled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Voice-intent plain text
|
|
||||||
if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) {
|
|
||||||
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
|
|
||||||
if (sentVoice) {
|
|
||||||
recordOutbound();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Plain text + images/media
|
|
||||||
await sendPlainReply(
|
|
||||||
payload,
|
|
||||||
replyText,
|
|
||||||
deliverEvent,
|
|
||||||
deliverActx,
|
|
||||||
sendWithRetry,
|
|
||||||
consumeQuoteRef,
|
|
||||||
toolMediaUrls,
|
|
||||||
deliverDeps,
|
|
||||||
);
|
|
||||||
recordOutbound();
|
|
||||||
},
|
|
||||||
onError: async (err: unknown) => {
|
|
||||||
if (streamingController && !streamingController.isTerminalPhase) {
|
|
||||||
try {
|
|
||||||
await streamingController.onError(err);
|
|
||||||
} catch (streamErr) {
|
|
||||||
const streamErrMsg =
|
|
||||||
streamErr instanceof Error ? streamErr.message : String(streamErr);
|
|
||||||
log?.error(`Streaming onError failed: ${streamErrMsg}`);
|
|
||||||
}
|
|
||||||
if (!streamingController.shouldFallbackToStatic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
log?.error(`Dispatch error: ${errMsg}`);
|
|
||||||
hasResponse = true;
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
replyOptions: {
|
|
||||||
disableBlockStreaming: useOfficialC2cStream
|
|
||||||
? true
|
|
||||||
: (() => {
|
|
||||||
const s = account.config?.streaming;
|
|
||||||
if (s === false) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return typeof s === "object" && s !== null && s.mode === "off";
|
|
||||||
})(),
|
if (streamingController && !streamingController.isTerminalPhase) {
|
||||||
...(streamingController
|
|
||||||
? {
|
|
||||||
onPartialReply: async (payload: { text?: string }) => {
|
|
||||||
try {
|
try {
|
||||||
await streamingController.onPartialReply(payload);
|
await streamingController.onDeliver(payload);
|
||||||
} catch (partialErr) {
|
} catch (err) {
|
||||||
log?.error(
|
log?.error(
|
||||||
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
|
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
const replyPreview = (payload.text ?? "").trim();
|
||||||
: {}),
|
if (
|
||||||
},
|
event.type === "group" &&
|
||||||
|
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
|
||||||
|
) {
|
||||||
|
log?.info(
|
||||||
|
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamingController.shouldFallbackToStatic) {
|
||||||
|
log?.info("Streaming API unavailable, falling back to static for this deliver");
|
||||||
|
} else {
|
||||||
|
recordOutbound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteRef = event.msgIdx;
|
||||||
|
let quoteRefUsed = false;
|
||||||
|
const consumeQuoteRef = (): string | undefined => {
|
||||||
|
if (quoteRef && !quoteRefUsed) {
|
||||||
|
quoteRefUsed = true;
|
||||||
|
return quoteRef;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
let replyText = payload.text ?? "";
|
||||||
|
const deliverEvent = {
|
||||||
|
type: event.type,
|
||||||
|
senderId: event.senderId,
|
||||||
|
messageId: event.messageId,
|
||||||
|
channelId: event.channelId,
|
||||||
|
groupOpenid: event.groupOpenid,
|
||||||
|
msgIdx: event.msgIdx,
|
||||||
|
};
|
||||||
|
const deliverActx = { account, qualifiedTarget, log };
|
||||||
|
|
||||||
|
// 1. Media tags
|
||||||
|
const mediaResult = await parseAndSendMediaTags(
|
||||||
|
replyText,
|
||||||
|
deliverEvent,
|
||||||
|
deliverActx,
|
||||||
|
sendWithRetry,
|
||||||
|
consumeQuoteRef,
|
||||||
|
deliverDeps,
|
||||||
|
);
|
||||||
|
if (mediaResult.handled) {
|
||||||
|
recordOutbound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replyText = mediaResult.normalizedText;
|
||||||
|
|
||||||
|
// 2. Structured payload (QQBOT_PAYLOAD:)
|
||||||
|
const handled = await handleStructuredPayload(
|
||||||
|
replyCtx,
|
||||||
|
replyText,
|
||||||
|
recordOutbound,
|
||||||
|
replyDeps,
|
||||||
|
);
|
||||||
|
if (handled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Voice-intent plain text
|
||||||
|
if (
|
||||||
|
payload.audioAsVoice === true &&
|
||||||
|
!payload.mediaUrl &&
|
||||||
|
!payload.mediaUrls?.length
|
||||||
|
) {
|
||||||
|
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
|
||||||
|
if (sentVoice) {
|
||||||
|
recordOutbound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Plain text + images/media
|
||||||
|
await sendPlainReply(
|
||||||
|
payload,
|
||||||
|
replyText,
|
||||||
|
deliverEvent,
|
||||||
|
deliverActx,
|
||||||
|
sendWithRetry,
|
||||||
|
consumeQuoteRef,
|
||||||
|
toolMediaUrls,
|
||||||
|
deliverDeps,
|
||||||
|
);
|
||||||
|
recordOutbound();
|
||||||
|
},
|
||||||
|
onError: async (err: unknown) => {
|
||||||
|
if (streamingController && !streamingController.isTerminalPhase) {
|
||||||
|
try {
|
||||||
|
await streamingController.onError(err);
|
||||||
|
} catch (streamErr) {
|
||||||
|
const streamErrMsg =
|
||||||
|
streamErr instanceof Error ? streamErr.message : String(streamErr);
|
||||||
|
log?.error(`Streaming onError failed: ${streamErrMsg}`);
|
||||||
|
}
|
||||||
|
if (!streamingController.shouldFallbackToStatic) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
log?.error(`Dispatch error: ${errMsg}`);
|
||||||
|
hasResponse = true;
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replyOptions: {
|
||||||
|
disableBlockStreaming: useOfficialC2cStream
|
||||||
|
? true
|
||||||
|
: (() => {
|
||||||
|
const s = account.config?.streaming;
|
||||||
|
if (s === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return typeof s === "object" && s !== null && s.mode === "off";
|
||||||
|
})(),
|
||||||
|
...(streamingController
|
||||||
|
? {
|
||||||
|
onPartialReply: async (payload: { text?: string }) => {
|
||||||
|
try {
|
||||||
|
await streamingController.onPartialReply(payload);
|
||||||
|
} catch (partialErr) {
|
||||||
|
log?.error(
|
||||||
|
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -493,7 +512,10 @@ export async function dispatchOutbound(
|
|||||||
|
|
||||||
// ============ ctxPayload builder ============
|
// ============ ctxPayload builder ============
|
||||||
|
|
||||||
function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime): unknown {
|
function buildCtxPayload(
|
||||||
|
inbound: InboundContext,
|
||||||
|
runtime: GatewayPluginRuntime,
|
||||||
|
): FinalizedMsgContext {
|
||||||
const { event } = inbound;
|
const { event } = inbound;
|
||||||
return runtime.channel.reply.finalizeInboundContext({
|
return runtime.channel.reply.finalizeInboundContext({
|
||||||
Body: inbound.body,
|
Body: inbound.body,
|
||||||
@@ -549,5 +571,5 @@ function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime)
|
|||||||
ReplyToIsQuote: inbound.replyTo.isQuote,
|
ReplyToIsQuote: inbound.replyTo.isQuote,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
}) as FinalizedMsgContext;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export interface GatewayPluginRuntime {
|
|||||||
recordInboundSession: (params: unknown) => Promise<unknown>;
|
recordInboundSession: (params: unknown) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
turn: {
|
turn: {
|
||||||
runPrepared: (params: unknown) => Promise<unknown>;
|
run: (params: unknown) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
text: {
|
text: {
|
||||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
toInternalMessageReceivedContext,
|
toInternalMessageReceivedContext,
|
||||||
triggerInternalHook,
|
triggerInternalHook,
|
||||||
} from "openclaw/plugin-sdk/hook-runtime";
|
} from "openclaw/plugin-sdk/hook-runtime";
|
||||||
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
@@ -288,72 +288,85 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await runPreparedInboundReplyTurn({
|
await runInboundReplyTurn({
|
||||||
channel: "signal",
|
channel: "signal",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
raw: entry,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession,
|
id: entry.messageId ?? `${entry.timestamp ?? Date.now()}`,
|
||||||
record: {
|
timestamp: entry.timestamp,
|
||||||
updateLastRoute: !entry.isGroup
|
rawText: entry.bodyText,
|
||||||
? {
|
raw: entry,
|
||||||
sessionKey: route.mainSessionKey,
|
|
||||||
channel: "signal",
|
|
||||||
to: entry.senderRecipient,
|
|
||||||
accountId: route.accountId,
|
|
||||||
mainDmOwnerPin: (() => {
|
|
||||||
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
|
||||||
dmScope: deps.cfg.session?.dmScope,
|
|
||||||
allowFrom: deps.allowFrom,
|
|
||||||
normalizeEntry: normalizeSignalAllowRecipient,
|
|
||||||
});
|
|
||||||
if (!pinnedOwner) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ownerRecipient: pinnedOwner,
|
|
||||||
senderRecipient: entry.senderRecipient,
|
|
||||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
|
||||||
logVerbose(
|
|
||||||
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onRecordError: (err) => {
|
|
||||||
logVerbose(`signal: failed updating session meta: ${String(err)}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
history: {
|
|
||||||
isGroup: entry.isGroup,
|
|
||||||
historyKey,
|
|
||||||
historyMap: deps.groupHistories,
|
|
||||||
limit: deps.historyLimit,
|
|
||||||
},
|
|
||||||
onPreDispatchFailure: () =>
|
|
||||||
settleReplyDispatcher({
|
|
||||||
dispatcher,
|
|
||||||
onSettled: () => markDispatchIdle(),
|
|
||||||
}),
|
}),
|
||||||
runDispatch: async () => {
|
resolveTurn: () => ({
|
||||||
try {
|
channel: "signal",
|
||||||
return await dispatchInboundMessage({
|
accountId: route.accountId,
|
||||||
ctx: ctxPayload,
|
routeSessionKey: route.sessionKey,
|
||||||
cfg: deps.cfg,
|
storePath,
|
||||||
dispatcher,
|
ctxPayload,
|
||||||
replyOptions: {
|
recordInboundSession,
|
||||||
...replyOptions,
|
record: {
|
||||||
disableBlockStreaming:
|
updateLastRoute: !entry.isGroup
|
||||||
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
? {
|
||||||
onModelSelected,
|
sessionKey: route.mainSessionKey,
|
||||||
|
channel: "signal",
|
||||||
|
to: entry.senderRecipient,
|
||||||
|
accountId: route.accountId,
|
||||||
|
mainDmOwnerPin: (() => {
|
||||||
|
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
||||||
|
dmScope: deps.cfg.session?.dmScope,
|
||||||
|
allowFrom: deps.allowFrom,
|
||||||
|
normalizeEntry: normalizeSignalAllowRecipient,
|
||||||
|
});
|
||||||
|
if (!pinnedOwner) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ownerRecipient: pinnedOwner,
|
||||||
|
senderRecipient: entry.senderRecipient,
|
||||||
|
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||||
|
logVerbose(
|
||||||
|
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onRecordError: (err) => {
|
||||||
|
logVerbose(`signal: failed updating session meta: ${String(err)}`);
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
} finally {
|
history: {
|
||||||
markDispatchIdle();
|
isGroup: entry.isGroup,
|
||||||
}
|
historyKey,
|
||||||
|
historyMap: deps.groupHistories,
|
||||||
|
limit: deps.historyLimit,
|
||||||
|
},
|
||||||
|
onPreDispatchFailure: () =>
|
||||||
|
settleReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
}),
|
||||||
|
runDispatch: async () => {
|
||||||
|
try {
|
||||||
|
return await dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: deps.cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
disableBlockStreaming:
|
||||||
|
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
markDispatchIdle();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/channel-streaming";
|
} from "openclaw/plugin-sdk/channel-streaming";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import {
|
import {
|
||||||
|
type ChannelTurnRecordOptions,
|
||||||
hasVisibleInboundReplyDispatch,
|
hasVisibleInboundReplyDispatch,
|
||||||
runPreparedInboundReplyTurn,
|
runInboundReplyTurn,
|
||||||
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
|
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
|
||||||
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
|
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
|
||||||
@@ -987,93 +988,111 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
let counts: { final?: number; block?: number } = {};
|
let counts: { final?: number; block?: number } = {};
|
||||||
let dispatchSettledBeforeStart = false;
|
let dispatchSettledBeforeStart = false;
|
||||||
try {
|
try {
|
||||||
const { dispatchResult } = await runPreparedInboundReplyTurn({
|
const turnResult = await runInboundReplyTurn({
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
raw: prepared.message,
|
||||||
storePath: prepared.turn.storePath,
|
adapter: {
|
||||||
ctxPayload: prepared.ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession,
|
id: prepared.message.ts ?? `${prepared.ctxPayload.From}:${Date.now()}`,
|
||||||
record: prepared.turn.record as Parameters<typeof runPreparedInboundReplyTurn>[0]["record"],
|
timestamp: prepared.message.ts ? Number(prepared.message.ts) * 1000 : undefined,
|
||||||
onPreDispatchFailure: async () => {
|
rawText: prepared.ctxPayload.RawBody ?? "",
|
||||||
dispatchSettledBeforeStart = true;
|
textForAgent: prepared.ctxPayload.BodyForAgent,
|
||||||
await settleReplyDispatcher({
|
textForCommands: prepared.ctxPayload.CommandBody,
|
||||||
dispatcher,
|
raw: prepared.message,
|
||||||
onSettled: () => markDispatchIdle(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
runDispatch: () =>
|
|
||||||
dispatchInboundMessage({
|
|
||||||
ctx: prepared.ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions: {
|
|
||||||
...replyOptions,
|
|
||||||
skillFilter: prepared.channelConfig?.skills,
|
|
||||||
sourceReplyDeliveryMode,
|
|
||||||
hasRepliedRef,
|
|
||||||
disableBlockStreaming,
|
|
||||||
onModelSelected,
|
|
||||||
suppressDefaultToolProgressMessages: previewToolProgressEnabled ? true : undefined,
|
|
||||||
onPartialReply: useStreaming
|
|
||||||
? undefined
|
|
||||||
: !previewStreamingEnabled
|
|
||||||
? undefined
|
|
||||||
: async (payload) => {
|
|
||||||
updateDraftFromPartial(payload.text);
|
|
||||||
},
|
|
||||||
onAssistantMessageStart: onDraftBoundary,
|
|
||||||
onReasoningEnd: onDraftBoundary,
|
|
||||||
onReasoningStream: statusReactionsEnabled
|
|
||||||
? async () => {
|
|
||||||
await statusReactions.setThinking();
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onToolStart: async (payload) => {
|
|
||||||
if (statusReactionsEnabled) {
|
|
||||||
await statusReactions.setTool(payload.name);
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(payload.name ? `tool: ${payload.name}` : "tool running");
|
|
||||||
},
|
|
||||||
onItemEvent: async (payload) => {
|
|
||||||
pushPreviewToolProgress(
|
|
||||||
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPlanUpdate: async (payload) => {
|
|
||||||
if (payload.phase !== "update") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
|
|
||||||
},
|
|
||||||
onApprovalEvent: async (payload) => {
|
|
||||||
if (payload.phase !== "requested") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(
|
|
||||||
payload.command ? `approval: ${payload.command}` : "approval requested",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onCommandOutput: async (payload) => {
|
|
||||||
if (payload.phase !== "end") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(
|
|
||||||
payload.name
|
|
||||||
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
|
|
||||||
: payload.title,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPatchSummary: async (payload) => {
|
|
||||||
if (payload.phase !== "end") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
resolveTurn: () => ({
|
||||||
|
channel: "slack",
|
||||||
|
accountId: route.accountId,
|
||||||
|
routeSessionKey: route.sessionKey,
|
||||||
|
storePath: prepared.turn.storePath,
|
||||||
|
ctxPayload: prepared.ctxPayload,
|
||||||
|
recordInboundSession,
|
||||||
|
record: prepared.turn.record as ChannelTurnRecordOptions,
|
||||||
|
onPreDispatchFailure: async () => {
|
||||||
|
dispatchSettledBeforeStart = true;
|
||||||
|
await settleReplyDispatcher({
|
||||||
|
dispatcher,
|
||||||
|
onSettled: () => markDispatchIdle(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
runDispatch: () =>
|
||||||
|
dispatchInboundMessage({
|
||||||
|
ctx: prepared.ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
skillFilter: prepared.channelConfig?.skills,
|
||||||
|
sourceReplyDeliveryMode,
|
||||||
|
hasRepliedRef,
|
||||||
|
disableBlockStreaming,
|
||||||
|
onModelSelected,
|
||||||
|
suppressDefaultToolProgressMessages: previewToolProgressEnabled ? true : undefined,
|
||||||
|
onPartialReply: useStreaming
|
||||||
|
? undefined
|
||||||
|
: !previewStreamingEnabled
|
||||||
|
? undefined
|
||||||
|
: async (payload) => {
|
||||||
|
updateDraftFromPartial(payload.text);
|
||||||
|
},
|
||||||
|
onAssistantMessageStart: onDraftBoundary,
|
||||||
|
onReasoningEnd: onDraftBoundary,
|
||||||
|
onReasoningStream: statusReactionsEnabled
|
||||||
|
? async () => {
|
||||||
|
await statusReactions.setThinking();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onToolStart: async (payload) => {
|
||||||
|
if (statusReactionsEnabled) {
|
||||||
|
await statusReactions.setTool(payload.name);
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(payload.name ? `tool: ${payload.name}` : "tool running");
|
||||||
|
},
|
||||||
|
onItemEvent: async (payload) => {
|
||||||
|
pushPreviewToolProgress(
|
||||||
|
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPlanUpdate: async (payload) => {
|
||||||
|
if (payload.phase !== "update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
|
||||||
|
},
|
||||||
|
onApprovalEvent: async (payload) => {
|
||||||
|
if (payload.phase !== "requested") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(
|
||||||
|
payload.command ? `approval: ${payload.command}` : "approval requested",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCommandOutput: async (payload) => {
|
||||||
|
if (payload.phase !== "end") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(
|
||||||
|
payload.name
|
||||||
|
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
|
||||||
|
: payload.title,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPatchSummary: async (payload) => {
|
||||||
|
if (payload.phase !== "end") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const result = dispatchResult;
|
if (!turnResult.dispatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = turnResult.dispatchResult;
|
||||||
queuedFinal = result.queuedFinal;
|
queuedFinal = result.queuedFinal;
|
||||||
counts = result.counts;
|
counts = result.counts;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
import {
|
import {
|
||||||
hasFinalInboundReplyDispatch,
|
hasFinalInboundReplyDispatch,
|
||||||
runPreparedInboundReplyTurn,
|
runInboundReplyTurn,
|
||||||
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
} from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
import {
|
import {
|
||||||
createOutboundPayloadPlan,
|
createOutboundPayloadPlan,
|
||||||
@@ -844,313 +844,337 @@ export const dispatchTelegramMessage = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dispatchResult } = await runPreparedInboundReplyTurn({
|
const turnResult = await runInboundReplyTurn({
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
routeSessionKey: route.sessionKey,
|
raw: context,
|
||||||
storePath: context.turn.storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession: context.turn.recordInboundSession,
|
id: ctxPayload.MessageSid ?? `${chatId}:${Date.now()}`,
|
||||||
record: context.turn.record,
|
timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined,
|
||||||
runDispatch: () =>
|
rawText: ctxPayload.RawBody ?? "",
|
||||||
telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
ctx: ctxPayload,
|
textForCommands: ctxPayload.CommandBody,
|
||||||
cfg,
|
raw: context,
|
||||||
dispatcherOptions: {
|
|
||||||
...replyPipeline,
|
|
||||||
beforeDeliver: async (payload) => payload,
|
|
||||||
deliver: async (payload, info) => {
|
|
||||||
if (isDispatchSuperseded()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const clearPendingCompactionReplayBoundaryOnVisibleBoundary = (
|
|
||||||
didDeliver: boolean,
|
|
||||||
) => {
|
|
||||||
if (didDeliver && info.kind !== "final") {
|
|
||||||
pendingCompactionReplayBoundary = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (payload.isError === true) {
|
|
||||||
hadErrorReplyFailureOrSkip = true;
|
|
||||||
}
|
|
||||||
if (info.kind === "final") {
|
|
||||||
await enqueueDraftLaneEvent(async () => {});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
shouldSuppressLocalTelegramExecApprovalPrompt({
|
|
||||||
cfg,
|
|
||||||
accountId: route.accountId,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
queuedFinal = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const previewButtons = (
|
|
||||||
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
|
|
||||||
)?.buttons;
|
|
||||||
const split = splitTextIntoLaneSegments(payload.text);
|
|
||||||
const segments = split.segments;
|
|
||||||
const reply = resolveSendableOutboundReplyParts(payload);
|
|
||||||
const _hasMedia = reply.hasMedia;
|
|
||||||
|
|
||||||
const flushBufferedFinalAnswer = async () => {
|
|
||||||
const buffered = reasoningStepState.takeBufferedFinalAnswer();
|
|
||||||
if (!buffered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bufferedButtons = (
|
|
||||||
buffered.payload.channelData?.telegram as
|
|
||||||
| { buttons?: TelegramInlineButtons }
|
|
||||||
| undefined
|
|
||||||
)?.buttons;
|
|
||||||
await deliverLaneText({
|
|
||||||
laneName: "answer",
|
|
||||||
text: buffered.text,
|
|
||||||
payload: buffered.payload,
|
|
||||||
infoKind: "final",
|
|
||||||
previewButtons: bufferedButtons,
|
|
||||||
});
|
|
||||||
reasoningStepState.resetForNextStep();
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const segment of segments) {
|
|
||||||
if (
|
|
||||||
segment.lane === "answer" &&
|
|
||||||
info.kind === "final" &&
|
|
||||||
reasoningStepState.shouldBufferFinalAnswer()
|
|
||||||
) {
|
|
||||||
reasoningStepState.bufferFinalAnswer({
|
|
||||||
payload,
|
|
||||||
text: segment.text,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (segment.lane === "reasoning") {
|
|
||||||
reasoningStepState.noteReasoningHint();
|
|
||||||
}
|
|
||||||
const result = await deliverLaneText({
|
|
||||||
laneName: segment.lane,
|
|
||||||
text: segment.text,
|
|
||||||
payload,
|
|
||||||
infoKind: info.kind,
|
|
||||||
previewButtons,
|
|
||||||
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
|
|
||||||
});
|
|
||||||
if (info.kind === "final") {
|
|
||||||
emitPreviewFinalizedHook(result);
|
|
||||||
}
|
|
||||||
if (segment.lane === "reasoning") {
|
|
||||||
if (result.kind !== "skipped") {
|
|
||||||
reasoningStepState.noteReasoningDelivered();
|
|
||||||
await flushBufferedFinalAnswer();
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (info.kind === "final") {
|
|
||||||
if (reasoningLane.hasStreamedMessage) {
|
|
||||||
activePreviewLifecycleByLane.reasoning = "complete";
|
|
||||||
retainPreviewOnCleanupByLane.reasoning = true;
|
|
||||||
}
|
|
||||||
reasoningStepState.resetForNextStep();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (segments.length > 0) {
|
|
||||||
if (info.kind === "final") {
|
|
||||||
pendingCompactionReplayBoundary = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (split.suppressedReasoningOnly) {
|
|
||||||
if (reply.hasMedia) {
|
|
||||||
const payloadWithoutSuppressedReasoning =
|
|
||||||
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
|
|
||||||
clearPendingCompactionReplayBoundaryOnVisibleBoundary(
|
|
||||||
await sendPayload(payloadWithoutSuppressedReasoning),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (info.kind === "final") {
|
|
||||||
await flushBufferedFinalAnswer();
|
|
||||||
pendingCompactionReplayBoundary = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.kind === "final") {
|
|
||||||
await answerLane.stream?.stop();
|
|
||||||
await reasoningLane.stream?.stop();
|
|
||||||
reasoningStepState.resetForNextStep();
|
|
||||||
}
|
|
||||||
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
|
|
||||||
if (!canSendAsIs) {
|
|
||||||
if (info.kind === "final") {
|
|
||||||
await flushBufferedFinalAnswer();
|
|
||||||
pendingCompactionReplayBoundary = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearPendingCompactionReplayBoundaryOnVisibleBoundary(await sendPayload(payload));
|
|
||||||
if (info.kind === "final") {
|
|
||||||
await flushBufferedFinalAnswer();
|
|
||||||
pendingCompactionReplayBoundary = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSkip: (payload, info) => {
|
|
||||||
if (payload.isError === true) {
|
|
||||||
hadErrorReplyFailureOrSkip = true;
|
|
||||||
}
|
|
||||||
if (info.reason !== "silent") {
|
|
||||||
deliveryState.markNonSilentSkip();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
const errorPolicy = resolveTelegramErrorPolicy({
|
|
||||||
accountConfig: telegramCfg,
|
|
||||||
groupConfig,
|
|
||||||
topicConfig,
|
|
||||||
});
|
|
||||||
if (isSilentErrorPolicy(errorPolicy.policy)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
errorPolicy.policy === "once" &&
|
|
||||||
shouldSuppressTelegramError({
|
|
||||||
scopeKey: buildTelegramErrorScopeKey({
|
|
||||||
accountId: route.accountId,
|
|
||||||
chatId,
|
|
||||||
threadId: threadSpec.id,
|
|
||||||
}),
|
|
||||||
cooldownMs: errorPolicy.cooldownMs,
|
|
||||||
errorMessage: String(err),
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deliveryState.markNonSilentFailure();
|
|
||||||
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
replyOptions: {
|
|
||||||
skillFilter,
|
|
||||||
disableBlockStreaming,
|
|
||||||
onPartialReply:
|
|
||||||
answerLane.stream || reasoningLane.stream
|
|
||||||
? (payload) =>
|
|
||||||
enqueueDraftLaneEvent(async () => {
|
|
||||||
await ingestDraftLaneSegments(payload.text);
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
onReasoningStream: reasoningLane.stream
|
|
||||||
? (payload) =>
|
|
||||||
enqueueDraftLaneEvent(async () => {
|
|
||||||
if (splitReasoningOnNextStream) {
|
|
||||||
reasoningLane.stream?.forceNewMessage();
|
|
||||||
resetDraftLaneState(reasoningLane);
|
|
||||||
splitReasoningOnNextStream = false;
|
|
||||||
}
|
|
||||||
await ingestDraftLaneSegments(payload.text);
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
onAssistantMessageStart: answerLane.stream
|
|
||||||
? () =>
|
|
||||||
enqueueDraftLaneEvent(async () => {
|
|
||||||
reasoningStepState.resetForNextStep();
|
|
||||||
previewToolProgressSuppressed = false;
|
|
||||||
previewToolProgressLines = [];
|
|
||||||
if (skipNextAnswerMessageStartRotation) {
|
|
||||||
skipNextAnswerMessageStartRotation = false;
|
|
||||||
activePreviewLifecycleByLane.answer = "transient";
|
|
||||||
retainPreviewOnCleanupByLane.answer = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pendingCompactionReplayBoundary) {
|
|
||||||
pendingCompactionReplayBoundary = false;
|
|
||||||
activePreviewLifecycleByLane.answer = "transient";
|
|
||||||
retainPreviewOnCleanupByLane.answer = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await rotateAnswerLaneForNewAssistantMessage();
|
|
||||||
activePreviewLifecycleByLane.answer = "transient";
|
|
||||||
retainPreviewOnCleanupByLane.answer = false;
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
onReasoningEnd: reasoningLane.stream
|
|
||||||
? () =>
|
|
||||||
enqueueDraftLaneEvent(async () => {
|
|
||||||
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
|
||||||
previewToolProgressSuppressed = false;
|
|
||||||
previewToolProgressLines = [];
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
suppressDefaultToolProgressMessages:
|
|
||||||
!previewStreamingEnabled || Boolean(answerLane.stream),
|
|
||||||
onToolStart: async (payload) => {
|
|
||||||
const toolName = payload.name?.trim();
|
|
||||||
if (statusReactionController && toolName) {
|
|
||||||
await statusReactionController.setTool(toolName);
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running");
|
|
||||||
},
|
|
||||||
onItemEvent: async (payload) => {
|
|
||||||
pushPreviewToolProgress(
|
|
||||||
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPlanUpdate: async (payload) => {
|
|
||||||
if (payload.phase !== "update") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
|
|
||||||
},
|
|
||||||
onApprovalEvent: async (payload) => {
|
|
||||||
if (payload.phase !== "requested") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(
|
|
||||||
payload.command ? `approval: ${payload.command}` : "approval requested",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onCommandOutput: async (payload) => {
|
|
||||||
if (payload.phase !== "end") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(
|
|
||||||
payload.name
|
|
||||||
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
|
|
||||||
: payload.title,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPatchSummary: async (payload) => {
|
|
||||||
if (payload.phase !== "end") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
|
|
||||||
},
|
|
||||||
onCompactionStart:
|
|
||||||
statusReactionController || answerLane.stream
|
|
||||||
? async () => {
|
|
||||||
if (
|
|
||||||
answerLane.hasStreamedMessage &&
|
|
||||||
activePreviewLifecycleByLane.answer === "transient"
|
|
||||||
) {
|
|
||||||
pendingCompactionReplayBoundary = true;
|
|
||||||
}
|
|
||||||
if (statusReactionController) {
|
|
||||||
await statusReactionController.setCompacting();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onCompactionEnd: statusReactionController
|
|
||||||
? async () => {
|
|
||||||
statusReactionController.cancelPending();
|
|
||||||
await statusReactionController.setThinking();
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onModelSelected,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
resolveTurn: () => ({
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: route.accountId,
|
||||||
|
routeSessionKey: route.sessionKey,
|
||||||
|
storePath: context.turn.storePath,
|
||||||
|
ctxPayload,
|
||||||
|
recordInboundSession: context.turn.recordInboundSession,
|
||||||
|
record: context.turn.record,
|
||||||
|
runDispatch: () =>
|
||||||
|
telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
...replyPipeline,
|
||||||
|
beforeDeliver: async (payload) => payload,
|
||||||
|
deliver: async (payload, info) => {
|
||||||
|
if (isDispatchSuperseded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clearPendingCompactionReplayBoundaryOnVisibleBoundary = (
|
||||||
|
didDeliver: boolean,
|
||||||
|
) => {
|
||||||
|
if (didDeliver && info.kind !== "final") {
|
||||||
|
pendingCompactionReplayBoundary = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (payload.isError === true) {
|
||||||
|
hadErrorReplyFailureOrSkip = true;
|
||||||
|
}
|
||||||
|
if (info.kind === "final") {
|
||||||
|
await enqueueDraftLaneEvent(async () => {});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
shouldSuppressLocalTelegramExecApprovalPrompt({
|
||||||
|
cfg,
|
||||||
|
accountId: route.accountId,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
queuedFinal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewButtons = (
|
||||||
|
payload.channelData?.telegram as
|
||||||
|
| { buttons?: TelegramInlineButtons }
|
||||||
|
| undefined
|
||||||
|
)?.buttons;
|
||||||
|
const split = splitTextIntoLaneSegments(payload.text);
|
||||||
|
const segments = split.segments;
|
||||||
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
|
const _hasMedia = reply.hasMedia;
|
||||||
|
|
||||||
|
const flushBufferedFinalAnswer = async () => {
|
||||||
|
const buffered = reasoningStepState.takeBufferedFinalAnswer();
|
||||||
|
if (!buffered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bufferedButtons = (
|
||||||
|
buffered.payload.channelData?.telegram as
|
||||||
|
| { buttons?: TelegramInlineButtons }
|
||||||
|
| undefined
|
||||||
|
)?.buttons;
|
||||||
|
await deliverLaneText({
|
||||||
|
laneName: "answer",
|
||||||
|
text: buffered.text,
|
||||||
|
payload: buffered.payload,
|
||||||
|
infoKind: "final",
|
||||||
|
previewButtons: bufferedButtons,
|
||||||
|
});
|
||||||
|
reasoningStepState.resetForNextStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (
|
||||||
|
segment.lane === "answer" &&
|
||||||
|
info.kind === "final" &&
|
||||||
|
reasoningStepState.shouldBufferFinalAnswer()
|
||||||
|
) {
|
||||||
|
reasoningStepState.bufferFinalAnswer({
|
||||||
|
payload,
|
||||||
|
text: segment.text,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segment.lane === "reasoning") {
|
||||||
|
reasoningStepState.noteReasoningHint();
|
||||||
|
}
|
||||||
|
const result = await deliverLaneText({
|
||||||
|
laneName: segment.lane,
|
||||||
|
text: segment.text,
|
||||||
|
payload,
|
||||||
|
infoKind: info.kind,
|
||||||
|
previewButtons,
|
||||||
|
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
|
||||||
|
});
|
||||||
|
if (info.kind === "final") {
|
||||||
|
emitPreviewFinalizedHook(result);
|
||||||
|
}
|
||||||
|
if (segment.lane === "reasoning") {
|
||||||
|
if (result.kind !== "skipped") {
|
||||||
|
reasoningStepState.noteReasoningDelivered();
|
||||||
|
await flushBufferedFinalAnswer();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (info.kind === "final") {
|
||||||
|
if (reasoningLane.hasStreamedMessage) {
|
||||||
|
activePreviewLifecycleByLane.reasoning = "complete";
|
||||||
|
retainPreviewOnCleanupByLane.reasoning = true;
|
||||||
|
}
|
||||||
|
reasoningStepState.resetForNextStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (segments.length > 0) {
|
||||||
|
if (info.kind === "final") {
|
||||||
|
pendingCompactionReplayBoundary = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (split.suppressedReasoningOnly) {
|
||||||
|
if (reply.hasMedia) {
|
||||||
|
const payloadWithoutSuppressedReasoning =
|
||||||
|
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
|
||||||
|
clearPendingCompactionReplayBoundaryOnVisibleBoundary(
|
||||||
|
await sendPayload(payloadWithoutSuppressedReasoning),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (info.kind === "final") {
|
||||||
|
await flushBufferedFinalAnswer();
|
||||||
|
pendingCompactionReplayBoundary = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.kind === "final") {
|
||||||
|
await answerLane.stream?.stop();
|
||||||
|
await reasoningLane.stream?.stop();
|
||||||
|
reasoningStepState.resetForNextStep();
|
||||||
|
}
|
||||||
|
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
|
||||||
|
if (!canSendAsIs) {
|
||||||
|
if (info.kind === "final") {
|
||||||
|
await flushBufferedFinalAnswer();
|
||||||
|
pendingCompactionReplayBoundary = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPendingCompactionReplayBoundaryOnVisibleBoundary(
|
||||||
|
await sendPayload(payload),
|
||||||
|
);
|
||||||
|
if (info.kind === "final") {
|
||||||
|
await flushBufferedFinalAnswer();
|
||||||
|
pendingCompactionReplayBoundary = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkip: (payload, info) => {
|
||||||
|
if (payload.isError === true) {
|
||||||
|
hadErrorReplyFailureOrSkip = true;
|
||||||
|
}
|
||||||
|
if (info.reason !== "silent") {
|
||||||
|
deliveryState.markNonSilentSkip();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
const errorPolicy = resolveTelegramErrorPolicy({
|
||||||
|
accountConfig: telegramCfg,
|
||||||
|
groupConfig,
|
||||||
|
topicConfig,
|
||||||
|
});
|
||||||
|
if (isSilentErrorPolicy(errorPolicy.policy)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
errorPolicy.policy === "once" &&
|
||||||
|
shouldSuppressTelegramError({
|
||||||
|
scopeKey: buildTelegramErrorScopeKey({
|
||||||
|
accountId: route.accountId,
|
||||||
|
chatId,
|
||||||
|
threadId: threadSpec.id,
|
||||||
|
}),
|
||||||
|
cooldownMs: errorPolicy.cooldownMs,
|
||||||
|
errorMessage: String(err),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deliveryState.markNonSilentFailure();
|
||||||
|
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replyOptions: {
|
||||||
|
skillFilter,
|
||||||
|
disableBlockStreaming,
|
||||||
|
onPartialReply:
|
||||||
|
answerLane.stream || reasoningLane.stream
|
||||||
|
? (payload) =>
|
||||||
|
enqueueDraftLaneEvent(async () => {
|
||||||
|
await ingestDraftLaneSegments(payload.text);
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
onReasoningStream: reasoningLane.stream
|
||||||
|
? (payload) =>
|
||||||
|
enqueueDraftLaneEvent(async () => {
|
||||||
|
if (splitReasoningOnNextStream) {
|
||||||
|
reasoningLane.stream?.forceNewMessage();
|
||||||
|
resetDraftLaneState(reasoningLane);
|
||||||
|
splitReasoningOnNextStream = false;
|
||||||
|
}
|
||||||
|
await ingestDraftLaneSegments(payload.text);
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
onAssistantMessageStart: answerLane.stream
|
||||||
|
? () =>
|
||||||
|
enqueueDraftLaneEvent(async () => {
|
||||||
|
reasoningStepState.resetForNextStep();
|
||||||
|
previewToolProgressSuppressed = false;
|
||||||
|
previewToolProgressLines = [];
|
||||||
|
if (skipNextAnswerMessageStartRotation) {
|
||||||
|
skipNextAnswerMessageStartRotation = false;
|
||||||
|
activePreviewLifecycleByLane.answer = "transient";
|
||||||
|
retainPreviewOnCleanupByLane.answer = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingCompactionReplayBoundary) {
|
||||||
|
pendingCompactionReplayBoundary = false;
|
||||||
|
activePreviewLifecycleByLane.answer = "transient";
|
||||||
|
retainPreviewOnCleanupByLane.answer = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await rotateAnswerLaneForNewAssistantMessage();
|
||||||
|
activePreviewLifecycleByLane.answer = "transient";
|
||||||
|
retainPreviewOnCleanupByLane.answer = false;
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
onReasoningEnd: reasoningLane.stream
|
||||||
|
? () =>
|
||||||
|
enqueueDraftLaneEvent(async () => {
|
||||||
|
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
||||||
|
previewToolProgressSuppressed = false;
|
||||||
|
previewToolProgressLines = [];
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
suppressDefaultToolProgressMessages:
|
||||||
|
!previewStreamingEnabled || Boolean(answerLane.stream),
|
||||||
|
onToolStart: async (payload) => {
|
||||||
|
const toolName = payload.name?.trim();
|
||||||
|
if (statusReactionController && toolName) {
|
||||||
|
await statusReactionController.setTool(toolName);
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running");
|
||||||
|
},
|
||||||
|
onItemEvent: async (payload) => {
|
||||||
|
pushPreviewToolProgress(
|
||||||
|
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPlanUpdate: async (payload) => {
|
||||||
|
if (payload.phase !== "update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(
|
||||||
|
payload.explanation ?? payload.steps?.[0] ?? "planning",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onApprovalEvent: async (payload) => {
|
||||||
|
if (payload.phase !== "requested") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(
|
||||||
|
payload.command ? `approval: ${payload.command}` : "approval requested",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCommandOutput: async (payload) => {
|
||||||
|
if (payload.phase !== "end") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(
|
||||||
|
payload.name
|
||||||
|
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
|
||||||
|
: payload.title,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPatchSummary: async (payload) => {
|
||||||
|
if (payload.phase !== "end") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
|
||||||
|
},
|
||||||
|
onCompactionStart:
|
||||||
|
statusReactionController || answerLane.stream
|
||||||
|
? async () => {
|
||||||
|
if (
|
||||||
|
answerLane.hasStreamedMessage &&
|
||||||
|
activePreviewLifecycleByLane.answer === "transient"
|
||||||
|
) {
|
||||||
|
pendingCompactionReplayBoundary = true;
|
||||||
|
}
|
||||||
|
if (statusReactionController) {
|
||||||
|
await statusReactionController.setCompacting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onCompactionEnd: statusReactionController
|
||||||
|
? async () => {
|
||||||
|
statusReactionController.cancelPending();
|
||||||
|
await statusReactionController.setThinking();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onModelSelected,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
({ queuedFinal } = dispatchResult);
|
if (!turnResult.dispatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
({ queuedFinal } = turnResult.dispatchResult);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dispatchError = err;
|
dispatchError = err;
|
||||||
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
|
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
toPluginMessageReceivedEvent,
|
toPluginMessageReceivedEvent,
|
||||||
triggerInternalHook,
|
triggerInternalHook,
|
||||||
} from "openclaw/plugin-sdk/hook-runtime";
|
} from "openclaw/plugin-sdk/hook-runtime";
|
||||||
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||||
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
||||||
import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
|
import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
|
||||||
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
|
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
|
||||||
@@ -454,52 +454,68 @@ export async function processMessage(params: {
|
|||||||
warn: params.replyLogger.warn.bind(params.replyLogger),
|
warn: params.replyLogger.warn.bind(params.replyLogger),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { dispatchResult: didSendReply } = await runPreparedInboundReplyTurn({
|
const turnResult = await runInboundReplyTurn({
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
accountId: params.route.accountId,
|
accountId: params.route.accountId,
|
||||||
routeSessionKey: params.route.sessionKey,
|
raw: params.msg,
|
||||||
storePath,
|
adapter: {
|
||||||
ctxPayload,
|
ingest: () => ({
|
||||||
recordInboundSession,
|
id: params.msg.id ?? `${conversationId}:${Date.now()}`,
|
||||||
record: {
|
timestamp: params.msg.timestamp,
|
||||||
onRecordError: (err) => {
|
rawText: ctxPayload.RawBody ?? "",
|
||||||
params.replyLogger.warn(
|
textForAgent: ctxPayload.BodyForAgent,
|
||||||
{
|
textForCommands: ctxPayload.CommandBody,
|
||||||
error: formatError(err),
|
raw: params.msg,
|
||||||
storePath,
|
|
||||||
sessionKey: params.route.sessionKey,
|
|
||||||
},
|
|
||||||
"failed updating session meta",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
trackSessionMetaTask: (task) => {
|
|
||||||
trackBackgroundTask(params.backgroundTasks, task);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
runDispatch: () =>
|
|
||||||
dispatchWhatsAppBufferedReply({
|
|
||||||
cfg: params.cfg,
|
|
||||||
connectionId: params.connectionId,
|
|
||||||
context: ctxPayload,
|
|
||||||
conversationId,
|
|
||||||
deliverReply: deliverWebReply,
|
|
||||||
groupHistories: params.groupHistories,
|
|
||||||
groupHistoryKey: params.groupHistoryKey,
|
|
||||||
maxMediaBytes: params.maxMediaBytes,
|
|
||||||
maxMediaTextChunkLimit: params.maxMediaTextChunkLimit,
|
|
||||||
msg: params.msg,
|
|
||||||
onModelSelected,
|
|
||||||
rememberSentText: params.rememberSentText,
|
|
||||||
replyLogger: params.replyLogger,
|
|
||||||
replyPipeline: {
|
|
||||||
...replyPipeline,
|
|
||||||
responsePrefix,
|
|
||||||
},
|
|
||||||
replyResolver: params.replyResolver,
|
|
||||||
route: params.route,
|
|
||||||
shouldClearGroupHistory,
|
|
||||||
}),
|
}),
|
||||||
|
resolveTurn: () => ({
|
||||||
|
channel: "whatsapp",
|
||||||
|
accountId: params.route.accountId,
|
||||||
|
routeSessionKey: params.route.sessionKey,
|
||||||
|
storePath,
|
||||||
|
ctxPayload,
|
||||||
|
recordInboundSession,
|
||||||
|
record: {
|
||||||
|
onRecordError: (err) => {
|
||||||
|
params.replyLogger.warn(
|
||||||
|
{
|
||||||
|
error: formatError(err),
|
||||||
|
storePath,
|
||||||
|
sessionKey: params.route.sessionKey,
|
||||||
|
},
|
||||||
|
"failed updating session meta",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
trackSessionMetaTask: (task) => {
|
||||||
|
trackBackgroundTask(params.backgroundTasks, task);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runDispatch: () =>
|
||||||
|
dispatchWhatsAppBufferedReply({
|
||||||
|
cfg: params.cfg,
|
||||||
|
connectionId: params.connectionId,
|
||||||
|
context: ctxPayload,
|
||||||
|
conversationId,
|
||||||
|
deliverReply: deliverWebReply,
|
||||||
|
groupHistories: params.groupHistories,
|
||||||
|
groupHistoryKey: params.groupHistoryKey,
|
||||||
|
maxMediaBytes: params.maxMediaBytes,
|
||||||
|
maxMediaTextChunkLimit: params.maxMediaTextChunkLimit,
|
||||||
|
msg: params.msg,
|
||||||
|
onModelSelected,
|
||||||
|
rememberSentText: params.rememberSentText,
|
||||||
|
replyLogger: params.replyLogger,
|
||||||
|
replyPipeline: {
|
||||||
|
...replyPipeline,
|
||||||
|
responsePrefix,
|
||||||
|
},
|
||||||
|
replyResolver: params.replyResolver,
|
||||||
|
route: params.route,
|
||||||
|
shouldClearGroupHistory,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const didSendReply = turnResult.dispatched ? turnResult.dispatchResult : false;
|
||||||
removeAckReactionHandleAfterReply({
|
removeAckReactionHandleAfterReply({
|
||||||
removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply),
|
removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply),
|
||||||
ackReaction,
|
ackReaction,
|
||||||
|
|||||||
@@ -257,13 +257,23 @@ export function createImageLifecycleCore() {
|
|||||||
updateLastRoute: resolved.record?.updateLastRoute,
|
updateLastRoute: resolved.record?.updateLastRoute,
|
||||||
onRecordError: resolved.record?.onRecordError ?? (() => undefined),
|
onRecordError: resolved.record?.onRecordError ?? (() => undefined),
|
||||||
});
|
});
|
||||||
|
if ("runDispatch" in resolved) {
|
||||||
|
const dispatchResult = await resolved.runDispatch();
|
||||||
|
return {
|
||||||
|
admission: { kind: "dispatch" as const },
|
||||||
|
dispatched: true,
|
||||||
|
ctxPayload: resolved.ctxPayload,
|
||||||
|
routeSessionKey: resolved.routeSessionKey,
|
||||||
|
dispatchResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
|
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: resolved.ctxPayload,
|
ctx: resolved.ctxPayload,
|
||||||
cfg: resolved.cfg,
|
cfg: resolved.cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
...resolved.dispatcherOptions,
|
...resolved.dispatcherOptions,
|
||||||
deliver: async (payload, info) => {
|
deliver: async (...args: Parameters<typeof resolved.delivery.deliver>) => {
|
||||||
await resolved.delivery.deliver(payload, info);
|
await resolved.delivery.deliver(...args);
|
||||||
},
|
},
|
||||||
onError: resolved.delivery.onError,
|
onError: resolved.delivery.onError,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,13 +102,23 @@ function installRuntime(params: {
|
|||||||
updateLastRoute: turn.record?.updateLastRoute,
|
updateLastRoute: turn.record?.updateLastRoute,
|
||||||
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
||||||
});
|
});
|
||||||
|
if ("runDispatch" in turn) {
|
||||||
|
const dispatchResult = await turn.runDispatch();
|
||||||
|
return {
|
||||||
|
admission: { kind: "dispatch" as const },
|
||||||
|
dispatched: true,
|
||||||
|
ctxPayload: turn.ctxPayload,
|
||||||
|
routeSessionKey: turn.routeSessionKey,
|
||||||
|
dispatchResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
|
const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: turn.ctxPayload,
|
ctx: turn.ctxPayload,
|
||||||
cfg: turn.cfg,
|
cfg: turn.cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
...turn.dispatcherOptions,
|
...turn.dispatcherOptions,
|
||||||
deliver: async (payload, info) => {
|
deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
|
||||||
await turn.delivery.deliver(payload, info);
|
await turn.delivery.deliver(...args);
|
||||||
},
|
},
|
||||||
onError: turn.delivery.onError,
|
onError: turn.delivery.onError,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -310,6 +310,38 @@ describe("channel turn kernel", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs custom prepared dispatch from a full turn adapter", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const result = await runChannelTurn({
|
||||||
|
channel: "test",
|
||||||
|
raw: { id: "msg-1", text: "hello" },
|
||||||
|
adapter: {
|
||||||
|
ingest: () => ({ id: "msg-1", rawText: "hello" }),
|
||||||
|
resolveTurn: () => ({
|
||||||
|
channel: "test",
|
||||||
|
routeSessionKey: "agent:main:test:peer",
|
||||||
|
storePath: "/tmp/sessions.json",
|
||||||
|
ctxPayload: createCtx(),
|
||||||
|
recordInboundSession: createRecordInboundSession(events),
|
||||||
|
runDispatch: async () => {
|
||||||
|
events.push("custom-dispatch");
|
||||||
|
return {
|
||||||
|
queuedFinal: true,
|
||||||
|
counts: { tool: 0, block: 0, final: 1 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual(["record", "custom-dispatch"]);
|
||||||
|
expect(result.dispatched).toBe(true);
|
||||||
|
if (!result.dispatched) {
|
||||||
|
throw new Error("expected dispatch");
|
||||||
|
}
|
||||||
|
expect(result.dispatchResult.queuedFinal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes failed dispatches before rethrowing", async () => {
|
it("finalizes failed dispatches before rethrowing", async () => {
|
||||||
const onFinalize = vi.fn();
|
const onFinalize = vi.fn();
|
||||||
const dispatchError = new Error("dispatch failed");
|
const dispatchError = new Error("dispatch failed");
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import type {
|
|||||||
ChannelTurnDeliveryAdapter,
|
ChannelTurnDeliveryAdapter,
|
||||||
ChannelTurnHistoryFinalizeOptions,
|
ChannelTurnHistoryFinalizeOptions,
|
||||||
ChannelTurnLogEvent,
|
ChannelTurnLogEvent,
|
||||||
|
ChannelTurnResolved,
|
||||||
ChannelTurnResult,
|
ChannelTurnResult,
|
||||||
DispatchedChannelTurnResult,
|
DispatchedChannelTurnResult,
|
||||||
PreparedChannelTurn,
|
PreparedChannelTurn,
|
||||||
PreflightFacts,
|
PreflightFacts,
|
||||||
RunChannelTurnParams,
|
RunChannelTurnParams,
|
||||||
RunResolvedChannelTurnParams,
|
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
export {
|
export {
|
||||||
EMPTY_CHANNEL_TURN_DISPATCH_COUNTS,
|
EMPTY_CHANNEL_TURN_DISPATCH_COUNTS,
|
||||||
@@ -49,7 +49,6 @@ export type {
|
|||||||
ReplyPlanFacts,
|
ReplyPlanFacts,
|
||||||
RouteFacts,
|
RouteFacts,
|
||||||
RunChannelTurnParams,
|
RunChannelTurnParams,
|
||||||
RunResolvedChannelTurnParams,
|
|
||||||
SenderFacts,
|
SenderFacts,
|
||||||
SupplementalContextFacts,
|
SupplementalContextFacts,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -143,6 +142,29 @@ export async function dispatchAssembledChannelTurn(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPreparedChannelTurn<TDispatchResult>(
|
||||||
|
value: ChannelTurnResolved<TDispatchResult>,
|
||||||
|
): value is PreparedChannelTurn<TDispatchResult> & {
|
||||||
|
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
|
||||||
|
} {
|
||||||
|
return "runDispatch" in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchResolvedChannelTurn<TDispatchResult>(
|
||||||
|
params: ChannelTurnResolved<TDispatchResult> & {
|
||||||
|
admission: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
|
||||||
|
log?: (event: ChannelTurnLogEvent) => void;
|
||||||
|
messageId?: string;
|
||||||
|
},
|
||||||
|
): Promise<DispatchedChannelTurnResult<TDispatchResult>> {
|
||||||
|
if (isPreparedChannelTurn(params)) {
|
||||||
|
return await runPreparedChannelTurn(params);
|
||||||
|
}
|
||||||
|
return (await dispatchAssembledChannelTurn(
|
||||||
|
params,
|
||||||
|
)) as DispatchedChannelTurnResult<TDispatchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runPreparedChannelTurn<
|
export async function runPreparedChannelTurn<
|
||||||
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
|
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
|
||||||
>(
|
>(
|
||||||
@@ -248,9 +270,12 @@ export async function runPreparedChannelTurn<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runChannelTurn<TRaw>(
|
export async function runChannelTurn<
|
||||||
params: RunChannelTurnParams<TRaw>,
|
TRaw,
|
||||||
): Promise<ChannelTurnResult> {
|
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
|
||||||
|
>(
|
||||||
|
params: RunChannelTurnParams<TRaw, TDispatchResult>,
|
||||||
|
): Promise<ChannelTurnResult<TDispatchResult>> {
|
||||||
emit({
|
emit({
|
||||||
...params,
|
...params,
|
||||||
event: { stage: "ingest", event: "start" },
|
event: { stage: "ingest", event: "start" },
|
||||||
@@ -327,9 +352,9 @@ export async function runChannelTurn<TRaw>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const admission = resolved.admission ?? preflightAdmission ?? ({ kind: "dispatch" } as const);
|
const admission = resolved.admission ?? preflightAdmission ?? ({ kind: "dispatch" } as const);
|
||||||
let result: ChannelTurnResult;
|
let result: ChannelTurnResult<TDispatchResult>;
|
||||||
try {
|
try {
|
||||||
const dispatchResult = await dispatchAssembledChannelTurn(
|
const dispatchResult = await dispatchResolvedChannelTurn(
|
||||||
admission.kind === "observeOnly"
|
admission.kind === "observeOnly"
|
||||||
? {
|
? {
|
||||||
...resolved,
|
...resolved,
|
||||||
@@ -350,7 +375,7 @@ export async function runChannelTurn<TRaw>(
|
|||||||
admission,
|
admission,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const failedResult: ChannelTurnResult = {
|
const failedResult: ChannelTurnResult<TDispatchResult> = {
|
||||||
admission,
|
admission,
|
||||||
dispatched: false,
|
dispatched: false,
|
||||||
ctxPayload: resolved.ctxPayload,
|
ctxPayload: resolved.ctxPayload,
|
||||||
@@ -406,18 +431,3 @@ export async function runChannelTurn<TRaw>(
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runResolvedChannelTurn<TRaw>(
|
|
||||||
params: RunResolvedChannelTurnParams<TRaw>,
|
|
||||||
): Promise<ChannelTurnResult> {
|
|
||||||
return await runChannelTurn({
|
|
||||||
channel: params.channel,
|
|
||||||
accountId: params.accountId,
|
|
||||||
raw: params.raw,
|
|
||||||
log: params.log,
|
|
||||||
adapter: {
|
|
||||||
ingest: (raw) => (typeof params.input === "function" ? params.input(raw) : params.input),
|
|
||||||
resolveTurn: params.resolveTurn,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -240,9 +240,13 @@ export type PreparedChannelTurn<TDispatchResult = DispatchFromConfigResult> = {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelTurnResolved = AssembledChannelTurn & {
|
export type ChannelTurnResolved<TDispatchResult = DispatchFromConfigResult> =
|
||||||
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
|
| (AssembledChannelTurn & {
|
||||||
};
|
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
|
||||||
|
})
|
||||||
|
| (PreparedChannelTurn<TDispatchResult> & {
|
||||||
|
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
|
||||||
|
});
|
||||||
|
|
||||||
export type ChannelTurnStage =
|
export type ChannelTurnStage =
|
||||||
| "ingest"
|
| "ingest"
|
||||||
@@ -267,13 +271,14 @@ export type ChannelTurnLogEvent = {
|
|||||||
error?: unknown;
|
error?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelTurnResult = {
|
export type ChannelTurnResult<TDispatchResult = DispatchFromConfigResult> =
|
||||||
admission: ChannelTurnAdmission;
|
| DispatchedChannelTurnResult<TDispatchResult>
|
||||||
dispatched: boolean;
|
| {
|
||||||
ctxPayload?: MsgContext;
|
admission: ChannelTurnAdmission;
|
||||||
routeSessionKey?: string;
|
dispatched: false;
|
||||||
dispatchResult?: DispatchFromConfigResult;
|
ctxPayload?: MsgContext;
|
||||||
};
|
routeSessionKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DispatchedChannelTurnResult<TDispatchResult = DispatchFromConfigResult> = {
|
export type DispatchedChannelTurnResult<TDispatchResult = DispatchFromConfigResult> = {
|
||||||
admission: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
|
admission: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
|
||||||
@@ -283,7 +288,7 @@ export type DispatchedChannelTurnResult<TDispatchResult = DispatchFromConfigResu
|
|||||||
dispatchResult: TDispatchResult;
|
dispatchResult: TDispatchResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelTurnAdapter<TRaw> = {
|
export type ChannelTurnAdapter<TRaw, TDispatchResult = DispatchFromConfigResult> = {
|
||||||
ingest: (raw: TRaw) => Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null;
|
ingest: (raw: TRaw) => Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null;
|
||||||
classify?: (input: NormalizedTurnInput) => Promise<ChannelEventClass> | ChannelEventClass;
|
classify?: (input: NormalizedTurnInput) => Promise<ChannelEventClass> | ChannelEventClass;
|
||||||
preflight?: (
|
preflight?: (
|
||||||
@@ -299,29 +304,14 @@ export type ChannelTurnAdapter<TRaw> = {
|
|||||||
input: NormalizedTurnInput,
|
input: NormalizedTurnInput,
|
||||||
eventClass: ChannelEventClass,
|
eventClass: ChannelEventClass,
|
||||||
preflight: PreflightFacts,
|
preflight: PreflightFacts,
|
||||||
) => Promise<ChannelTurnResolved> | ChannelTurnResolved;
|
) => Promise<ChannelTurnResolved<TDispatchResult>> | ChannelTurnResolved<TDispatchResult>;
|
||||||
onFinalize?: (result: ChannelTurnResult) => Promise<void> | void;
|
onFinalize?: (result: ChannelTurnResult<TDispatchResult>) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RunChannelTurnParams<TRaw> = {
|
export type RunChannelTurnParams<TRaw, TDispatchResult = DispatchFromConfigResult> = {
|
||||||
channel: string;
|
channel: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
raw: TRaw;
|
raw: TRaw;
|
||||||
adapter: ChannelTurnAdapter<TRaw>;
|
adapter: ChannelTurnAdapter<TRaw, TDispatchResult>;
|
||||||
log?: (event: ChannelTurnLogEvent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RunResolvedChannelTurnParams<TRaw> = {
|
|
||||||
channel: string;
|
|
||||||
accountId?: string;
|
|
||||||
raw: TRaw;
|
|
||||||
input:
|
|
||||||
| NormalizedTurnInput
|
|
||||||
| ((raw: TRaw) => Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null);
|
|
||||||
resolveTurn: (
|
|
||||||
input: NormalizedTurnInput,
|
|
||||||
eventClass: ChannelEventClass,
|
|
||||||
preflight: PreflightFacts,
|
|
||||||
) => Promise<ChannelTurnResolved> | ChannelTurnResolved;
|
|
||||||
log?: (event: ChannelTurnLogEvent) => void;
|
log?: (event: ChannelTurnLogEvent) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
hasFinalChannelTurnDispatch,
|
hasFinalChannelTurnDispatch,
|
||||||
hasVisibleChannelTurnDispatch,
|
hasVisibleChannelTurnDispatch,
|
||||||
resolveChannelTurnDispatchCounts,
|
resolveChannelTurnDispatchCounts,
|
||||||
|
runChannelTurn,
|
||||||
runPreparedChannelTurn,
|
runPreparedChannelTurn,
|
||||||
} from "../channels/turn/kernel.js";
|
} from "../channels/turn/kernel.js";
|
||||||
import type { PreparedChannelTurn } from "../channels/turn/types.js";
|
import type { PreparedChannelTurn, RunChannelTurnParams } from "../channels/turn/types.js";
|
||||||
|
export type { ChannelTurnRecordOptions } from "../channels/turn/types.js";
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
import { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
|
import { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
|
||||||
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
|
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
|
||||||
@@ -33,6 +35,13 @@ export async function runPreparedInboundReplyTurn<TDispatchResult>(
|
|||||||
return await runPreparedChannelTurn(params);
|
return await runPreparedChannelTurn(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run a channel turn through shared ingest, record, dispatch, and finalize ordering. */
|
||||||
|
export async function runInboundReplyTurn<TRaw, TDispatchResult = DispatchFromConfigResult>(
|
||||||
|
params: RunChannelTurnParams<TRaw, TDispatchResult>,
|
||||||
|
) {
|
||||||
|
return await runChannelTurn(params);
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
hasFinalChannelTurnDispatch as hasFinalInboundReplyDispatch,
|
hasFinalChannelTurnDispatch as hasFinalInboundReplyDispatch,
|
||||||
hasVisibleChannelTurnDispatch as hasVisibleInboundReplyDispatch,
|
hasVisibleChannelTurnDispatch as hasVisibleInboundReplyDispatch,
|
||||||
|
|||||||
@@ -78,40 +78,64 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
|||||||
createTaskFlowSessionMock,
|
createTaskFlowSessionMock,
|
||||||
) as unknown as PluginRuntime["tasks"]["managedFlows"]["fromToolContext"],
|
) as unknown as PluginRuntime["tasks"]["managedFlows"]["fromToolContext"],
|
||||||
};
|
};
|
||||||
const dispatchAssembledChannelTurnMock = vi.fn(
|
const dispatchAssembledChannelTurnMock = vi.fn(async (params: Record<string, unknown>) => {
|
||||||
async (params: Parameters<PluginRuntime["channel"]["turn"]["dispatchAssembled"]>[0]) => {
|
const ctxPayload = params.ctxPayload as Record<string, unknown>;
|
||||||
await params.recordInboundSession({
|
const record = params.record as
|
||||||
storePath: params.storePath,
|
| Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]["record"]
|
||||||
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
|
| undefined;
|
||||||
ctx: params.ctxPayload,
|
const recordInboundSession = params.recordInboundSession as Parameters<
|
||||||
groupResolution: params.record?.groupResolution,
|
PluginRuntime["channel"]["turn"]["runPrepared"]
|
||||||
createIfMissing: params.record?.createIfMissing,
|
>[0]["recordInboundSession"];
|
||||||
updateLastRoute: params.record?.updateLastRoute,
|
const routeSessionKey = params.routeSessionKey as string;
|
||||||
onRecordError: params.record?.onRecordError ?? (() => undefined),
|
const storePath = params.storePath as string;
|
||||||
trackSessionMetaTask: params.record?.trackSessionMetaTask,
|
const delivery = params.delivery as {
|
||||||
});
|
deliver: (payload: unknown, info: unknown) => Promise<unknown>;
|
||||||
const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({
|
onError?: (err: unknown, info: { kind: string }) => void;
|
||||||
ctx: params.ctxPayload,
|
};
|
||||||
cfg: params.cfg,
|
const ctxSessionKey = ctxPayload.SessionKey;
|
||||||
|
const sessionKey = typeof ctxSessionKey === "string" ? ctxSessionKey : routeSessionKey;
|
||||||
|
const dispatchReplyWithBufferedBlockDispatcher =
|
||||||
|
params.dispatchReplyWithBufferedBlockDispatcher as (params: {
|
||||||
|
ctx: unknown;
|
||||||
|
cfg: unknown;
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
...params.dispatcherOptions,
|
deliver: (payload: unknown, info: unknown) => Promise<void>;
|
||||||
deliver: async (payload, info) => {
|
onError?: (err: unknown, info: { kind: string }) => void;
|
||||||
await params.delivery.deliver(payload, info);
|
};
|
||||||
},
|
replyOptions?: unknown;
|
||||||
onError: params.delivery.onError,
|
replyResolver?: unknown;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
await recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
groupResolution: record?.groupResolution,
|
||||||
|
createIfMissing: record?.createIfMissing,
|
||||||
|
updateLastRoute: record?.updateLastRoute,
|
||||||
|
onRecordError: record?.onRecordError ?? (() => undefined),
|
||||||
|
trackSessionMetaTask: record?.trackSessionMetaTask,
|
||||||
|
});
|
||||||
|
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: params.cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
...(params.dispatcherOptions as Record<string, unknown> | undefined),
|
||||||
|
deliver: async (payload, info) => {
|
||||||
|
await delivery.deliver(payload, info);
|
||||||
},
|
},
|
||||||
replyOptions: params.replyOptions,
|
onError: delivery.onError,
|
||||||
replyResolver: params.replyResolver,
|
},
|
||||||
});
|
replyOptions: params.replyOptions,
|
||||||
return {
|
replyResolver: params.replyResolver,
|
||||||
admission: params.admission ?? { kind: "dispatch" as const },
|
});
|
||||||
dispatched: true,
|
return {
|
||||||
ctxPayload: params.ctxPayload,
|
admission: params.admission ?? { kind: "dispatch" },
|
||||||
routeSessionKey: params.routeSessionKey,
|
dispatched: true,
|
||||||
dispatchResult,
|
ctxPayload,
|
||||||
};
|
routeSessionKey,
|
||||||
},
|
dispatchResult,
|
||||||
) as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"];
|
};
|
||||||
|
});
|
||||||
const runPreparedChannelTurnMock = vi.fn(
|
const runPreparedChannelTurnMock = vi.fn(
|
||||||
async (params: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
|
async (params: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
|
||||||
try {
|
try {
|
||||||
@@ -180,18 +204,24 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
|||||||
const resolved = await params.adapter.resolveTurn(input, eventClass, preflight ?? {});
|
const resolved = await params.adapter.resolveTurn(input, eventClass, preflight ?? {});
|
||||||
const admission =
|
const admission =
|
||||||
resolved.admission ?? preflight.admission ?? ({ kind: "dispatch" } as const);
|
resolved.admission ?? preflight.admission ?? ({ kind: "dispatch" } as const);
|
||||||
const dispatchResult = await dispatchAssembledChannelTurnMock({
|
const dispatchResult =
|
||||||
...resolved,
|
"runDispatch" in resolved
|
||||||
admission,
|
? await runPreparedChannelTurnMock({
|
||||||
delivery:
|
...resolved,
|
||||||
admission.kind === "observeOnly"
|
admission,
|
||||||
? { deliver: async () => ({ visibleReplySent: false }) }
|
})
|
||||||
: resolved.delivery,
|
: await dispatchAssembledChannelTurnMock({
|
||||||
});
|
...resolved,
|
||||||
|
admission,
|
||||||
|
delivery:
|
||||||
|
admission.kind === "observeOnly"
|
||||||
|
? { deliver: async () => ({ visibleReplySent: false }) }
|
||||||
|
: resolved.delivery,
|
||||||
|
});
|
||||||
const result = {
|
const result = {
|
||||||
...dispatchResult,
|
...dispatchResult,
|
||||||
admission,
|
admission,
|
||||||
};
|
} as Parameters<NonNullable<typeof params.adapter.onFinalize>>[0];
|
||||||
await params.adapter.onFinalize?.(result);
|
await params.adapter.onFinalize?.(result);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -233,28 +263,6 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
|||||||
...params.extra,
|
...params.extra,
|
||||||
}) as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>,
|
}) as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>,
|
||||||
) as unknown as PluginRuntime["channel"]["turn"]["buildContext"];
|
) as unknown as PluginRuntime["channel"]["turn"]["buildContext"];
|
||||||
const runResolvedChannelTurnMock = vi.fn(
|
|
||||||
async (params: Parameters<PluginRuntime["channel"]["turn"]["runResolved"]>[0]) => {
|
|
||||||
const input =
|
|
||||||
typeof params.input === "function" ? await params.input(params.raw) : params.input;
|
|
||||||
if (!input) {
|
|
||||||
return {
|
|
||||||
admission: { kind: "drop" as const, reason: "ingest-null" },
|
|
||||||
dispatched: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return await runChannelTurnMock({
|
|
||||||
channel: params.channel,
|
|
||||||
accountId: params.accountId,
|
|
||||||
raw: params.raw,
|
|
||||||
log: params.log,
|
|
||||||
adapter: {
|
|
||||||
ingest: () => input,
|
|
||||||
resolveTurn: params.resolveTurn,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
) as unknown as PluginRuntime["channel"]["turn"]["runResolved"];
|
|
||||||
const base: PluginRuntime = {
|
const base: PluginRuntime = {
|
||||||
version: "1.0.0-test",
|
version: "1.0.0-test",
|
||||||
config: {
|
config: {
|
||||||
@@ -609,10 +617,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
|||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
run: runChannelTurnMock,
|
run: runChannelTurnMock,
|
||||||
runResolved: runResolvedChannelTurnMock,
|
|
||||||
buildContext: buildChannelTurnContextMock,
|
buildContext: buildChannelTurnContextMock,
|
||||||
runPrepared: runPreparedChannelTurnMock,
|
runPrepared: runPreparedChannelTurnMock,
|
||||||
dispatchAssembled: dispatchAssembledChannelTurnMock,
|
|
||||||
},
|
},
|
||||||
threadBindings: {
|
threadBindings: {
|
||||||
setIdleTimeoutBySessionKey:
|
setIdleTimeoutBySessionKey:
|
||||||
|
|||||||
@@ -52,10 +52,8 @@ import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load
|
|||||||
import { recordInboundSession } from "../../channels/session.js";
|
import { recordInboundSession } from "../../channels/session.js";
|
||||||
import {
|
import {
|
||||||
buildChannelTurnContext,
|
buildChannelTurnContext,
|
||||||
dispatchAssembledChannelTurn,
|
|
||||||
runChannelTurn,
|
runChannelTurn,
|
||||||
runPreparedChannelTurn,
|
runPreparedChannelTurn,
|
||||||
runResolvedChannelTurn,
|
|
||||||
} from "../../channels/turn/kernel.js";
|
} from "../../channels/turn/kernel.js";
|
||||||
import {
|
import {
|
||||||
resolveChannelGroupPolicy,
|
resolveChannelGroupPolicy,
|
||||||
@@ -174,10 +172,8 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
|
|||||||
},
|
},
|
||||||
turn: {
|
turn: {
|
||||||
run: runChannelTurn,
|
run: runChannelTurn,
|
||||||
runResolved: runResolvedChannelTurn,
|
|
||||||
buildContext: buildChannelTurnContext,
|
buildContext: buildChannelTurnContext,
|
||||||
runPrepared: runPreparedChannelTurn,
|
runPrepared: runPreparedChannelTurn,
|
||||||
dispatchAssembled: dispatchAssembledChannelTurn,
|
|
||||||
},
|
},
|
||||||
threadBindings: {
|
threadBindings: {
|
||||||
setIdleTimeoutBySessionKey: ({ channelId, targetSessionKey, accountId, idleTimeoutMs }) =>
|
setIdleTimeoutBySessionKey: ({ channelId, targetSessionKey, accountId, idleTimeoutMs }) =>
|
||||||
|
|||||||
@@ -153,12 +153,8 @@ export type PluginRuntimeChannel = {
|
|||||||
};
|
};
|
||||||
turn: {
|
turn: {
|
||||||
run: typeof import("../../channels/turn/kernel.js").runChannelTurn;
|
run: typeof import("../../channels/turn/kernel.js").runChannelTurn;
|
||||||
/** @deprecated Prefer `run(...)`. */
|
|
||||||
runResolved: typeof import("../../channels/turn/kernel.js").runResolvedChannelTurn;
|
|
||||||
buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext;
|
buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext;
|
||||||
runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn;
|
runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn;
|
||||||
/** @deprecated Prefer `run(...)` or `runPrepared(...)`. */
|
|
||||||
dispatchAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn;
|
|
||||||
};
|
};
|
||||||
threadBindings: {
|
threadBindings: {
|
||||||
setIdleTimeoutBySessionKey: (params: {
|
setIdleTimeoutBySessionKey: (params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user