mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:00:47 +00:00
refactor(channels): route inbound turns through kernel
This commit is contained in:
@@ -69,9 +69,24 @@ function makeRuntime(): GatewayPluginRuntime {
|
||||
recordInboundSession: vi.fn(async () => undefined),
|
||||
},
|
||||
turn: {
|
||||
runPrepared: vi.fn(async (rawParams: unknown) => {
|
||||
const params = rawParams as { runDispatch: () => Promise<unknown> };
|
||||
return { dispatchResult: await params.runDispatch() };
|
||||
run: vi.fn(async (rawParams: unknown) => {
|
||||
const params = rawParams as {
|
||||
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: {
|
||||
|
||||
@@ -141,9 +141,24 @@ function makeRuntime(params: {
|
||||
recordInboundSession: vi.fn(async () => undefined),
|
||||
},
|
||||
turn: {
|
||||
runPrepared: vi.fn(async (rawParams: unknown) => {
|
||||
const params = rawParams as { runDispatch: () => Promise<unknown> };
|
||||
return { dispatchResult: await params.runDispatch() };
|
||||
run: vi.fn(async (rawParams: unknown) => {
|
||||
const params = rawParams as {
|
||||
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: {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* Separated from gateway.ts for testability and to keep handleMessage thin.
|
||||
*/
|
||||
|
||||
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
parseAndSendMediaTags,
|
||||
sendPlainReply,
|
||||
@@ -224,238 +225,256 @@ export async function dispatchOutbound(
|
||||
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
|
||||
agentId,
|
||||
});
|
||||
const dispatchPromise = runtime.channel.turn.runPrepared({
|
||||
const dispatchPromise = runtime.channel.turn.run({
|
||||
channel: "qqbot",
|
||||
accountId: inbound.route.accountId,
|
||||
routeSessionKey: inbound.route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||
record: {
|
||||
onRecordError: (err: unknown) => {
|
||||
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;
|
||||
raw: inbound,
|
||||
adapter: {
|
||||
ingest: () => ({
|
||||
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
|
||||
rawText: ctxPayload.RawBody ?? "",
|
||||
textForAgent: ctxPayload.BodyForAgent,
|
||||
textForCommands: ctxPayload.CommandBody,
|
||||
raw: inbound,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
channel: "qqbot",
|
||||
accountId: inbound.route.accountId,
|
||||
routeSessionKey: inbound.route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||
record: {
|
||||
onRecordError: (err: unknown) => {
|
||||
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 ----
|
||||
if (info.kind === "tool") {
|
||||
toolDeliverCount++;
|
||||
const toolText = (payload.text ?? "").trim();
|
||||
if (toolText) {
|
||||
toolTexts.push(toolText);
|
||||
}
|
||||
if (payload.mediaUrls?.length) {
|
||||
toolMediaUrls.push(...payload.mediaUrls);
|
||||
}
|
||||
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
|
||||
toolMediaUrls.push(payload.mediaUrl);
|
||||
}
|
||||
// ---- Tool deliver ----
|
||||
if (info.kind === "tool") {
|
||||
toolDeliverCount++;
|
||||
const toolText = (payload.text ?? "").trim();
|
||||
if (toolText) {
|
||||
toolTexts.push(toolText);
|
||||
}
|
||||
if (payload.mediaUrls?.length) {
|
||||
toolMediaUrls.push(...payload.mediaUrls);
|
||||
}
|
||||
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
|
||||
toolMediaUrls.push(payload.mediaUrl);
|
||||
}
|
||||
|
||||
if (hasBlockResponse && toolMediaUrls.length > 0) {
|
||||
const urlsToSend = [...toolMediaUrls];
|
||||
toolMediaUrls.length = 0;
|
||||
for (const mediaUrl of urlsToSend) {
|
||||
try {
|
||||
await sendMedia({
|
||||
to: qualifiedTarget,
|
||||
text: "",
|
||||
mediaUrl,
|
||||
accountId: account.accountId,
|
||||
replyToId: event.messageId,
|
||||
account,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (toolFallbackSent) {
|
||||
return;
|
||||
}
|
||||
if (toolOnlyTimeoutId) {
|
||||
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
|
||||
clearTimeout(toolOnlyTimeoutId);
|
||||
toolRenewalCount++;
|
||||
} else {
|
||||
if (hasBlockResponse && toolMediaUrls.length > 0) {
|
||||
const urlsToSend = [...toolMediaUrls];
|
||||
toolMediaUrls.length = 0;
|
||||
for (const mediaUrl of urlsToSend) {
|
||||
try {
|
||||
await sendMedia({
|
||||
to: qualifiedTarget,
|
||||
text: "",
|
||||
mediaUrl,
|
||||
accountId: account.accountId,
|
||||
replyToId: event.messageId,
|
||||
account,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (toolFallbackSent) {
|
||||
return;
|
||||
}
|
||||
if (toolOnlyTimeoutId) {
|
||||
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
|
||||
clearTimeout(toolOnlyTimeoutId);
|
||||
toolRenewalCount++;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
toolOnlyTimeoutId = setTimeout(async () => {
|
||||
if (!hasBlockResponse && !toolFallbackSent) {
|
||||
toolFallbackSent = true;
|
||||
try {
|
||||
await sendToolFallback();
|
||||
} catch {}
|
||||
}
|
||||
}, TOOL_ONLY_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
toolOnlyTimeoutId = setTimeout(async () => {
|
||||
if (!hasBlockResponse && !toolFallbackSent) {
|
||||
toolFallbackSent = true;
|
||||
try {
|
||||
await sendToolFallback();
|
||||
} catch {}
|
||||
|
||||
// ---- Block deliver ----
|
||||
hasBlockResponse = true;
|
||||
inbound.typing.keepAlive?.stop();
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
}, TOOL_ONLY_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
if (toolOnlyTimeoutId) {
|
||||
clearTimeout(toolOnlyTimeoutId);
|
||||
toolOnlyTimeoutId = null;
|
||||
}
|
||||
return typeof s === "object" && s !== null && s.mode === "off";
|
||||
})(),
|
||||
...(streamingController
|
||||
? {
|
||||
onPartialReply: async (payload: { text?: string }) => {
|
||||
|
||||
if (streamingController && !streamingController.isTerminalPhase) {
|
||||
try {
|
||||
await streamingController.onPartialReply(payload);
|
||||
} catch (partialErr) {
|
||||
await streamingController.onDeliver(payload);
|
||||
} catch (err) {
|
||||
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 {
|
||||
@@ -493,7 +512,10 @@ export async function dispatchOutbound(
|
||||
|
||||
// ============ ctxPayload builder ============
|
||||
|
||||
function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime): unknown {
|
||||
function buildCtxPayload(
|
||||
inbound: InboundContext,
|
||||
runtime: GatewayPluginRuntime,
|
||||
): FinalizedMsgContext {
|
||||
const { event } = inbound;
|
||||
return runtime.channel.reply.finalizeInboundContext({
|
||||
Body: inbound.body,
|
||||
@@ -549,5 +571,5 @@ function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime)
|
||||
ReplyToIsQuote: inbound.replyTo.isQuote,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}) as FinalizedMsgContext;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface GatewayPluginRuntime {
|
||||
recordInboundSession: (params: unknown) => Promise<unknown>;
|
||||
};
|
||||
turn: {
|
||||
runPrepared: (params: unknown) => Promise<unknown>;
|
||||
run: (params: unknown) => Promise<unknown>;
|
||||
};
|
||||
text: {
|
||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||
|
||||
Reference in New Issue
Block a user