mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 04:16:13 +00:00
fix(gateway): mirror source message sends into transcript (#84837)
Co-authored-by: Firas Alswihry <itzfiras@gmail.com>
This commit is contained in:
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
|
||||
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
|
||||
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
|
||||
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
|
||||
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
|
||||
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
|
||||
- Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.
|
||||
|
||||
@@ -154,19 +154,19 @@ The script exits with code `2` for invalid method selection or invalid `--instal
|
||||
|
||||
<Accordion title="Environment variables reference">
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| Variable | Description |
|
||||
| ------------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| `OPENCLAW_VERSION=latest\|next\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -1297,6 +1297,468 @@ describe("gateway send mirroring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors successful source-conversation message.action sends into the assistant transcript", async () => {
|
||||
const telegramPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram source send transcript mirror test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ enabled: true }),
|
||||
isConfigured: () => true,
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction: async () => jsonResult({ ok: true, messageId: "tg-1" }),
|
||||
},
|
||||
threading: {
|
||||
resolveCurrentChannelId: ({ to, threadId }) =>
|
||||
threadId == null ? to : `${to}:topic:${threadId}`,
|
||||
},
|
||||
};
|
||||
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
||||
"send-test-source-message-action-mirror",
|
||||
);
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-1" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
message: "visible source reply",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-source-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
text: "visible source reply",
|
||||
mediaUrls: undefined,
|
||||
idempotencyKey: "idem-source-message-action",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors accepted source send text aliases", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-content-1" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
content: "visible content alias reply",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-content-source-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
text: "visible content alias reply",
|
||||
mediaUrls: undefined,
|
||||
idempotencyKey: "idem-content-source-message-action",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps delivered source sends successful when transcript mirroring fails", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-mirror-failed" }),
|
||||
);
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockRejectedValueOnce(
|
||||
new Error("transcript unavailable"),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
message: "visible source reply",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-source-message-action-mirror-failed",
|
||||
});
|
||||
|
||||
const call = firstRespondCall(respond);
|
||||
expect(call[0]).toBe(true);
|
||||
expect(call[1]).toEqual({ ok: true, messageId: "tg-mirror-failed" });
|
||||
expect(call[2]).toBeUndefined();
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("mirrors caption-only source sends with media", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-caption-1" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
caption: "visible media caption",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-caption-source-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
text: "visible media caption",
|
||||
mediaUrls: ["https://example.com/image.png"],
|
||||
idempotencyKey: "idem-caption-source-message-action",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors presentation-only source-conversation message.action sends", async () => {
|
||||
const telegramPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram source send rich transcript mirror test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ enabled: true }),
|
||||
isConfigured: () => true,
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction: async () => jsonResult({ ok: true, messageId: "tg-rich-1" }),
|
||||
},
|
||||
threading: {
|
||||
resolveCurrentChannelId: ({ to, threadId }) =>
|
||||
threadId == null ? to : `${to}:topic:${threadId}`,
|
||||
},
|
||||
};
|
||||
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
||||
"send-test-rich-source-message-action-mirror",
|
||||
);
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-rich-1" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
presentation: {
|
||||
title: "Approval needed",
|
||||
blocks: [
|
||||
{ type: "text", text: "Review the deployment request" },
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "Approve", value: "approve" },
|
||||
{ label: "Reject", value: "reject" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-rich-source-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
text: "Approval needed\nReview the deployment request\nApprove\nReject",
|
||||
mediaUrls: undefined,
|
||||
idempotencyKey: "idem-rich-source-message-action",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors title-only source-conversation presentation sends", async () => {
|
||||
const telegramPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram source send title-only transcript mirror test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ enabled: true }),
|
||||
isConfigured: () => true,
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction: async () => jsonResult({ ok: true, messageId: "tg-title-1" }),
|
||||
},
|
||||
};
|
||||
mocks.getChannelPlugin.mockReturnValue(telegramPlugin);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
||||
"send-test-title-only-source-message-action-mirror",
|
||||
);
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-title-1" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
presentation: {
|
||||
title: "Title-only approval",
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-title-only-source-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
text: "Title-only approval",
|
||||
mediaUrls: undefined,
|
||||
idempotencyKey: "idem-title-only-source-message-action",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors auto-threaded Telegram source sends into the topic transcript", async () => {
|
||||
const telegramTopicPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram topic source send transcript mirror test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ enabled: true }),
|
||||
isConfigured: () => true,
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction: async () => jsonResult({ ok: true, messageId: "tg-topic-1" }),
|
||||
},
|
||||
threading: {
|
||||
resolveCurrentChannelId: ({ to, threadId }) =>
|
||||
threadId == null ? to : `${to}:topic:${threadId}`,
|
||||
},
|
||||
};
|
||||
mocks.getChannelPlugin.mockReturnValue(telegramTopicPlugin);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramTopicPlugin }]),
|
||||
"send-test-topic-source-message-action-mirror",
|
||||
);
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-topic-1" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
message: "visible topic source reply",
|
||||
messageThreadId: "77",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:group:chat-123:topic:77",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123:topic:77",
|
||||
currentThreadTs: "77",
|
||||
},
|
||||
idempotencyKey: "idem-topic-source-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:group:chat-123:topic:77",
|
||||
text: "visible topic source reply",
|
||||
mediaUrls: undefined,
|
||||
idempotencyKey: "idem-topic-source-message-action",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mirror topic context when delivery params target the parent chat", async () => {
|
||||
const telegramTopicPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram parent send transcript mirror test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ enabled: true }),
|
||||
isConfigured: () => true,
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
handleAction: async () => jsonResult({ ok: true, messageId: "tg-parent-1" }),
|
||||
},
|
||||
threading: {
|
||||
resolveCurrentChannelId: ({ to, threadId }) =>
|
||||
threadId == null ? to : `${to}:topic:${threadId}`,
|
||||
},
|
||||
};
|
||||
mocks.getChannelPlugin.mockReturnValue(telegramTopicPlugin);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramTopicPlugin }]),
|
||||
"send-test-topic-context-parent-message-action-mirror",
|
||||
);
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-parent-1" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
message: "visible parent source reply",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:group:chat-123:topic:77",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123:topic:77",
|
||||
currentThreadTs: "77",
|
||||
},
|
||||
idempotencyKey: "idem-topic-context-parent-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mirror message.action sends to a different target", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: true, messageId: "tg-external" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "other-chat",
|
||||
message: "external visible reply",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-external-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mirror explicitly failed message.action sends", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValueOnce(
|
||||
jsonResult({ ok: false, error: "delivery failed" }),
|
||||
);
|
||||
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat-123",
|
||||
message: "failed source reply",
|
||||
},
|
||||
sessionKey: "agent:main:telegram:direct:chat-123",
|
||||
agentId: "main",
|
||||
toolContext: {
|
||||
currentChannelProvider: "telegram",
|
||||
currentChannelId: "chat-123",
|
||||
},
|
||||
idempotencyKey: "idem-failed-message-action",
|
||||
});
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes agent-scoped media roots to gateway message actions", async () => {
|
||||
const mediaActionPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
projectOutboundPayloadPlanForMirror,
|
||||
} from "../../infra/outbound/payloads.js";
|
||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||
import { mirrorDeliveredSourceReplyToTranscript } from "../../infra/outbound/source-reply-mirror.js";
|
||||
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
|
||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||
import { extractToolPayload } from "../../infra/outbound/tool-payload.js";
|
||||
@@ -264,6 +265,21 @@ function createGatewayInflightUnavailableFailure(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function mirrorDeliveredSourceReplyToTranscriptBestEffort(params: {
|
||||
context: GatewayRequestContext;
|
||||
mirror: Parameters<typeof mirrorDeliveredSourceReplyToTranscript>[0];
|
||||
}) {
|
||||
try {
|
||||
await mirrorDeliveredSourceReplyToTranscript(params.mirror);
|
||||
} catch (err) {
|
||||
params.context.logGateway?.warn?.("Source reply transcript mirror failed after delivery.", {
|
||||
error: formatForLog(err),
|
||||
channel: params.mirror.channel,
|
||||
sessionKey: params.mirror.sessionKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sendHandlers: GatewayRequestHandlers = {
|
||||
"message.action": async ({ params, respond, context, client }) => {
|
||||
const p = params;
|
||||
@@ -371,6 +387,24 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
return { ok: false, error, meta: { channel } };
|
||||
}
|
||||
const payload = extractToolPayload(handled);
|
||||
const sessionKey = normalizeOptionalString(request.sessionKey) ?? undefined;
|
||||
const agentId =
|
||||
normalizeOptionalString(request.agentId) ??
|
||||
(sessionKey ? resolveSessionAgentId({ sessionKey, config: cfg }) : undefined);
|
||||
await mirrorDeliveredSourceReplyToTranscriptBestEffort({
|
||||
context,
|
||||
mirror: {
|
||||
action: request.action,
|
||||
channel,
|
||||
actionParams: request.params,
|
||||
cfg,
|
||||
sessionKey,
|
||||
agentId,
|
||||
toolContext: request.toolContext,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
deliveredPayload: payload,
|
||||
},
|
||||
});
|
||||
return createGatewayInflightSuccess({ context, dedupeKey, payload, channel });
|
||||
} catch (err) {
|
||||
return createGatewayInflightUnavailableFailure({ context, dedupeKey, channel, err });
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
hasMessagePresentationBlocks,
|
||||
hasReplyChannelData,
|
||||
hasReplyPayloadContent,
|
||||
normalizeInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
type InteractiveReply,
|
||||
type MessagePresentation,
|
||||
type ReplyPayloadDelivery,
|
||||
@@ -63,6 +65,86 @@ export type OutboundPayloadMirror = {
|
||||
mediaUrls: string[];
|
||||
};
|
||||
|
||||
function collectPresentationMirrorText(presentation: MessagePresentation | undefined): string[] {
|
||||
if (!presentation) {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (presentation.title?.trim()) {
|
||||
lines.push(presentation.title.trim());
|
||||
}
|
||||
for (const block of presentation.blocks) {
|
||||
if ((block.type === "text" || block.type === "context") && block.text.trim()) {
|
||||
lines.push(block.text.trim());
|
||||
continue;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
for (const button of block.buttons) {
|
||||
if (button.label.trim()) {
|
||||
lines.push(button.label.trim());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "select") {
|
||||
if (block.placeholder?.trim()) {
|
||||
lines.push(block.placeholder.trim());
|
||||
}
|
||||
for (const option of block.options) {
|
||||
if (option.label.trim()) {
|
||||
lines.push(option.label.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function collectInteractiveMirrorText(interactive: InteractiveReply | undefined): string[] {
|
||||
if (!interactive) {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (const block of interactive.blocks) {
|
||||
if (block.type === "text" && block.text.trim()) {
|
||||
lines.push(block.text.trim());
|
||||
continue;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
for (const button of block.buttons) {
|
||||
if (button.label.trim()) {
|
||||
lines.push(button.label.trim());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "select") {
|
||||
if (block.placeholder?.trim()) {
|
||||
lines.push(block.placeholder.trim());
|
||||
}
|
||||
for (const option of block.options) {
|
||||
if (option.label.trim()) {
|
||||
lines.push(option.label.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function resolveOutboundMirrorText(entry: OutboundPayloadPlan): string {
|
||||
const text = entry.parts.text.trim() ? entry.parts.text : entry.payload.text;
|
||||
if (text?.trim()) {
|
||||
return text;
|
||||
}
|
||||
const presentation = normalizeMessagePresentation(entry.payload.presentation);
|
||||
const interactive = normalizeInteractiveReply(entry.payload.interactive);
|
||||
return [
|
||||
...collectPresentationMirrorText(presentation),
|
||||
...collectInteractiveMirrorText(interactive),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isSuppressedRelayStatusText(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
@@ -265,7 +347,7 @@ export function projectOutboundPayloadPlanForMirror(
|
||||
): OutboundPayloadMirror {
|
||||
return {
|
||||
text: plan
|
||||
.map((entry) => entry.payload.text)
|
||||
.map(resolveOutboundMirrorText)
|
||||
.filter((text): text is string => Boolean(text))
|
||||
.join("\n"),
|
||||
mediaUrls: plan.flatMap((entry) => entry.parts.mediaUrls),
|
||||
|
||||
165
src/infra/outbound/source-reply-mirror.ts
Normal file
165
src/infra/outbound/source-reply-mirror.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../../channels/plugins/types.public.js";
|
||||
import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "../../shared/string-coerce.js";
|
||||
import { createOutboundPayloadPlan, projectOutboundPayloadPlanForMirror } from "./payloads.js";
|
||||
|
||||
type SourceReplyTranscriptMirrorParams = {
|
||||
action: string;
|
||||
channel: string;
|
||||
actionParams: Record<string, unknown>;
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
idempotencyKey?: string;
|
||||
deliveredPayload?: unknown;
|
||||
};
|
||||
|
||||
type MirrorableSourceReplyTranscriptParams = SourceReplyTranscriptMirrorParams & {
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
function readStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value
|
||||
.map((entry) => normalizeOptionalString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return normalized.length ? normalized : undefined;
|
||||
}
|
||||
|
||||
function readFirstString(
|
||||
params: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = normalizeOptionalString(params[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveSourceReplyTarget(params: Record<string, unknown>): string | undefined {
|
||||
return readFirstString(params, ["target", "to", "channelId", "chatId"]);
|
||||
}
|
||||
|
||||
function resolveSourceReplyThreadId(params: SourceReplyTranscriptMirrorParams): string | undefined {
|
||||
return readFirstString(params.actionParams, ["threadId", "messageThreadId"]);
|
||||
}
|
||||
|
||||
function resolveThreadedSourceTarget(
|
||||
params: SourceReplyTranscriptMirrorParams,
|
||||
requestedTarget: string,
|
||||
): string {
|
||||
const threadId = resolveSourceReplyThreadId(params);
|
||||
if (!threadId) {
|
||||
return requestedTarget;
|
||||
}
|
||||
return (
|
||||
normalizeOptionalString(
|
||||
getChannelPlugin(params.channel as ChannelId)?.threading?.resolveCurrentChannelId?.({
|
||||
to: requestedTarget,
|
||||
threadId,
|
||||
}),
|
||||
) ?? requestedTarget
|
||||
);
|
||||
}
|
||||
|
||||
function hasExplicitDeliveryFailure(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return false;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.ok === false) {
|
||||
return true;
|
||||
}
|
||||
const status = normalizeOptionalLowercaseString(record.status);
|
||||
if (status === "failed" || status === "error") {
|
||||
return true;
|
||||
}
|
||||
const deliveryStatus = normalizeOptionalLowercaseString(record.deliveryStatus);
|
||||
return deliveryStatus === "failed" || deliveryStatus === "error";
|
||||
}
|
||||
|
||||
function isCurrentSourceConversation(
|
||||
params: SourceReplyTranscriptMirrorParams,
|
||||
): params is MirrorableSourceReplyTranscriptParams {
|
||||
if (params.action !== "send") {
|
||||
return false;
|
||||
}
|
||||
if (!params.sessionKey?.trim()) {
|
||||
return false;
|
||||
}
|
||||
const currentChannel = normalizeOptionalLowercaseString(
|
||||
params.toolContext?.currentChannelProvider,
|
||||
);
|
||||
if (!currentChannel || currentChannel !== normalizeOptionalLowercaseString(params.channel)) {
|
||||
return false;
|
||||
}
|
||||
const currentTarget = normalizeOptionalString(params.toolContext?.currentChannelId);
|
||||
if (!currentTarget) {
|
||||
return false;
|
||||
}
|
||||
const requestedTarget = resolveSourceReplyTarget(params.actionParams);
|
||||
if (!requestedTarget) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
requestedTarget === currentTarget ||
|
||||
resolveThreadedSourceTarget(params, requestedTarget) === currentTarget
|
||||
);
|
||||
}
|
||||
|
||||
export async function mirrorDeliveredSourceReplyToTranscript(
|
||||
params: SourceReplyTranscriptMirrorParams,
|
||||
): Promise<boolean> {
|
||||
if (hasExplicitDeliveryFailure(params.deliveredPayload)) {
|
||||
return false;
|
||||
}
|
||||
if (!isCurrentSourceConversation(params)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const plan = createOutboundPayloadPlan([
|
||||
{
|
||||
text: readFirstString(params.actionParams, ["message", "content", "text", "caption"]) ?? "",
|
||||
mediaUrl: readFirstString(params.actionParams, [
|
||||
"mediaUrl",
|
||||
"media",
|
||||
"path",
|
||||
"filePath",
|
||||
"fileUrl",
|
||||
]),
|
||||
mediaUrls: readStringArray(params.actionParams.mediaUrls),
|
||||
presentation: params.actionParams.presentation as ReplyPayload["presentation"],
|
||||
interactive: params.actionParams.interactive as ReplyPayload["interactive"],
|
||||
channelData: params.actionParams.channelData as ReplyPayload["channelData"],
|
||||
},
|
||||
]);
|
||||
const mirror = projectOutboundPayloadPlanForMirror(plan);
|
||||
if (!mirror.text && mirror.mediaUrls.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
text: mirror.text,
|
||||
mediaUrls: mirror.mediaUrls.length ? mirror.mediaUrls : undefined,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
config: params.cfg,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user