Skip to content

Commit 694d12a

Browse files
committed
refactor: apply context visibility across channels
1 parent 35e1605 commit 694d12a

34 files changed

Lines changed: 1279 additions & 131 deletions

extensions/bluebubbles/src/monitor-processing.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,15 @@ import {
4444
createChannelPairingController,
4545
createChannelReplyPipeline,
4646
evictOldHistoryKeys,
47+
evaluateSupplementalContextVisibility,
4748
logAckFailure,
4849
logInboundDrop,
4950
logTypingFailure,
5051
mapAllowFromEntries,
5152
readStoreAllowFromForDmPolicy,
5253
recordPendingHistoryEntryIfEnabled,
5354
resolveAckReaction,
55+
resolveChannelContextVisibilityMode,
5456
resolveDmGroupAccessWithLists,
5557
resolveControlCommandGate,
5658
stripMarkdown,
@@ -844,6 +846,11 @@ export async function processMessage(
844846
chatGuid,
845847
chatIdentifier,
846848
});
849+
const contextVisibilityMode = resolveChannelContextVisibilityMode({
850+
cfg: config,
851+
channel: "bluebubbles",
852+
accountId: account.accountId,
853+
});
847854

848855
// Mention gating for group chats (parity with iMessage/WhatsApp)
849856
const messageText = text;
@@ -1048,11 +1055,45 @@ export async function processMessage(
10481055
if (replyToId && !replyToShortId) {
10491056
replyToShortId = getShortIdForUuid(replyToId);
10501057
}
1058+
const hasReplyContext = Boolean(replyToId || replyToBody || replyToSender);
1059+
const replySenderAllowed =
1060+
!isGroup || effectiveGroupAllowFrom.length === 0
1061+
? true
1062+
: replyToSender
1063+
? isAllowedBlueBubblesSender({
1064+
allowFrom: effectiveGroupAllowFrom,
1065+
sender: replyToSender,
1066+
chatId: message.chatId ?? undefined,
1067+
chatGuid: message.chatGuid ?? undefined,
1068+
chatIdentifier: message.chatIdentifier ?? undefined,
1069+
})
1070+
: false;
1071+
const includeReplyContext =
1072+
!hasReplyContext ||
1073+
evaluateSupplementalContextVisibility({
1074+
mode: contextVisibilityMode,
1075+
kind: "quote",
1076+
senderAllowed: replySenderAllowed,
1077+
}).include;
1078+
if (hasReplyContext && !includeReplyContext && isGroup) {
1079+
logVerbose(
1080+
core,
1081+
runtime,
1082+
`bluebubbles: drop reply context (mode=${contextVisibilityMode}, sender_allowed=${replySenderAllowed ? "yes" : "no"})`,
1083+
);
1084+
}
1085+
const visibleReplyToId = includeReplyContext ? replyToId : undefined;
1086+
const visibleReplyToShortId = includeReplyContext ? replyToShortId : undefined;
1087+
const visibleReplyToBody = includeReplyContext ? replyToBody : undefined;
1088+
const visibleReplyToSender = includeReplyContext ? replyToSender : undefined;
10511089

10521090
// Use inline [[reply_to:N]] tag format
10531091
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
10541092
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
1055-
const replyTag = formatReplyTag({ replyToId, replyToShortId });
1093+
const replyTag = formatReplyTag({
1094+
replyToId: visibleReplyToId,
1095+
replyToShortId: visibleReplyToShortId,
1096+
});
10561097
const baseBody = replyTag
10571098
? isTapbackMessage
10581099
? `${rawBody} ${replyTag}`
@@ -1345,10 +1386,10 @@ export async function processMessage(
13451386
ChatType: isGroup ? "group" : "direct",
13461387
ConversationLabel: fromLabel,
13471388
// Use short ID for token savings (agent can use this to reference the message)
1348-
ReplyToId: replyToShortId || replyToId,
1349-
ReplyToIdFull: replyToId,
1350-
ReplyToBody: replyToBody,
1351-
ReplyToSender: replyToSender,
1389+
ReplyToId: visibleReplyToShortId || visibleReplyToId,
1390+
ReplyToIdFull: visibleReplyToId,
1391+
ReplyToBody: visibleReplyToBody,
1392+
ReplyToSender: visibleReplyToSender,
13521393
GroupSubject: groupSubject,
13531394
GroupMembers: groupMembers,
13541395
SenderName: message.senderName || undefined,

extensions/bluebubbles/src/monitor.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,79 @@ describe("BlueBubbles webhook monitor", () => {
994994
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
995995
});
996996

997+
it("drops group reply context from non-allowlisted senders in allowlist mode", async () => {
998+
setupWebhookTarget({
999+
account: createMockAccount({
1000+
groupPolicy: "allowlist",
1001+
groupAllowFrom: ["+15551234567"],
1002+
}),
1003+
config: {
1004+
channels: {
1005+
bluebubbles: {
1006+
contextVisibility: "allowlist",
1007+
},
1008+
},
1009+
} as OpenClawConfig,
1010+
});
1011+
1012+
const payload = createTimestampedNewMessagePayloadForTest({
1013+
text: "replying now",
1014+
isGroup: true,
1015+
chatGuid: "iMessage;+;chat-reply-visibility",
1016+
replyTo: {
1017+
guid: "msg-0",
1018+
text: "blocked context",
1019+
handle: { address: "+15550000000", displayName: "Alice" },
1020+
},
1021+
});
1022+
1023+
await dispatchWebhookPayload(payload);
1024+
1025+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1026+
const callArgs = getFirstDispatchCall();
1027+
expect(callArgs.ctx.ReplyToId).toBeUndefined();
1028+
expect(callArgs.ctx.ReplyToIdFull).toBeUndefined();
1029+
expect(callArgs.ctx.ReplyToBody).toBeUndefined();
1030+
expect(callArgs.ctx.ReplyToSender).toBeUndefined();
1031+
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
1032+
});
1033+
1034+
it("keeps group reply context in allowlist_quote mode", async () => {
1035+
setupWebhookTarget({
1036+
account: createMockAccount({
1037+
groupPolicy: "allowlist",
1038+
groupAllowFrom: ["+15551234567"],
1039+
}),
1040+
config: {
1041+
channels: {
1042+
bluebubbles: {
1043+
contextVisibility: "allowlist_quote",
1044+
},
1045+
},
1046+
} as OpenClawConfig,
1047+
});
1048+
1049+
const payload = createTimestampedNewMessagePayloadForTest({
1050+
text: "replying now",
1051+
isGroup: true,
1052+
chatGuid: "iMessage;+;chat-reply-visibility",
1053+
replyTo: {
1054+
guid: "msg-0",
1055+
text: "quoted context",
1056+
handle: { address: "+15550000000", displayName: "Alice" },
1057+
},
1058+
});
1059+
1060+
await dispatchWebhookPayload(payload);
1061+
1062+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1063+
const callArgs = getFirstDispatchCall();
1064+
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
1065+
expect(callArgs.ctx.ReplyToBody).toBe("quoted context");
1066+
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
1067+
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
1068+
});
1069+
9971070
it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
9981071
setupWebhookTarget();
9991072

extensions/bluebubbles/src/runtime-api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,8 @@ export {
5151
resolveWebhookTargetWithAuthOrRejectSync,
5252
withResolvedWebhookRequestPipeline,
5353
} from "openclaw/plugin-sdk/bluebubbles";
54+
export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
55+
export {
56+
evaluateSupplementalContextVisibility,
57+
shouldIncludeSupplementalContext,
58+
} from "openclaw/plugin-sdk/security-runtime";

extensions/discord/src/monitor/inbound-context.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
createDiscordSupplementalContextAccessChecker,
34
buildDiscordGroupSystemPrompt,
45
buildDiscordInboundAccessContext,
56
buildDiscordUntrustedContext,
@@ -60,4 +61,46 @@ describe("Discord inbound context helpers", () => {
6061
}),
6162
).toEqual([expect.stringContaining("topic"), expect.stringContaining("hello")]);
6263
});
64+
65+
it("matches supplemental context senders through role allowlists", () => {
66+
const isAllowed = createDiscordSupplementalContextAccessChecker({
67+
channelConfig: {
68+
allowed: true,
69+
roles: ["role:ops", "123"],
70+
},
71+
isGuild: true,
72+
});
73+
74+
expect(
75+
isAllowed({
76+
id: "user-2",
77+
memberRoleIds: ["123"],
78+
}),
79+
).toBe(true);
80+
expect(
81+
isAllowed({
82+
id: "user-3",
83+
memberRoleIds: ["999"],
84+
}),
85+
).toBe(false);
86+
});
87+
88+
it("matches supplemental context senders by plain username when name matching is enabled", () => {
89+
const isAllowed = createDiscordSupplementalContextAccessChecker({
90+
channelConfig: {
91+
allowed: true,
92+
users: ["alice"],
93+
},
94+
allowNameMatching: true,
95+
isGuild: true,
96+
});
97+
98+
expect(
99+
isAllowed({
100+
id: "user-2",
101+
name: "Alice",
102+
tag: "Alice#1234",
103+
}),
104+
).toBe(true);
105+
});
63106
});

