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:
mudrii
2026-02-05 05:16:34 +08:00
committed by GitHub
parent 43590d8287
commit 5d82c82313
63 changed files with 838 additions and 120 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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,
},
});
}

View File

@@ -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(),

View File

@@ -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();

View File

@@ -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. */

View File

@@ -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]");
});
});
});

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -493,6 +493,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
cfg,
agentId: route.agentId,
accountId: route.accountId,
runtime,
log,
adapter,

View File

@@ -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,
};
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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(),

View File

@@ -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,
},
});
};

View File

@@ -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) */

View File

@@ -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,
},
});
}

View File

@@ -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) */

View File

@@ -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({

View File

@@ -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,
},
});
}

View File

@@ -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 = {

View File

@@ -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({

View File

@@ -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,
},
});
}

View File

@@ -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>;
};