From b188d292bcc80a2cbe0781bbd0e14be6f4c2b2d6 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Fri, 8 May 2026 12:10:02 +0000 Subject: [PATCH] feat(imessage): per-group systemPrompt (parity with other channels) channels.imessage.groups..systemPrompt is now resolved at inbound time and forwarded as ctxPayload.GroupSystemPrompt for group messages, matching the established pattern in Discord, Telegram, IRC, Slack, GoogleChat, and the retired BlueBubbles channel. - Add 'systemPrompt?: string' to the channels.imessage.groups[*] entry schema (types.imessage.ts + zod parser). - Capture groupListPolicy.groupConfig.systemPrompt at decision build time, falling back to the groups['*'] wildcard when the per-chat_id entry has no explicit prompt. DM decisions never carry the prompt. - Wire decision.groupSystemPrompt through to ctxPayload.GroupSystemPrompt in buildIMessageInboundContext, gated on decision.isGroup. Closes #78285. --- docs/.generated/config-baseline.sha256 | 6 +- docs/channels/imessage.md | 24 +++ .../inbound-processing.systemPrompt.test.ts | 203 ++++++++++++++++++ .../src/monitor/inbound-processing.ts | 20 ++ src/config/types.imessage.ts | 8 + src/config/zod-schema.providers-core.ts | 1 + 6 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 extensions/imessage/src/monitor/inbound-processing.systemPrompt.test.ts 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(),