extensions/discord/src/monitor/inbound-context.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,41 @@ import {
33
wrapExternalContent,
44
} from "openclaw/plugin-sdk/security-runtime";
55
import {
6+
resolveDiscordMemberAllowed,
67
resolveDiscordOwnerAllowFrom,
78
type DiscordChannelConfigResolved,
89
type DiscordGuildEntryResolved,
910
} from "./allow-list.js";
1011

12+
export type DiscordSupplementalContextSender = {
13+
id?: string;
14+
name?: string;
15+
tag?: string;
16+
memberRoleIds?: string[];
17+
};
18+
19+
export function createDiscordSupplementalContextAccessChecker(params: {
20+
channelConfig?: DiscordChannelConfigResolved | null;
21+
guildInfo?: DiscordGuildEntryResolved | null;
22+
allowNameMatching?: boolean;
23+
isGuild: boolean;
24+
}) {
25+
return (sender: DiscordSupplementalContextSender): boolean => {
26+
if (!params.isGuild) {
27+
return true;
28+
}
29+
return resolveDiscordMemberAllowed({
30+
userAllowList: params.channelConfig?.users ?? params.guildInfo?.users,
31+
roleAllowList: params.channelConfig?.roles ?? params.guildInfo?.roles,
32+
memberRoleIds: sender.memberRoleIds ?? [],
33+
userId: sender.id ?? "",
34+
userName: sender.name,
35+
userTag: sender.tag,
36+
allowNameMatching: params.allowNameMatching,
37+
});
38+
};
39+
}
40+
1141
export function buildDiscordGroupSystemPrompt(
1242
channelConfig?: DiscordChannelConfigResolved | null,
1343
): string | undefined {

extensions/discord/src/monitor/message-handler.process.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,24 @@ import {
1515
} from "openclaw/plugin-sdk/channel-inbound";
1616
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
1717
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
18+
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
1819
import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
1920
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
2021
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
2122
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
2223
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
24+
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
25+
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
2326
import {
2427
buildPendingHistoryContextFromMap,
2528
clearHistoryEntriesIfEnabled,
2629
} from "openclaw/plugin-sdk/reply-history";
2730
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
28-
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
29-
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
3031
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
3132
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
3233
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
3334
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
35+
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
3436
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
3537
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-runtime";
3638
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
@@ -42,7 +44,10 @@ import { reactMessageDiscord, removeReactionDiscord } from "../send.js";
4244
import { editMessageDiscord } from "../send.messages.js";
4345
import { normalizeDiscordSlug } from "./allow-list.js";
4446
import { resolveTimestampMs } from "./format.js";
45-
import { buildDiscordInboundAccessContext } from "./inbound-context.js";
47+
import {
48+
buildDiscordInboundAccessContext,
49+
createDiscordSupplementalContextAccessChecker,
50+
} from "./inbound-context.js";
4651
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
4752
import {
4853
buildDiscordMediaPayload,
@@ -257,6 +262,18 @@ export async function processDiscordMessage(
257262
channelTopic: channelInfo?.topic,
258263
messageBody: text,
259264
});
265+
const contextVisibilityMode = resolveChannelContextVisibilityMode({
266+
cfg,
267+
channel: "discord",
268+
accountId,
269+
});
270+
const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig);
271+
const isSupplementalContextSenderAllowed = createDiscordSupplementalContextAccessChecker({
272+
channelConfig,
273+
guildInfo,
274+
allowNameMatching,
275+
isGuild: isGuildMessage,
276+
});
260277
const storePath = resolveStorePath(cfg.session?.store, {
261278
agentId: route.agentId,
262279
});
@@ -296,6 +313,22 @@ export async function processDiscordMessage(
296313
});
297314
}
298315
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
316+
const replyVisibility = replyContext
317+
? evaluateSupplementalContextVisibility({
318+
mode: contextVisibilityMode,
319+
kind: "quote",
320+
senderAllowed: isSupplementalContextSenderAllowed({
321+
id: replyContext.senderId,
322+
name: replyContext.senderName,
323+
tag: replyContext.senderTag,
324+
memberRoleIds: replyContext.memberRoleIds,
325+
}),
326+
})
327+
: null;
328+
const filteredReplyContext = replyContext && replyVisibility?.include ? replyContext : null;
329+
if (replyContext && !filteredReplyContext && isGuildMessage) {
330+
logVerbose(`discord: drop reply context (mode=${contextVisibilityMode})`);
331+
}
299332
if (forumContextLine) {
300333
combinedBody = `${combinedBody}\n${forumContextLine}`;
301334
}
@@ -314,8 +347,22 @@ export async function processDiscordMessage(
314347
resolveTimestampMs,
315348
});
316349
if (starter?.text) {
317-
// Keep thread starter as raw text; metadata is provided out-of-band in the system prompt.
318-
threadStarterBody = starter.text;
350+
const starterVisibility = evaluateSupplementalContextVisibility({
351+
mode: contextVisibilityMode,
352+
kind: "thread",
353+
senderAllowed: isSupplementalContextSenderAllowed({
354+
id: starter.authorId,
355+
name: starter.authorName ?? starter.author,
356+
tag: starter.authorTag,
357+
memberRoleIds: starter.memberRoleIds,
358+
}),
359+
});
360+
if (starterVisibility.include) {
361+
// Keep thread starter as raw text; metadata is provided out-of-band in the system prompt.
362+
threadStarterBody = starter.text;
363+
} else {
364+
logVerbose(`discord: drop thread starter context (mode=${contextVisibilityMode})`);
365+
}
319366
}
320367
}
321368
const parentName = threadParentName ?? "parent";
@@ -405,9 +452,9 @@ export async function processDiscordMessage(
405452
Surface: "discord" as const,
406453
WasMentioned: effectiveWasMentioned,
407454
MessageSid: message.id,
408-
ReplyToId: replyContext?.id,
409-
ReplyToBody: replyContext?.body,
410-
ReplyToSender: replyContext?.sender,
455+
ReplyToId: filteredReplyContext?.id,
456+
ReplyToBody: filteredReplyContext?.body,
457+
ReplyToSender: filteredReplyContext?.sender,
411458
ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
412459
MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined,
413460
ThreadStarterBody: threadStarterBody,

0 commit comments

Comments
 (0)