mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
This commit is contained in:
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
|
||||
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
|
||||
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
|
||||
- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.
|
||||
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
|
||||
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
|
||||
- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs.
|
||||
|
||||
@@ -148,7 +148,7 @@ Details: [Thinking + reasoning directives](/tools/thinking) and [Token use](/tok
|
||||
|
||||
Outbound message formatting is centralized in `messages`:
|
||||
|
||||
- `messages.responsePrefix` (outbound prefix) and `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix)
|
||||
- `messages.responsePrefix`, `channels.<channel>.responsePrefix`, and `channels.<channel>.accounts.<id>.responsePrefix` (outbound prefix cascade), plus `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix)
|
||||
- Reply threading via `replyToMode` and per-channel defaults
|
||||
|
||||
Details: [Configuration](/gateway/configuration#messages) and channel docs.
|
||||
|
||||
@@ -1517,6 +1517,25 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context
|
||||
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
|
||||
streaming, final replies) across channels unless already present.
|
||||
|
||||
Overrides can be configured per channel and per account:
|
||||
|
||||
- `channels.<channel>.responsePrefix`
|
||||
- `channels.<channel>.accounts.<id>.responsePrefix`
|
||||
|
||||
Resolution order (most specific wins):
|
||||
|
||||
1. `channels.<channel>.accounts.<id>.responsePrefix`
|
||||
2. `channels.<channel>.responsePrefix`
|
||||
3. `messages.responsePrefix`
|
||||
|
||||
Semantics:
|
||||
|
||||
- `undefined` falls through to the next level.
|
||||
- `""` explicitly disables the prefix and stops the cascade.
|
||||
- `"auto"` derives `[{identity.name}]` for the routed agent.
|
||||
|
||||
Overrides apply to all channels, including extensions, and to every outbound reply kind.
|
||||
|
||||
If `messages.responsePrefix` is unset, no prefix is applied by default. WhatsApp self-chat
|
||||
replies are the exception: they default to `[{identity.name}]` when set, otherwise
|
||||
`[openclaw]`, so same-phone conversations stay legible.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
logAckFailure,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
@@ -2173,10 +2174,17 @@ async function processMessage(
|
||||
}, typingRestartDelayMs);
|
||||
};
|
||||
try {
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload, info) => {
|
||||
const rawReplyToId =
|
||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
@@ -2288,6 +2296,7 @@ async function processMessage(
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
|
||||
@@ -37,6 +37,7 @@ const FeishuAccountSchema = z
|
||||
blockStreaming: z.boolean().optional(),
|
||||
streaming: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk";
|
||||
import { createReplyPrefixOptions, resolveMentionGatingWithBypass } from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
GoogleChatAnnotation,
|
||||
GoogleChatAttachment,
|
||||
@@ -725,10 +725,18 @@ async function processMessageWithPipeline(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverGoogleChatReply({
|
||||
payload,
|
||||
@@ -749,6 +757,9 @@ async function processMessageWithPipeline(params: {
|
||||
);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export const MatrixConfigSchema = z.object({
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
textChunkLimit: z.number().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
formatAllowlistMatchMeta,
|
||||
logInboundDrop,
|
||||
@@ -579,7 +579,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
channel: "matrix",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "matrix",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: () => sendTypingMatrix(roomId, true, undefined, client),
|
||||
stop: () => sendTypingMatrix(roomId, false, undefined, client),
|
||||
@@ -604,8 +609,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deliverMatrixReplies({
|
||||
@@ -635,7 +639,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: roomConfig?.skills,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
@@ -71,6 +71,8 @@ export type MatrixConfig = {
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Auto-join invites (always|allowlist|off). Default: always. */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mattermostPlugin } from "./channel.js";
|
||||
|
||||
@@ -44,5 +46,27 @@ describe("mattermostPlugin", () => {
|
||||
});
|
||||
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
|
||||
});
|
||||
|
||||
it("uses account responsePrefix overrides", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
responsePrefix: "[Channel]",
|
||||
accounts: {
|
||||
default: { responsePrefix: "[Account]" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prefixContext = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
channel: "mattermost",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(prefixContext.responsePrefix).toBe("[Account]");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ const MattermostAccountSchemaBase = z
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
@@ -760,7 +760,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: () => sendTypingIndicator(channelId, threadRootId),
|
||||
@@ -775,8 +780,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
@@ -825,7 +829,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
@@ -42,6 +42,8 @@ export type MattermostAccountConfig = {
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type MattermostConfig = {
|
||||
|
||||
@@ -493,6 +493,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
runtime,
|
||||
log,
|
||||
adapter,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
logTypingFailure,
|
||||
resolveChannelMediaMaxBytes,
|
||||
@@ -26,6 +26,7 @@ import { getMSTeamsRuntime } from "./runtime.js";
|
||||
export function createMSTeamsReplyDispatcher(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
log: MSTeamsMonitorLogger;
|
||||
adapter: MSTeamsAdapter;
|
||||
@@ -55,16 +56,17 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
});
|
||||
},
|
||||
});
|
||||
const prefixContext = createReplyPrefixContext({
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "msteams",
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
deliver: async (payload) => {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
@@ -124,7 +126,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
|
||||
replyOptions: { ...replyOptions, onModelSelected },
|
||||
markDispatchIdle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export const NextcloudTalkAccountSchemaBase = z
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
logInboundDrop,
|
||||
resolveControlCommandGate,
|
||||
type OpenClawConfig,
|
||||
@@ -285,10 +286,18 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config as OpenClawConfig,
|
||||
agentId: route.agentId,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config as OpenClawConfig,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverNextcloudTalkReply({
|
||||
payload: payload as {
|
||||
@@ -308,6 +317,7 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter: roomConfig?.skills,
|
||||
onModelSelected,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
|
||||
@@ -68,6 +68,8 @@ export type NextcloudTalkAccountConfig = {
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Media upload max size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const TlonAccountSchema = z.object({
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
showModelSignature: z.boolean().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TlonConfigSchema = z.object({
|
||||
@@ -35,6 +36,7 @@ export const TlonConfigSchema = z.object({
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
showModelSignature: z.boolean().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
authorization: TlonAuthorizationSchema.optional(),
|
||||
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
||||
accounts: z.record(z.string(), TlonAccountSchema).optional(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { format } from "node:util";
|
||||
import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
|
||||
import { getTlonRuntime } from "../runtime.js";
|
||||
import { normalizeShip, parseChannelNest } from "../targets.js";
|
||||
import { resolveTlonAccount } from "../types.js";
|
||||
@@ -28,6 +29,29 @@ type ChannelAuthorization = {
|
||||
allowedShips?: string[];
|
||||
};
|
||||
|
||||
type UrbitMemo = {
|
||||
author?: string;
|
||||
content?: unknown;
|
||||
sent?: number;
|
||||
};
|
||||
|
||||
type UrbitUpdate = {
|
||||
id?: string | number;
|
||||
response?: {
|
||||
add?: { memo?: UrbitMemo };
|
||||
post?: {
|
||||
id?: string | number;
|
||||
"r-post"?: {
|
||||
set?: { essay?: UrbitMemo };
|
||||
reply?: {
|
||||
id?: string | number;
|
||||
"r-reply"?: { set?: { memo?: UrbitMemo } };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function resolveChannelAuthorization(
|
||||
cfg: OpenClawConfig,
|
||||
channelNest: string,
|
||||
@@ -120,15 +144,14 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const handleIncomingDM = async (update: any) => {
|
||||
const handleIncomingDM = async (update: UrbitUpdate) => {
|
||||
try {
|
||||
const memo = update?.response?.add?.memo;
|
||||
if (!memo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = update.id as string | undefined;
|
||||
const messageId = update.id != null ? String(update.id) : undefined;
|
||||
if (!processedTracker.mark(messageId)) {
|
||||
return;
|
||||
}
|
||||
@@ -160,25 +183,24 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => {
|
||||
const handleIncomingGroupMessage = (channelNest: string) => async (update: UrbitUpdate) => {
|
||||
try {
|
||||
const parsed = parseChannelNest(channelNest);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const essay = update?.response?.post?.["r-post"]?.set?.essay;
|
||||
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
|
||||
const post = update?.response?.post?.["r-post"];
|
||||
const essay = post?.set?.essay;
|
||||
const memo = post?.reply?.["r-reply"]?.set?.memo;
|
||||
if (!essay && !memo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = memo || essay;
|
||||
const isThreadReply = Boolean(memo);
|
||||
const messageId = isThreadReply
|
||||
? update?.response?.post?.["r-post"]?.reply?.id
|
||||
: update?.response?.post?.id;
|
||||
const rawMessageId = isThreadReply ? post?.reply?.id : update?.response?.post?.id;
|
||||
const messageId = rawMessageId != null ? String(rawMessageId) : undefined;
|
||||
|
||||
if (!processedTracker.mark(messageId)) {
|
||||
return;
|
||||
@@ -355,17 +377,19 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
|
||||
const dispatchStartTime = Date.now();
|
||||
|
||||
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
route.agentId,
|
||||
).responsePrefix;
|
||||
agentId: route.agentId,
|
||||
channel: "tlon",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix,
|
||||
...prefixOptions,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
let replyText = payload.text;
|
||||
@@ -408,6 +432,9 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
|
||||
);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ const TwitchAccountSchema = z.object({
|
||||
allowedRoles: z.array(TwitchRoleSchema).optional(),
|
||||
/** Require @mention to trigger bot responses */
|
||||
requireMention: z.boolean().optional(),
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix: z.string().optional(),
|
||||
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
|
||||
clientSecret: z.string().optional(),
|
||||
/** Refresh token (required for automatic token refresh) */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
|
||||
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
import { checkTwitchAccessControl } from "./access-control.js";
|
||||
import { getOrCreateClientManager } from "./client-manager-registry.js";
|
||||
@@ -103,11 +104,18 @@ async function processTwitchMessage(params: {
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
});
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverTwitchReply({
|
||||
payload,
|
||||
@@ -121,6 +129,9 @@ async function processTwitchMessage(params: {
|
||||
});
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface TwitchAccountConfig {
|
||||
allowedRoles?: TwitchRole[];
|
||||
/** Require @mention to trigger bot responses */
|
||||
requireMention?: boolean;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
|
||||
clientSecret?: string;
|
||||
/** Refresh token (required for automatic token refresh) */
|
||||
|
||||
@@ -16,6 +16,7 @@ const zaloAccountSchema = z.object({
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
proxy: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZaloConfigSchema = zaloAccountSchema.extend({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
|
||||
import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
ZaloApiError,
|
||||
@@ -583,11 +584,18 @@ async function processMessageWithPipeline(params: {
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverZaloReply({
|
||||
payload,
|
||||
@@ -606,6 +614,9 @@ async function processMessageWithPipeline(params: {
|
||||
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export type ZaloAccountConfig = {
|
||||
mediaMaxMb?: number;
|
||||
/** Proxy URL for API requests. */
|
||||
proxy?: string;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZaloConfig = {
|
||||
|
||||
@@ -19,6 +19,7 @@ const zalouserAccountSchema = z.object({
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groups: z.object({}).catchall(groupConfigSchema).optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZalouserConfigSchema = zalouserAccountSchema.extend({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk";
|
||||
import { createReplyPrefixOptions, mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
@@ -334,10 +334,18 @@ async function processMessage(
|
||||
},
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverZalouserReply({
|
||||
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
||||
@@ -360,6 +368,9 @@ async function processMessage(
|
||||
runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export type ZalouserAccountConfig = {
|
||||
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
|
||||
>;
|
||||
messagePrefix?: string;
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZalouserConfig = {
|
||||
@@ -95,6 +96,7 @@ export type ZalouserConfig = {
|
||||
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
|
||||
>;
|
||||
messagePrefix?: string;
|
||||
responsePrefix?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
|
||||
300
src/agents/identity.per-channel-prefix.test.ts
Normal file
300
src/agents/identity.per-channel-prefix.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveResponsePrefix, resolveEffectiveMessagesConfig } from "./identity.js";
|
||||
|
||||
const makeConfig = <T extends OpenClawConfig>(cfg: T) => cfg;
|
||||
|
||||
describe("resolveResponsePrefix with per-channel override", () => {
|
||||
// ─── Backward compatibility ─────────────────────────────────────────
|
||||
|
||||
describe("backward compatibility (no channel param)", () => {
|
||||
it("returns undefined when no prefix configured anywhere", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
expect(resolveResponsePrefix(cfg, "main")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns global prefix when set", () => {
|
||||
const cfg: OpenClawConfig = { messages: { responsePrefix: "[Bot] " } };
|
||||
expect(resolveResponsePrefix(cfg, "main")).toBe("[Bot] ");
|
||||
});
|
||||
|
||||
it("resolves 'auto' to identity name at global level", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", identity: { name: "TestBot" } }],
|
||||
},
|
||||
messages: { responsePrefix: "auto" },
|
||||
};
|
||||
expect(resolveResponsePrefix(cfg, "main")).toBe("[TestBot]");
|
||||
});
|
||||
|
||||
it("returns empty string when global prefix is explicitly empty", () => {
|
||||
const cfg: OpenClawConfig = { messages: { responsePrefix: "" } };
|
||||
expect(resolveResponsePrefix(cfg, "main")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Channel-level prefix ──────────────────────────────────────────
|
||||
|
||||
describe("channel-level prefix", () => {
|
||||
it("returns channel prefix when set, ignoring global", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
whatsapp: { responsePrefix: "[WA] " },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[WA] ");
|
||||
});
|
||||
|
||||
it("falls through to global when channel prefix is undefined", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
whatsapp: {},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[Global] ");
|
||||
});
|
||||
|
||||
it("channel empty string stops cascade (no global prefix applied)", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
telegram: { responsePrefix: "" },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBe("");
|
||||
});
|
||||
|
||||
it("resolves 'auto' at channel level to identity name", () => {
|
||||
const cfg = makeConfig({
|
||||
agents: {
|
||||
list: [{ id: "main", identity: { name: "MyBot" } }],
|
||||
},
|
||||
channels: {
|
||||
whatsapp: { responsePrefix: "auto" },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[MyBot]");
|
||||
});
|
||||
|
||||
it("different channels get different prefixes", () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: { responsePrefix: "[WA Bot] " },
|
||||
telegram: { responsePrefix: "" },
|
||||
discord: { responsePrefix: "🤖 " },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[WA Bot] ");
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBe("");
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "discord" })).toBe("🤖 ");
|
||||
});
|
||||
|
||||
it("returns undefined when channel not in config", () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: { responsePrefix: "[WA] " },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Account-level prefix ─────────────────────────────────────────
|
||||
|
||||
describe("account-level prefix", () => {
|
||||
it("returns account prefix when set, ignoring channel and global", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
whatsapp: {
|
||||
responsePrefix: "[WA] ",
|
||||
accounts: {
|
||||
business: { responsePrefix: "[Biz] " },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }),
|
||||
).toBe("[Biz] ");
|
||||
});
|
||||
|
||||
it("falls through to channel prefix when account prefix is undefined", () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
responsePrefix: "[WA] ",
|
||||
accounts: {
|
||||
business: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }),
|
||||
).toBe("[WA] ");
|
||||
});
|
||||
|
||||
it("falls through to global when both account and channel are undefined", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
business: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }),
|
||||
).toBe("[Global] ");
|
||||
});
|
||||
|
||||
it("account empty string stops cascade", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
whatsapp: {
|
||||
responsePrefix: "[WA] ",
|
||||
accounts: {
|
||||
business: { responsePrefix: "" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("resolves 'auto' at account level to identity name", () => {
|
||||
const cfg = makeConfig({
|
||||
agents: {
|
||||
list: [{ id: "main", identity: { name: "BizBot" } }],
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
business: { responsePrefix: "auto" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }),
|
||||
).toBe("[BizBot]");
|
||||
});
|
||||
|
||||
it("different accounts on same channel get different prefixes", () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
responsePrefix: "[WA] ",
|
||||
accounts: {
|
||||
business: { responsePrefix: "[Biz] " },
|
||||
personal: { responsePrefix: "[Personal] " },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }),
|
||||
).toBe("[Biz] ");
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "personal" }),
|
||||
).toBe("[Personal] ");
|
||||
});
|
||||
|
||||
it("unknown accountId falls through to channel level", () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
responsePrefix: "[WA] ",
|
||||
accounts: {
|
||||
business: { responsePrefix: "[Biz] " },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
expect(
|
||||
resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "unknown" }),
|
||||
).toBe("[WA] ");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Full cascade ─────────────────────────────────────────────────
|
||||
|
||||
describe("full 4-level cascade", () => {
|
||||
const fullCfg = makeConfig({
|
||||
agents: {
|
||||
list: [{ id: "main", identity: { name: "TestBot" } }],
|
||||
},
|
||||
messages: { responsePrefix: "[L4-Global] " },
|
||||
channels: {
|
||||
whatsapp: {
|
||||
responsePrefix: "[L2-Channel] ",
|
||||
accounts: {
|
||||
business: { responsePrefix: "[L1-Account] " },
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
telegram: {},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
|
||||
it("L1: account prefix wins when all levels set", () => {
|
||||
expect(
|
||||
resolveResponsePrefix(fullCfg, "main", { channel: "whatsapp", accountId: "business" }),
|
||||
).toBe("[L1-Account] ");
|
||||
});
|
||||
|
||||
it("L2: channel prefix when account undefined", () => {
|
||||
expect(
|
||||
resolveResponsePrefix(fullCfg, "main", { channel: "whatsapp", accountId: "default" }),
|
||||
).toBe("[L2-Channel] ");
|
||||
});
|
||||
|
||||
it("L4: global prefix when channel has no prefix", () => {
|
||||
expect(resolveResponsePrefix(fullCfg, "main", { channel: "telegram" })).toBe("[L4-Global] ");
|
||||
});
|
||||
|
||||
it("undefined: no prefix at any level", () => {
|
||||
const cfg = makeConfig({
|
||||
channels: { telegram: {} },
|
||||
} satisfies OpenClawConfig);
|
||||
expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveEffectiveMessagesConfig integration ────────────────────
|
||||
|
||||
describe("resolveEffectiveMessagesConfig with channel context", () => {
|
||||
it("passes channel context through to responsePrefix resolution", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
whatsapp: { responsePrefix: "[WA] " },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
const result = resolveEffectiveMessagesConfig(cfg, "main", {
|
||||
channel: "whatsapp",
|
||||
});
|
||||
expect(result.responsePrefix).toBe("[WA] ");
|
||||
});
|
||||
|
||||
it("uses global when no channel context provided", () => {
|
||||
const cfg = makeConfig({
|
||||
messages: { responsePrefix: "[Global] " },
|
||||
channels: {
|
||||
whatsapp: { responsePrefix: "[WA] " },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
const result = resolveEffectiveMessagesConfig(cfg, "main");
|
||||
expect(result.responsePrefix).toBe("[Global] ");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,49 @@ export function resolveMessagePrefix(
|
||||
return resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[openclaw]";
|
||||
}
|
||||
|
||||
export function resolveResponsePrefix(cfg: OpenClawConfig, agentId: string): string | undefined {
|
||||
/** Helper to extract a channel config value by dynamic key. */
|
||||
function getChannelConfig(
|
||||
cfg: OpenClawConfig,
|
||||
channel: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const value = channels?.[channel];
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveResponsePrefix(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { channel?: string; accountId?: string },
|
||||
): string | undefined {
|
||||
// L1: Channel account level
|
||||
if (opts?.channel && opts?.accountId) {
|
||||
const channelCfg = getChannelConfig(cfg, opts.channel);
|
||||
const accounts = channelCfg?.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
const accountPrefix = accounts?.[opts.accountId]?.responsePrefix as string | undefined;
|
||||
if (accountPrefix !== undefined) {
|
||||
if (accountPrefix === "auto") {
|
||||
return resolveIdentityNamePrefix(cfg, agentId);
|
||||
}
|
||||
return accountPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
// L2: Channel level
|
||||
if (opts?.channel) {
|
||||
const channelCfg = getChannelConfig(cfg, opts.channel);
|
||||
const channelPrefix = channelCfg?.responsePrefix as string | undefined;
|
||||
if (channelPrefix !== undefined) {
|
||||
if (channelPrefix === "auto") {
|
||||
return resolveIdentityNamePrefix(cfg, agentId);
|
||||
}
|
||||
return channelPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
// L4: Global level
|
||||
const configured = cfg.messages?.responsePrefix;
|
||||
if (configured !== undefined) {
|
||||
if (configured === "auto") {
|
||||
@@ -67,14 +109,22 @@ export function resolveResponsePrefix(cfg: OpenClawConfig, agentId: string): str
|
||||
export function resolveEffectiveMessagesConfig(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { hasAllowFrom?: boolean; fallbackMessagePrefix?: string },
|
||||
opts?: {
|
||||
hasAllowFrom?: boolean;
|
||||
fallbackMessagePrefix?: string;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
},
|
||||
): { messagePrefix: string; responsePrefix?: string } {
|
||||
return {
|
||||
messagePrefix: resolveMessagePrefix(cfg, agentId, {
|
||||
hasAllowFrom: opts?.hasAllowFrom,
|
||||
fallback: opts?.fallbackMessagePrefix,
|
||||
}),
|
||||
responsePrefix: resolveResponsePrefix(cfg, agentId),
|
||||
responsePrefix: resolveResponsePrefix(cfg, agentId, {
|
||||
channel: opts?.channel,
|
||||
accountId: opts?.accountId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ReplyPayload } from "../types.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
|
||||
export type RouteReplyParams = {
|
||||
@@ -56,6 +56,7 @@ export type RouteReplyResult = {
|
||||
*/
|
||||
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
|
||||
const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params;
|
||||
const normalizedChannel = normalizeMessageChannel(channel);
|
||||
|
||||
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
|
||||
const responsePrefix = params.sessionKey
|
||||
@@ -65,6 +66,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
sessionKey: params.sessionKey,
|
||||
config: cfg,
|
||||
}),
|
||||
{ channel: normalizedChannel, accountId },
|
||||
).responsePrefix
|
||||
: cfg.messages?.responsePrefix === "auto"
|
||||
? undefined
|
||||
|
||||
@@ -15,9 +15,16 @@ export type ReplyPrefixContextBundle = {
|
||||
onModelSelected: (ctx: ModelSelectionContext) => void;
|
||||
};
|
||||
|
||||
export type ReplyPrefixOptions = Pick<
|
||||
ReplyPrefixContextBundle,
|
||||
"responsePrefix" | "responsePrefixContextProvider" | "onModelSelected"
|
||||
>;
|
||||
|
||||
export function createReplyPrefixContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
}): ReplyPrefixContextBundle {
|
||||
const { cfg, agentId } = params;
|
||||
const prefixContext: ResponsePrefixContext = {
|
||||
@@ -34,8 +41,22 @@ export function createReplyPrefixContext(params: {
|
||||
|
||||
return {
|
||||
prefixContext,
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId, {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
}).responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
onModelSelected,
|
||||
};
|
||||
}
|
||||
|
||||
export function createReplyPrefixOptions(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
}): ReplyPrefixOptions {
|
||||
const { responsePrefix, responsePrefixContextProvider, onModelSelected } =
|
||||
createReplyPrefixContext(params);
|
||||
return { responsePrefix, responsePrefixContextProvider, onModelSelected };
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ export type DiscordAccountConfig = {
|
||||
intents?: DiscordIntentsConfig;
|
||||
/** PluralKit identity resolution for proxied messages. */
|
||||
pluralkit?: DiscordPluralKitConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
|
||||
@@ -84,6 +84,8 @@ export type FeishuAccountConfig = {
|
||||
retry?: OutboundRetryConfig;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type FeishuConfig = {
|
||||
|
||||
@@ -98,6 +98,8 @@ export type GoogleChatAccountConfig = {
|
||||
* If configured, falls back to message mode with a warning.
|
||||
*/
|
||||
typingIndicator?: "none" | "message" | "reaction";
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type GoogleChatConfig = {
|
||||
|
||||
@@ -71,6 +71,8 @@ export type IMessageAccountConfig = {
|
||||
>;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type IMessageConfig = {
|
||||
|
||||
@@ -106,4 +106,6 @@ export type MSTeamsConfig = {
|
||||
sharePointSiteId?: string;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
@@ -84,6 +84,8 @@ export type SignalAccountConfig = {
|
||||
reactionLevel?: SignalReactionLevel;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type SignalConfig = {
|
||||
|
||||
@@ -142,6 +142,8 @@ export type SlackAccountConfig = {
|
||||
channels?: Record<string, SlackChannelConfig>;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type SlackConfig = {
|
||||
|
||||
@@ -130,6 +130,14 @@ export type TelegramAccountConfig = {
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Controls whether link previews are shown in outbound messages. Default: true. */
|
||||
linkPreview?: boolean;
|
||||
/**
|
||||
* Per-channel outbound response prefix override.
|
||||
*
|
||||
* When set, this takes precedence over the global `messages.responsePrefix`.
|
||||
* Use `""` to explicitly disable a global prefix for this channel.
|
||||
* Use `"auto"` to derive `[{identity.name}]` from the routed agent.
|
||||
*/
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type TelegramTopicConfig = {
|
||||
|
||||
@@ -30,6 +30,14 @@ export type WhatsAppConfig = {
|
||||
* Default: `[{agents.list[].identity.name}]` (or `[openclaw]`) when allowFrom is empty, else `""`.
|
||||
*/
|
||||
messagePrefix?: string;
|
||||
/**
|
||||
* Per-channel outbound response prefix override.
|
||||
*
|
||||
* When set, this takes precedence over the global `messages.responsePrefix`.
|
||||
* Use `""` to explicitly disable a global prefix for this channel.
|
||||
* Use `"auto"` to derive `[{identity.name}]` from the routed agent.
|
||||
*/
|
||||
responsePrefix?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/**
|
||||
@@ -109,6 +117,8 @@ export type WhatsAppAccountConfig = {
|
||||
sendReadReceipts?: boolean;
|
||||
/** Inbound message prefix override for this account (WhatsApp only). */
|
||||
messagePrefix?: string;
|
||||
/** Per-account outbound response prefix override (takes precedence over channel and global). */
|
||||
responsePrefix?: string;
|
||||
/** Override auth directory (Baileys multi-file auth state). */
|
||||
authDir?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
|
||||
@@ -137,6 +137,7 @@ export const TelegramAccountSchemaBase = z
|
||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
linkPreview: z.boolean().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -321,6 +322,7 @@ export const DiscordAccountSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -391,6 +393,7 @@ export const GoogleChatAccountSchema = z
|
||||
.optional(),
|
||||
dm: GoogleChatDmSchema.optional(),
|
||||
typingIndicator: z.enum(["none", "message", "reaction"]).optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -505,6 +508,7 @@ export const SlackAccountSchema = z
|
||||
dm: SlackDmSchema.optional(),
|
||||
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -588,6 +592,7 @@ export const SignalAccountSchemaBase = z
|
||||
.optional(),
|
||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -652,6 +657,7 @@ export const IMessageAccountSchemaBase = z
|
||||
)
|
||||
.optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -731,6 +737,7 @@ export const BlueBubblesAccountSchemaBase = z
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -813,6 +820,7 @@ export const MSTeamsConfigSchema = z
|
||||
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
|
||||
sharePointSiteId: z.string().optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
responsePrefix: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const WhatsAppAccountSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
||||
authDir: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
@@ -84,6 +85,7 @@ export const WhatsAppConfigSchema = z
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
selfChatMode: z.boolean().optional(),
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
groupAllowFrom: z.array(z.string()).optional(),
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
shouldAckReaction as shouldAckReactionGate,
|
||||
} from "../../channels/ack-reactions.js";
|
||||
import { logTypingFailure, logAckFailure } from "../../channels/logging.js";
|
||||
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import { createTypingCallbacks } from "../../channels/typing.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
@@ -334,7 +334,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
? deliverTarget.slice("channel:".length)
|
||||
: message.channelId;
|
||||
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "discord",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
@@ -342,8 +347,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const replyToId = replyReference.use();
|
||||
@@ -389,9 +393,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
typeof discordConfig?.blockStreaming === "boolean"
|
||||
? !discordConfig.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.onModelSelected(ctx);
|
||||
},
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
} from "../../auto-reply/commands-registry.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
|
||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
@@ -790,12 +791,19 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
CommandSource: "native" as const,
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "discord",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
let didReply = false;
|
||||
await dispatchReplyWithDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
try {
|
||||
@@ -828,6 +836,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
typeof discordConfig?.blockStreaming === "boolean"
|
||||
? !discordConfig.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSessionAgentId } from "../agents/agent-scope.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
@@ -302,10 +304,19 @@ export async function processFeishuMessage(
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
};
|
||||
|
||||
const agentId = resolveSessionAgentId({ config: cfg });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
});
|
||||
|
||||
await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload, info) => {
|
||||
const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0);
|
||||
if (!payload.text && !hasMedia) {
|
||||
@@ -391,6 +402,7 @@ export async function processFeishuMessage(
|
||||
},
|
||||
replyOptions: {
|
||||
disableBlockStreaming: !feishuCfg.blockStreaming,
|
||||
onModelSelected,
|
||||
onPartialReply: streamingSession
|
||||
? async (payload) => {
|
||||
if (!streamingSession.isActive() || !payload.text) {
|
||||
|
||||
@@ -5,15 +5,11 @@ import path from "node:path";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js";
|
||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import {
|
||||
extractShortModelName,
|
||||
type ResponsePrefixContext,
|
||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import {
|
||||
@@ -477,13 +473,14 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: p.sessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
identityName: resolveIdentityName(cfg, agentId),
|
||||
};
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
});
|
||||
const finalReplyParts: string[] = [];
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
...prefixOptions,
|
||||
onError: (err) => {
|
||||
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
|
||||
},
|
||||
@@ -512,12 +509,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
onAgentRunStart: () => {
|
||||
agentRunStarted = true;
|
||||
},
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.provider = ctx.provider;
|
||||
prefixContext.model = extractShortModelName(ctx.model);
|
||||
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
||||
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
||||
},
|
||||
onModelSelected,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/re
|
||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
||||
import { logInboundDrop } from "../../channels/logging.js";
|
||||
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
@@ -610,11 +610,15 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
);
|
||||
}
|
||||
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "imessage",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deliverReplies({
|
||||
@@ -642,7 +646,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
typeof accountInfo.config.blockStreaming === "boolean"
|
||||
? !accountInfo.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
if (!queuedFinal) {
|
||||
|
||||
@@ -543,7 +543,10 @@ export async function runHeartbeatOnce(opts: {
|
||||
})
|
||||
: { showOk: false, showAlerts: true, useIndicator: true };
|
||||
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
|
||||
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
|
||||
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId, {
|
||||
channel: delivery.channel !== "none" ? delivery.channel : undefined,
|
||||
accountId: delivery.accountId,
|
||||
}).responsePrefix;
|
||||
|
||||
// Check if this is an exec event with pending exec completion system events.
|
||||
// If so, use a specialized prompt that instructs the model to relay the result
|
||||
|
||||
@@ -25,6 +25,7 @@ const LineAccountConfigSchema = z
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
|
||||
@@ -43,6 +44,7 @@ export const LineConfigSchema = z
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { normalizePluginHttpPath } from "../plugins/http-path.js";
|
||||
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||
@@ -192,12 +192,18 @@ export async function monitorLineProvider(
|
||||
try {
|
||||
const textLimit = 5000; // LINE max message length
|
||||
let replyTokenUsed = false; // Track if we've used the one-time reply token
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "line",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(config, route.agentId).responsePrefix,
|
||||
...prefixOptions,
|
||||
deliver: async (payload, _info) => {
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
|
||||
@@ -249,7 +255,9 @@ export async function monitorLineProvider(
|
||||
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
replyOptions: {},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queuedFinal) {
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface LineConfig {
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
webhookPath?: string;
|
||||
accounts?: Record<string, LineAccountConfig>;
|
||||
@@ -38,6 +40,8 @@ export interface LineAccountConfig {
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
/** Outbound response prefix override for this account. */
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
webhookPath?: string;
|
||||
groups?: Record<string, LineGroupConfig>;
|
||||
|
||||
@@ -148,7 +148,7 @@ export {
|
||||
shouldAckReactionForWhatsApp,
|
||||
} from "../channels/ack-reactions.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
export { createReplyPrefixContext } from "../channels/reply-prefix.js";
|
||||
export { createReplyPrefixContext, createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js";
|
||||
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
||||
export type { NormalizedLocation } from "../channels/location.js";
|
||||
|
||||
@@ -19,7 +19,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j
|
||||
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
||||
import { logInboundDrop, logTypingFailure } from "../../channels/logging.js";
|
||||
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import { createTypingCallbacks } from "../../channels/typing.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
||||
@@ -171,7 +171,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
|
||||
}
|
||||
|
||||
const prefixContext = createReplyPrefixContext({ cfg: deps.cfg, agentId: route.agentId });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: deps.cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "signal",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
@@ -195,8 +200,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deps.deliverReplies({
|
||||
@@ -224,9 +228,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.onModelSelected(ctx);
|
||||
},
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.
|
||||
import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js";
|
||||
import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js";
|
||||
import { logAckFailure, logTypingFailure } from "../../../channels/logging.js";
|
||||
import { createReplyPrefixContext } from "../../../channels/reply-prefix.js";
|
||||
import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js";
|
||||
import { createTypingCallbacks } from "../../../channels/typing.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||
@@ -95,11 +95,15 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
},
|
||||
});
|
||||
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "slack",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
const replyThreadTs = replyPlan.nextThreadTs();
|
||||
@@ -134,9 +138,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.onModelSelected(ctx);
|
||||
},
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@sla
|
||||
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
|
||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import { resolveChunkMode } from "../../auto-reply/chunk.js";
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
@@ -17,6 +16,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
||||
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||
import { resolveConversationLabel } from "../../channels/conversation-label.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { danger, logVerbose } from "../../globals.js";
|
||||
@@ -434,11 +434,18 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
OriginatingTo: `user:${command.user_id}`,
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "slack",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const { counts } = await dispatchReplyWithDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverSlackSlashReplies({
|
||||
replies: [payload],
|
||||
@@ -457,7 +464,10 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
runtime.error?.(danger(`slack slash ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
replyOptions: { skillFilter: channelConfig?.skills },
|
||||
replyOptions: {
|
||||
skillFilter: channelConfig?.skills,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
if (counts.final + counts.tool + counts.block === 0) {
|
||||
await deliverSlackSlashReplies({
|
||||
|
||||
@@ -695,3 +695,7 @@ export const buildTelegramMessageContext = async ({
|
||||
accountId: account.accountId,
|
||||
};
|
||||
};
|
||||
|
||||
export type TelegramMessageContext = NonNullable<
|
||||
Awaited<ReturnType<typeof buildTelegramMessageContext>>
|
||||
>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createTelegramDraftStream = vi.hoisted(() => vi.fn());
|
||||
@@ -72,16 +73,25 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
removeAckAfterReply: false,
|
||||
};
|
||||
|
||||
const bot = { api: { sendMessageDraft: vi.fn() } } as unknown as Bot;
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: () => {
|
||||
throw new Error("exit");
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchTelegramMessage({
|
||||
context,
|
||||
bot: { api: {} },
|
||||
bot,
|
||||
cfg: {},
|
||||
runtime: {},
|
||||
runtime,
|
||||
replyToMode: "first",
|
||||
streamMode: "partial",
|
||||
textLimit: 4096,
|
||||
telegramCfg: {},
|
||||
opts: {},
|
||||
opts: { token: "token" },
|
||||
resolveBotTopicsEnabled,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Bot } from "grammy";
|
||||
import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { TelegramMessageContext } from "./bot-message-context.js";
|
||||
import type { TelegramBotOptions } from "./bot.js";
|
||||
import type { TelegramStreamMode, TelegramContext } from "./bot/types.js";
|
||||
import { resolveAgentDir } from "../agents/agent-scope.js";
|
||||
// @ts-nocheck
|
||||
import {
|
||||
findModelInCatalog,
|
||||
loadModelCatalog,
|
||||
@@ -12,9 +17,8 @@ import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { removeAckReactionAfterReply } from "../channels/ack-reactions.js";
|
||||
import { logAckFailure, logTypingFailure } from "../channels/logging.js";
|
||||
import { createReplyPrefixContext } from "../channels/reply-prefix.js";
|
||||
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
import { createTypingCallbacks } from "../channels/typing.js";
|
||||
import { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { deliverReplies } from "./bot/delivery.js";
|
||||
@@ -38,6 +42,21 @@ async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string)
|
||||
}
|
||||
}
|
||||
|
||||
type ResolveBotTopicsEnabled = (ctx: TelegramContext) => boolean | Promise<boolean>;
|
||||
|
||||
type DispatchTelegramMessageParams = {
|
||||
context: TelegramMessageContext;
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
replyToMode: ReplyToMode;
|
||||
streamMode: TelegramStreamMode;
|
||||
textLimit: number;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
opts: Pick<TelegramBotOptions, "token">;
|
||||
resolveBotTopicsEnabled: ResolveBotTopicsEnabled;
|
||||
};
|
||||
|
||||
export const dispatchTelegramMessage = async ({
|
||||
context,
|
||||
bot,
|
||||
@@ -49,8 +68,7 @@ export const dispatchTelegramMessage = async ({
|
||||
telegramCfg,
|
||||
opts,
|
||||
resolveBotTopicsEnabled,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
}: any) => {
|
||||
}: DispatchTelegramMessageParams) => {
|
||||
const {
|
||||
ctxPayload,
|
||||
primaryCtx,
|
||||
@@ -157,7 +175,12 @@ export const dispatchTelegramMessage = async ({
|
||||
Boolean(draftStream) ||
|
||||
(typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined);
|
||||
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
@@ -202,16 +225,20 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
|
||||
// Cache the description for future encounters
|
||||
cacheSticker({
|
||||
fileId: sticker.fileId,
|
||||
fileUniqueId: sticker.fileUniqueId,
|
||||
emoji: sticker.emoji,
|
||||
setName: sticker.setName,
|
||||
description,
|
||||
cachedAt: new Date().toISOString(),
|
||||
receivedFrom: ctxPayload.From,
|
||||
});
|
||||
logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`);
|
||||
if (sticker.fileId) {
|
||||
cacheSticker({
|
||||
fileId: sticker.fileId,
|
||||
fileUniqueId: sticker.fileUniqueId,
|
||||
emoji: sticker.emoji,
|
||||
setName: sticker.setName,
|
||||
description,
|
||||
cachedAt: new Date().toISOString(),
|
||||
receivedFrom: ctxPayload.From,
|
||||
});
|
||||
logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`);
|
||||
} else {
|
||||
logVerbose(`telegram: skipped sticker cache (missing fileId)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,8 +255,7 @@ export const dispatchTelegramMessage = async ({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
...prefixOptions,
|
||||
deliver: async (payload, info) => {
|
||||
if (info.kind === "final") {
|
||||
await flushDraft();
|
||||
@@ -278,9 +304,7 @@ export const dispatchTelegramMessage = async ({
|
||||
skillFilter,
|
||||
disableBlockStreaming,
|
||||
onPartialReply: draftStream ? (payload) => updateDraftFromPartial(payload.text) : undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.onModelSelected(ctx);
|
||||
},
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
draftStream?.stop();
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
} from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
@@ -24,6 +23,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
||||
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js";
|
||||
import {
|
||||
@@ -547,11 +547,18 @@ export const registerTelegramNativeCommands = ({
|
||||
skippedNonSilent: 0,
|
||||
};
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
...prefixOptions,
|
||||
deliver: async (payload, _info) => {
|
||||
const result = await deliverReplies({
|
||||
replies: [payload],
|
||||
@@ -582,6 +589,7 @@ export const registerTelegramNativeCommands = ({
|
||||
replyOptions: {
|
||||
skillFilter,
|
||||
disableBlockStreaming,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
||||
|
||||
@@ -258,6 +258,44 @@ describe("web auto-reply", () => {
|
||||
expect(reply).toHaveBeenCalledWith("🦞 hello there");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("applies channel responsePrefix override to replies", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"], responsePrefix: "[WA]" } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "[Global]",
|
||||
},
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "hello there" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
expect(reply).toHaveBeenCalledWith("[WA] hello there");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("defaults responsePrefix for self-chat replies when unset", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
agents: {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js";
|
||||
import { toLocationContext } from "../../../channels/location.js";
|
||||
import { createReplyPrefixContext } from "../../../channels/reply-prefix.js";
|
||||
import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js";
|
||||
import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js";
|
||||
import {
|
||||
readSessionUpdatedAt,
|
||||
@@ -255,16 +255,18 @@ export async function processMessage(params: {
|
||||
? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg })
|
||||
: undefined;
|
||||
const configuredResponsePrefix = params.cfg.messages?.responsePrefix;
|
||||
const prefixContext = createReplyPrefixContext({
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: params.cfg,
|
||||
agentId: params.route.agentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
});
|
||||
const isSelfChat =
|
||||
params.msg.chatType !== "group" &&
|
||||
Boolean(params.msg.selfE164) &&
|
||||
normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? "");
|
||||
const responsePrefix =
|
||||
prefixContext.responsePrefix ??
|
||||
prefixOptions.responsePrefix ??
|
||||
(configuredResponsePrefix === undefined && isSelfChat
|
||||
? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[openclaw]")
|
||||
: undefined);
|
||||
@@ -339,8 +341,8 @@ export async function processMessage(params: {
|
||||
cfg: params.cfg,
|
||||
replyResolver: params.replyResolver,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
onHeartbeatStrip: () => {
|
||||
if (!didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
@@ -400,7 +402,7 @@ export async function processMessage(params: {
|
||||
typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean"
|
||||
? !params.cfg.channels.whatsapp.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user