diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 136ddc8b2fb..e8fe454509b 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -98f80c92fc4fcb37d41470216ae6cd19b094d7f67b0ddc4983eba04aba314fe0 config-baseline.json -d9c4b2035178d3ffe637b751036f12082d4f26761681bb8496b86550565307e8 config-baseline.core.json -ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json +f68749532fd8da60ac362187d783d92d43747703fa0dcc097eaef1e906eeda75 config-baseline.json +67c7db6eeb7f74dd454118e17304c5486ab59d33e7899c501b003c326d35db0f config-baseline.core.json +eb893b6b096635434b60c22bc1b466d598daf4c86c0c296da5a8b72df44f6021 config-baseline.channel.json 7a9ed89a6ff7e578bfcab7828ab660af59e62402a85bfbfc05d5ae3d975e9728 config-baseline.plugin.json diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 6a59f0b37d7..5729eaf13d5 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -271,6 +271,30 @@ If SIP-disabled isn't acceptable for your threat model: Control commands from authorized senders can bypass mention gating in groups. + Per-group `systemPrompt`: + + Each entry under `channels.imessage.groups.*` accepts an optional `systemPrompt` string. The value is injected into the agent's system prompt on every turn that handles a message in that group. The wildcard `groups["*"]` entry is honored as a fallback when the per-`chat_id` entry has no `systemPrompt`. + + ```json5 + { + channels: { + imessage: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15555550123"], + groups: { + "*": { systemPrompt: "Use British spelling." }, + "8421": { + requireMention: true, + systemPrompt: "This is the on-call rotation chat. Keep replies under 3 sentences.", + }, + }, + }, + }, + } + ``` + + Per-group prompts only apply to group messages — direct messages in this channel are unaffected. + diff --git a/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts b/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts new file mode 100644 index 00000000000..a519b552649 --- /dev/null +++ b/extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts @@ -0,0 +1,203 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { describe, expect, it } from "vitest"; +import { + buildIMessageInboundContext, + resolveIMessageInboundDecision, +} from "./inbound-processing.js"; + +type DecisionParams = Parameters[0]; + +function buildCfgWithGroups( + groups: Record, +): OpenClawConfig { + return { + channels: { + imessage: { + groupPolicy: "allowlist", + groups, + }, + }, + } as unknown as OpenClawConfig; +} + +function buildDecisionParams(overrides: Partial = {}): DecisionParams { + return { + cfg: overrides.cfg ?? ({} as OpenClawConfig), + accountId: "default", + message: { + id: 1, + sender: "+15555550123", + text: "hi", + is_from_me: false, + is_group: true, + chat_id: 7, + chat_guid: "any;+;chatXYZ", + chat_identifier: "chatXYZ", + created_at: "2026-05-08T03:00:00Z", + } as DecisionParams["message"], + messageText: "hi", + bodyText: "hi", + allowFrom: ["+15555550123"], + groupAllowFrom: ["+15555550123"], + groupPolicy: "allowlist", + dmPolicy: "open", + storeAllowFrom: [], + historyLimit: 0, + groupHistories: new Map(), + echoCache: undefined, + selfChatCache: undefined, + logVerbose: undefined, + ...overrides, + }; +} + +describe("resolveIMessageInboundDecision per-group systemPrompt", () => { + it("captures the per-chat_id systemPrompt on group dispatch decisions", () => { + const decision = resolveIMessageInboundDecision( + buildDecisionParams({ + cfg: buildCfgWithGroups({ + "7": { systemPrompt: "Keep responses under 3 sentences." }, + }), + }), + ); + expect(decision.kind).toBe("dispatch"); + if (decision.kind !== "dispatch") return; + expect(decision.groupSystemPrompt).toBe("Keep responses under 3 sentences."); + }); + + it("falls back to the groups['*'] wildcard systemPrompt", () => { + const decision = resolveIMessageInboundDecision( + buildDecisionParams({ + cfg: buildCfgWithGroups({ + "*": { systemPrompt: "Default group voice." }, + }), + }), + ); + expect(decision.kind).toBe("dispatch"); + if (decision.kind !== "dispatch") return; + expect(decision.groupSystemPrompt).toBe("Default group voice."); + }); + + it("prefers the per-chat_id systemPrompt over the wildcard when both are set", () => { + const decision = resolveIMessageInboundDecision( + buildDecisionParams({ + cfg: buildCfgWithGroups({ + "*": { systemPrompt: "Default group voice." }, + "7": { systemPrompt: "Specific group voice." }, + }), + }), + ); + expect(decision.kind).toBe("dispatch"); + if (decision.kind !== "dispatch") return; + expect(decision.groupSystemPrompt).toBe("Specific group voice."); + }); + + it("treats whitespace-only systemPrompt as undefined", () => { + const decision = resolveIMessageInboundDecision( + buildDecisionParams({ + cfg: buildCfgWithGroups({ + "7": { systemPrompt: " " }, + }), + }), + ); + expect(decision.kind).toBe("dispatch"); + if (decision.kind !== "dispatch") return; + expect(decision.groupSystemPrompt).toBeUndefined(); + }); + + it("does not set groupSystemPrompt on true DM decisions", () => { + // Use a chat_id that does NOT match any configured group entry, and + // route through the DM-shaped message (is_group=false, no chat_id key + // in groups). Without a groupConfig match the path stays a DM and the + // group prompt must not bleed into the ctx. + const decision = resolveIMessageInboundDecision( + buildDecisionParams({ + cfg: buildCfgWithGroups({ + "999": { systemPrompt: "Other group." }, + }), + message: { + id: 1, + sender: "+15555550123", + text: "hi", + is_from_me: false, + is_group: false, + chat_id: 42, + chat_identifier: "+15555550123", + destination_caller_id: "+15555550456", + created_at: "2026-05-08T03:00:00Z", + } as DecisionParams["message"], + groupPolicy: "open", + }), + ); + expect(decision.kind).toBe("dispatch"); + if (decision.kind !== "dispatch") return; + expect(decision.isGroup).toBe(false); + expect(decision.groupSystemPrompt).toBeUndefined(); + }); +}); + +describe("buildIMessageInboundContext forwards GroupSystemPrompt", () => { + function buildBuildParams(decision: { + isGroup: boolean; + groupSystemPrompt?: string; + }): Parameters[0] { + return { + cfg: {} as OpenClawConfig, + decision: { + kind: "dispatch", + isGroup: decision.isGroup, + chatId: decision.isGroup ? 7 : undefined, + chatGuid: decision.isGroup ? "any;+;chatXYZ" : "any;-;+15555550123", + chatIdentifier: decision.isGroup ? "chatXYZ" : "+15555550123", + groupId: decision.isGroup ? "7" : undefined, + historyKey: undefined, + sender: "+15555550123", + senderNormalized: "+15555550123", + route: { + accountId: "default", + agentId: "lobster", + sessionKey: "k", + mainSessionKey: "mk", + }, + bodyText: "hi", + createdAt: undefined, + replyContext: null, + effectiveWasMentioned: false, + commandAuthorized: false, + effectiveDmAllowFrom: [], + effectiveGroupAllowFrom: [], + groupSystemPrompt: decision.groupSystemPrompt, + } as Parameters[0]["decision"], + message: { + sender: "+15555550123", + text: "hi", + is_group: decision.isGroup, + chat_id: decision.isGroup ? 7 : undefined, + chat_name: decision.isGroup ? "Test Group" : undefined, + } as Parameters[0]["message"], + historyLimit: 0, + groupHistories: new Map(), + } as Parameters[0]; + } + + it("sets ctxPayload.GroupSystemPrompt for group messages", () => { + const { ctxPayload } = buildIMessageInboundContext( + buildBuildParams({ isGroup: true, groupSystemPrompt: "Be concise." }), + ); + expect(ctxPayload.GroupSystemPrompt).toBe("Be concise."); + }); + + it("leaves ctxPayload.GroupSystemPrompt undefined when no per-group prompt is configured", () => { + const { ctxPayload } = buildIMessageInboundContext( + buildBuildParams({ isGroup: true, groupSystemPrompt: undefined }), + ); + expect(ctxPayload.GroupSystemPrompt).toBeUndefined(); + }); + + it("leaves ctxPayload.GroupSystemPrompt undefined for DMs even if a prompt is somehow on decision", () => { + const { ctxPayload } = buildIMessageInboundContext( + buildBuildParams({ isGroup: false, groupSystemPrompt: "should-not-leak" }), + ); + expect(ctxPayload.GroupSystemPrompt).toBeUndefined(); + }); +}); diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index c21824f4a9f..27897d44473 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -28,6 +28,7 @@ import { resolveDmGroupAccessWithLists, evaluateSupplementalContextVisibility, } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageConversationRoute } from "../conversation-route.js"; @@ -150,6 +151,10 @@ type IMessageInboundDispatchDecision = { // Used for allowlist checks for control commands. effectiveDmAllowFrom: string[]; effectiveGroupAllowFrom: string[]; + // Forwarded as ctxPayload.GroupSystemPrompt for group messages. Resolved + // from `channels.imessage.groups..systemPrompt` (or the `"*"` + // wildcard) at gate time. Always undefined for DMs. + groupSystemPrompt?: string; }; type IMessageInboundDecision = @@ -526,6 +531,19 @@ export function resolveIMessageInboundDecision(params: { return { kind: "drop", reason: "no mention" }; } + // Per-chat_id `systemPrompt` wins; fall back to the `groups["*"]` wildcard + // entry when the matched group has no explicit prompt. Mirrors the per-group + // systemPrompt pattern already supported by Telegram, IRC, Discord, Slack, + // GoogleChat, and the retired BlueBubbles channel. + const groupSystemPrompt = isGroup + ? (normalizeOptionalString( + (groupListPolicy.groupConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt, + ) ?? + normalizeOptionalString( + (groupListPolicy.defaultConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt, + )) + : undefined; + return { kind: "dispatch", isGroup, @@ -544,6 +562,7 @@ export function resolveIMessageInboundDecision(params: { commandAuthorized, effectiveDmAllowFrom, effectiveGroupAllowFrom, + groupSystemPrompt, }; } @@ -665,6 +684,7 @@ export function buildIMessageInboundContext(params: { ChatType: decision.isGroup ? "group" : "direct", ConversationLabel: fromLabel, GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined, + GroupSystemPrompt: decision.isGroup ? decision.groupSystemPrompt : undefined, GroupMembers: decision.isGroup ? (params.message.participants ?? []).filter(Boolean).join(", ") : undefined, diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index bdead06b12a..8ea0f60f26f 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -106,6 +106,14 @@ export type IMessageAccountConfig = { requireMention?: boolean; tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig; + /** + * Per-group system prompt. Injected into the agent's system prompt on + * every turn that handles a message in that group. Matches the shape + * already supported by Discord, Telegram, IRC, Slack, GoogleChat, and + * other group-capable channels. The wildcard `groups["*"]` entry is + * also honored. + */ + systemPrompt?: string; } >; /** Heartbeat visibility settings for this channel. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index efd0e86e698..3b8c33d1325 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -1432,6 +1432,7 @@ export const IMessageAccountSchemaBase = z requireMention: z.boolean().optional(), tools: ToolPolicySchema, toolsBySender: ToolPolicyBySenderSchema, + systemPrompt: z.string().optional(), }) .strict() .optional(),