Add per-DM and per-group Agent Preset overrides (4.9.1-ops.19).

Access grants can pin an agentPreset on direct members and groups; new sessions resolve override over the bot-global preset.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 18:53:43 +08:00
parent 7993499273
commit 2718586fda
12 changed files with 590 additions and 232 deletions

View file

@ -571,7 +571,7 @@ var React24 = __toESM(require("react"), 1);
// package.json
var package_default = {
name: "dsh-im-ops",
version: "4.9.1-ops.18",
version: "4.9.1-ops.19",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 \u2014 access/session policies owned here.",
keywords: [
"deepseek-harness",
@ -852,6 +852,9 @@ var EN = Object.freeze({
"\u673A\u5668\u4EBA\u8BBE\u7F6E\u9875\u7B7E": "Bot settings tabs",
"\u6295\u9012\u8BBE\u7F6E": "Delivery settings",
"\u8BBF\u95EE\u8BBE\u7F6E": "Access settings",
"\uFF08\u4E0D\u53EF\u7528\uFF09": " (unavailable)",
"\u7FA4 Agent Preset": "Group Agent Preset",
"\u8DDF\u968F\u5168\u5C40": "Follow global",
"\u2014": "\u2014",
"\u6765\u6E90": "Source",
"\u6682\u65E0\u7BA1\u7406\u5458\uFF0C\u53EF\u70B9\u4E0B\u65B9\u65B0\u589E\u3002": "No admins yet. Add one below.",
@ -11119,7 +11122,13 @@ var ACCESS_GRANT_VERSION = 1;
var ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
var ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
var ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}@g\.us$/;
var AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
var CONTROL_CHARACTERS2 = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
function normalizeOptionalAgentPresetId(value) {
if (typeof value !== "string") return null;
const id5 = value.trim();
return AGENT_PRESET_ID.test(id5) ? id5 : null;
}
function invalidGrant(message) {
const error = new TypeError(message);
error.code = "access-grant-invalid";
@ -11159,7 +11168,18 @@ function validateMember(input) {
}
const phone = validateAccessPhone(input.phone ?? input.id);
const canExecuteCommands = input.canExecuteCommands === void 0 ? true : Boolean(input.canExecuteCommands);
return Object.freeze({ phone, canExecuteCommands });
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
phone,
canExecuteCommands,
...agentPreset ? { agentPreset } : {}
});
}
function optionalAgentPreset(value) {
if (value == null || value === "") return void 0;
const id5 = normalizeOptionalAgentPresetId(value);
if (!id5) invalidGrant("Agent Preset \u65E0\u6548\u3002");
return id5;
}
function validatePhoneList(input, { emptyMessage, label }) {
if (!Array.isArray(input)) invalidGrant(`${label}\u5FC5\u987B\u662F\u6570\u7EC4\u3002`);
@ -11178,8 +11198,10 @@ function validateGroupGrant(input) {
invalidGrant("\u7FA4\u6210\u5458\u4E0D\u80FD\u5305\u542B\u91CD\u590D\u7535\u8BDD\u3002");
}
const title = typeof input.title === "string" ? input.title.trim().slice(0, 128) : "";
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
...title ? { title } : {},
...agentPreset ? { agentPreset } : {},
admins,
members
});
@ -12597,6 +12619,7 @@ function cloneGrant(grant, ownerPhone) {
directMembers: (base.directMembers ?? []).map((m) => ({ ...m })),
groups: Object.fromEntries(Object.entries(base.groups ?? {}).map(([jid, group]) => [jid, {
title: group.title ?? "",
...group.agentPreset ? { agentPreset: group.agentPreset } : {},
admins: [...group.admins ?? []],
members: (group.members ?? []).map((m) => ({ ...m }))
}])),
@ -12715,14 +12738,52 @@ function PhoneTypeahead({
) : null
);
}
function AgentPresetSelect({
value,
catalog,
disabled,
onChange,
ariaLabel = "Agent Preset"
}) {
const current = typeof value === "string" ? value : "";
const items = [];
const seen = /* @__PURE__ */ new Set();
for (const item of Array.isArray(catalog?.items) ? catalog.items : []) {
if (!item?.id || seen.has(item.id)) continue;
seen.add(item.id);
items.push(item);
}
if (current && !seen.has(current)) {
items.push({ id: current, label: current, unavailable: true });
}
return h2(
"select",
{
className: "dim-accessCompactSelect",
value: current,
disabled,
title: ariaLabel,
"aria-label": ariaLabel,
onChange: (event) => onChange(event.target.value || "")
},
h2("option", { value: "" }, "\u8DDF\u968F\u5168\u5C40"),
...items.map((item) => h2("option", {
key: item.id,
value: item.id
}, item.unavailable ? item.label + localizeText("\uFF08\u4E0D\u53EF\u7528\uFF09") : item.label))
);
}
function MemberRows({
title,
members,
contacts,
disabled,
onChange,
nested = false
nested = false,
showAgentPreset = false,
presetCatalog = EMPTY_AGENT_PRESET_CATALOG
}) {
const columns = showAgentPreset ? 5 : 4;
return h2(
nested ? "div" : "fieldset",
{
@ -12735,7 +12796,10 @@ function MemberRows({
{ className: "dim-accessTableWrap" },
h2(
"table",
{ className: "dim-accessTable", "data-kind": "member" },
{
className: "dim-accessTable",
"data-kind": showAgentPreset ? "member-preset" : "member"
},
h2(
"thead",
null,
@ -12745,10 +12809,14 @@ function MemberRows({
h2("th", { scope: "col" }, "\u6635\u79F0"),
h2("th", { scope: "col" }, "\u7535\u8BDD"),
h2("th", { scope: "col" }, "\u547D\u4EE4\u6743\u9650"),
showAgentPreset ? h2("th", { scope: "col" }, "Agent Preset") : null,
h2("th", { scope: "col", className: "dim-accessTableActions" }, "\u64CD\u4F5C")
)
),
h2("tbody", null, members.length === 0 ? h2("tr", null, h2("td", { colSpan: 4, className: "dim-accessTableEmpty" }, "\u6682\u65E0\u7528\u6237\uFF0C\u53EF\u70B9\u4E0B\u65B9\u65B0\u589E\u3002")) : members.map((member, index) => h2(
h2("tbody", null, members.length === 0 ? h2("tr", null, h2("td", {
colSpan: columns,
className: "dim-accessTableEmpty"
}, "\u6682\u65E0\u7528\u6237\uFF0C\u53EF\u70B9\u4E0B\u65B9\u65B0\u589E\u3002")) : members.map((member, index) => h2(
"tr",
{ key: `member-${index}` },
h2(
@ -12784,6 +12852,23 @@ function MemberRows({
h2("option", { value: "deny" }, "\u4E0D\u53EF\u6267\u884C\u547D\u4EE4")
)
),
showAgentPreset ? h2(
"td",
{ className: "dim-accessTablePreset" },
h2(AgentPresetSelect, {
value: member.agentPreset ?? "",
catalog: presetCatalog,
disabled,
ariaLabel: "Agent Preset",
onChange: (preset) => onChange(members.map((entry, i) => {
if (i !== index) return entry;
const next = { ...entry };
if (preset) next.agentPreset = preset;
else delete next.agentPreset;
return next;
}))
})
) : null,
h2(
"td",
{ className: "dim-accessTableActions" },
@ -12906,6 +12991,10 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
const [feedback, setFeedback] = React21.useState(null);
const [newGroupJid, setNewGroupJid] = React21.useState("");
const [loadingGrant, setLoadingGrant] = React21.useState(!initialGrant);
const contextCatalog = React21.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React21.useState(() => normalizeAgentPresetCatalog(
account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG
));
React21.useEffect(() => {
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
@ -12918,6 +13007,9 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
try {
const value = unwrapRpcResult11(await rpcCall("connection.status", {}));
if (cancelled) return;
if (value?.agentPresetCatalog) {
setPresetCatalog(normalizeAgentPresetCatalog(value.agentPresetCatalog));
}
const bot = Array.isArray(value?.bots) ? value.bots.find((entry) => entry?.botId === account.botId) : null;
let grant = normalizeAccessGrant(bot?.accessGrant);
if (grant) {
@ -13072,6 +13164,8 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
members: draft.directMembers,
contacts,
disabled: saving,
showAgentPreset: true,
presetCatalog,
onChange: (directMembers) => {
setDraft((current) => ({ ...current, directMembers }));
setFeedback(null);
@ -13183,6 +13277,30 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
})
}, "\u540C\u6B65\u7FA4\u540D")
),
h2(
"label",
{ className: "dim-accessField" },
h2("span", null, "\u7FA4 Agent Preset"),
h2(AgentPresetSelect, {
value: group.agentPreset ?? "",
catalog: presetCatalog,
disabled: saving,
ariaLabel: "\u7FA4 Agent Preset",
onChange: (preset) => setDraft((current) => {
const existing = current.groups[groupJid] ?? { title: "", admins: [], members: [] };
const nextGroup = { ...existing };
if (preset) nextGroup.agentPreset = preset;
else delete nextGroup.agentPreset;
return {
...current,
groups: {
...current.groups,
[groupJid]: nextGroup
}
};
})
})
),
h2(AdminPhones, {
title: "\u672C\u7FA4\u7BA1\u7406\u5458",
help: "\u672A\u914D\u7F6E\u65F6\uFF0C\u672C\u7FA4\u7533\u8BF7\u56DE\u843D\u7ED9\u5168\u5C40\u7BA1\u7406\u5458\u5BA1\u6279\uFF08\u4ECD\u53EA\u6388\u4E88\u672C\u7FA4\u6743\uFF09\u3002",
@ -14799,6 +14917,12 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
.dim-accessTable[data-kind="member"] th:nth-child(2), .dim-accessTable[data-kind="member"] td:nth-child(2) { width: 36%; }
.dim-accessTable[data-kind="member"] th:nth-child(3), .dim-accessTable[data-kind="member"] td:nth-child(3) { width: 28%; }
.dim-accessTable[data-kind="member"] th:nth-child(4), .dim-accessTable[data-kind="member"] td:nth-child(4) { width: 16%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(1), .dim-accessTable[data-kind="member-preset"] td:nth-child(1) { width: 16%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(2), .dim-accessTable[data-kind="member-preset"] td:nth-child(2) { width: 26%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(3), .dim-accessTable[data-kind="member-preset"] td:nth-child(3) { width: 18%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(4), .dim-accessTable[data-kind="member-preset"] td:nth-child(4) { width: 26%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(5), .dim-accessTable[data-kind="member-preset"] td:nth-child(5) { width: 14%; }
.dim-accessTablePreset { min-width: 0; }
.dim-accessTable[data-kind="contact"] th:nth-child(1), .dim-accessTable[data-kind="contact"] td:nth-child(1) { width: 18%; }
.dim-accessTable[data-kind="contact"] th:nth-child(2), .dim-accessTable[data-kind="contact"] td:nth-child(2) { width: 26%; }
.dim-accessTable[data-kind="contact"] th:nth-child(3), .dim-accessTable[data-kind="contact"] td:nth-child(3) { width: 16%; }

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"name": "dsh-im-ops",
"version": "4.9.1-ops.18",
"version": "4.9.1-ops.19",
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 — access/session policies owned here.",
"keywords": [
"deepseek-harness",

View file

@ -10,6 +10,11 @@ import {
normalizeGroupSessionScope,
validateGroupSessionScope,
} from '../../src/channels/shared/session-scope.mjs';
import {
AgentPresetCatalogContext,
EMPTY_AGENT_PRESET_CATALOG,
normalizeAgentPresetCatalog,
} from './agent-preset.js';
import { h, localizeText } from './i18n.js';
export const ACCESS_GRANT_ENDPOINT = 'bot.access-grant.set';
@ -58,6 +63,7 @@ function cloneGrant(grant, ownerPhone) {
directMembers: (base.directMembers ?? []).map((m) => ({ ...m })),
groups: Object.fromEntries(Object.entries(base.groups ?? {}).map(([jid, group]) => [jid, {
title: group.title ?? '',
...(group.agentPreset ? { agentPreset: group.agentPreset } : {}),
admins: [...(group.admins ?? [])],
members: (group.members ?? []).map((m) => ({ ...m })),
}])),
@ -191,6 +197,40 @@ function PhoneTypeahead({
}, contactLabel(contact))))) : null);
}
function AgentPresetSelect({
value,
catalog,
disabled,
onChange,
ariaLabel = 'Agent Preset',
}) {
const current = typeof value === 'string' ? value : '';
const items = [];
const seen = new Set();
for (const item of Array.isArray(catalog?.items) ? catalog.items : []) {
if (!item?.id || seen.has(item.id)) continue;
seen.add(item.id);
items.push(item);
}
if (current && !seen.has(current)) {
items.push({ id: current, label: current, unavailable: true });
}
return h('select', {
className: 'dim-accessCompactSelect',
value: current,
disabled,
title: ariaLabel,
'aria-label': ariaLabel,
onChange: (event) => onChange(event.target.value || ''),
},
h('option', { value: '' }, '跟随全局'),
...items.map((item) => h('option', {
key: item.id,
value: item.id,
}, item.unavailable ? item.label + localizeText('(不可用)') : item.label)));
}
function MemberRows({
title,
members,
@ -198,22 +238,32 @@ function MemberRows({
disabled,
onChange,
nested = false,
showAgentPreset = false,
presetCatalog = EMPTY_AGENT_PRESET_CATALOG,
}) {
const columns = showAgentPreset ? 5 : 4;
return h(nested ? 'div' : 'fieldset', {
className: nested ? 'dim-accessSubblock' : 'dim-accessScene',
...(nested ? {} : { disabled }),
},
nested ? h('h4', { className: 'dim-accessSubblockTitle' }, title) : h('legend', null, title),
h('div', { className: 'dim-accessTableWrap' },
h('table', { className: 'dim-accessTable', 'data-kind': 'member' },
h('table', {
className: 'dim-accessTable',
'data-kind': showAgentPreset ? 'member-preset' : 'member',
},
h('thead', null,
h('tr', null,
h('th', { scope: 'col' }, '昵称'),
h('th', { scope: 'col' }, '电话'),
h('th', { scope: 'col' }, '命令权限'),
showAgentPreset ? h('th', { scope: 'col' }, 'Agent Preset') : null,
h('th', { scope: 'col', className: 'dim-accessTableActions' }, '操作'))),
h('tbody', null, members.length === 0
? h('tr', null, h('td', { colSpan: 4, className: 'dim-accessTableEmpty' }, '暂无用户,可点下方新增。'))
? h('tr', null, h('td', {
colSpan: columns,
className: 'dim-accessTableEmpty',
}, '暂无用户,可点下方新增。'))
: members.map((member, index) =>
h('tr', { key: `member-${index}` },
h('td', { className: 'dim-accessTableNick' },
@ -243,6 +293,20 @@ function MemberRows({
},
h('option', { value: 'allow' }, '可执行命令'),
h('option', { value: 'deny' }, '不可执行命令'))),
showAgentPreset ? h('td', { className: 'dim-accessTablePreset' },
h(AgentPresetSelect, {
value: member.agentPreset ?? '',
catalog: presetCatalog,
disabled,
ariaLabel: 'Agent Preset',
onChange: (preset) => onChange(members.map((entry, i) => {
if (i !== index) return entry;
const next = { ...entry };
if (preset) next.agentPreset = preset;
else delete next.agentPreset;
return next;
})),
})) : null,
h('td', { className: 'dim-accessTableActions' },
h('button', {
type: 'button',
@ -339,6 +403,10 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved })
const [feedback, setFeedback] = React.useState(null);
const [newGroupJid, setNewGroupJid] = React.useState('');
const [loadingGrant, setLoadingGrant] = React.useState(!initialGrant);
const contextCatalog = React.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React.useState(() => normalizeAgentPresetCatalog(
account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
));
React.useEffect(() => {
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
@ -354,6 +422,9 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved })
try {
const value = unwrapRpcResult(await rpcCall('connection.status', {}));
if (cancelled) return;
if (value?.agentPresetCatalog) {
setPresetCatalog(normalizeAgentPresetCatalog(value.agentPresetCatalog));
}
const bot = Array.isArray(value?.bots)
? value.bots.find((entry) => entry?.botId === account.botId)
: null;
@ -521,6 +592,8 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved })
members: draft.directMembers,
contacts,
disabled: saving,
showAgentPreset: true,
presetCatalog,
onChange: (directMembers) => {
setDraft((current) => ({ ...current, directMembers }));
setFeedback(null);
@ -618,6 +691,27 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved })
}
}),
}, '同步群名')),
h('label', { className: 'dim-accessField' },
h('span', null, '群 Agent Preset'),
h(AgentPresetSelect, {
value: group.agentPreset ?? '',
catalog: presetCatalog,
disabled: saving,
ariaLabel: '群 Agent Preset',
onChange: (preset) => setDraft((current) => {
const existing = current.groups[groupJid] ?? { title: '', admins: [], members: [] };
const nextGroup = { ...existing };
if (preset) nextGroup.agentPreset = preset;
else delete nextGroup.agentPreset;
return {
...current,
groups: {
...current.groups,
[groupJid]: nextGroup,
},
};
}),
})),
h(AdminPhones, {
title: '本群管理员',
help: '未配置时,本群申请回落给全局管理员审批(仍只授予本群权)。',

View file

@ -11,6 +11,9 @@ const EN = Object.freeze({
'机器人设置页签': 'Bot settings tabs',
'投递设置': 'Delivery settings',
'访问设置': 'Access settings',
"(不可用)": " (unavailable)",
"群 Agent Preset": "Group Agent Preset",
"跟随全局": "Follow global",
"—": "—",
"来源": "Source",
"暂无管理员,可点下方新增。": "No admins yet. Add one below.",

View file

@ -484,6 +484,12 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
.dim-accessTable[data-kind="member"] th:nth-child(2), .dim-accessTable[data-kind="member"] td:nth-child(2) { width: 36%; }
.dim-accessTable[data-kind="member"] th:nth-child(3), .dim-accessTable[data-kind="member"] td:nth-child(3) { width: 28%; }
.dim-accessTable[data-kind="member"] th:nth-child(4), .dim-accessTable[data-kind="member"] td:nth-child(4) { width: 16%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(1), .dim-accessTable[data-kind="member-preset"] td:nth-child(1) { width: 16%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(2), .dim-accessTable[data-kind="member-preset"] td:nth-child(2) { width: 26%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(3), .dim-accessTable[data-kind="member-preset"] td:nth-child(3) { width: 18%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(4), .dim-accessTable[data-kind="member-preset"] td:nth-child(4) { width: 26%; }
.dim-accessTable[data-kind="member-preset"] th:nth-child(5), .dim-accessTable[data-kind="member-preset"] td:nth-child(5) { width: 14%; }
.dim-accessTablePreset { min-width: 0; }
.dim-accessTable[data-kind="contact"] th:nth-child(1), .dim-accessTable[data-kind="contact"] td:nth-child(1) { width: 18%; }
.dim-accessTable[data-kind="contact"] th:nth-child(2), .dim-accessTable[data-kind="contact"] td:nth-child(2) { width: 26%; }
.dim-accessTable[data-kind="contact"] th:nth-child(3), .dim-accessTable[data-kind="contact"] td:nth-child(3) { width: 16%; }

View file

@ -66,6 +66,7 @@ export function publicAccessGrant(grant) {
directMembers: normalized.directMembers.map((m) => ({ ...m })),
groups: Object.fromEntries(Object.entries(normalized.groups).map(([jid, group]) => [jid, {
...(group.title ? { title: group.title } : {}),
...(group.agentPreset ? { agentPreset: group.agentPreset } : {}),
admins: [...group.admins],
members: group.members.map((m) => ({ ...m })),
}])),

View file

@ -7,12 +7,19 @@ export const ACCESS_GRANT_VERSION = 1;
export const ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export const ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
export const ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}@g\.us$/;
const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
/** @typedef {'direct' | 'group'} AccessGrantScene */
/** @typedef {'approve' | 'deny'} AccessPendingAction */
function normalizeOptionalAgentPresetId(value) {
if (typeof value !== 'string') return null;
const id = value.trim();
return AGENT_PRESET_ID.test(id) ? id : null;
}
function invalidGrant(message) {
const error = new TypeError(message);
error.code = 'access-grant-invalid';
@ -75,7 +82,7 @@ export function validateGroupJid(value) {
/**
* @param {unknown} input
* @returns {{ phone: string, canExecuteCommands: boolean }}
* @returns {{ phone: string, canExecuteCommands: boolean, agentPreset?: string }}
*/
function validateMember(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
@ -85,7 +92,24 @@ function validateMember(input) {
const canExecuteCommands = input.canExecuteCommands === undefined
? true
: Boolean(input.canExecuteCommands);
return Object.freeze({ phone, canExecuteCommands });
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
phone,
canExecuteCommands,
...(agentPreset ? { agentPreset } : {}),
});
}
/**
* Optional Agent Preset override. Empty/null means follow the bot-level preset.
* @param {unknown} value
* @returns {string|undefined}
*/
function optionalAgentPreset(value) {
if (value == null || value === '') return undefined;
const id = normalizeOptionalAgentPresetId(value);
if (!id) invalidGrant('Agent Preset 无效。');
return id;
}
/**
@ -119,8 +143,10 @@ function validateGroupGrant(input) {
invalidGrant('群成员不能包含重复电话。');
}
const title = typeof input.title === 'string' ? input.title.trim().slice(0, 128) : '';
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
...(title ? { title } : {}),
...(agentPreset ? { agentPreset } : {}),
admins,
members,
});
@ -282,6 +308,30 @@ export function normalizeAccessGrant(input) {
}
}
/**
* Resolve a chat-scoped Agent Preset override from the access grant.
* Returns null when the chat should follow the bot-level (global) preset.
* Priority: direct-member or group override > bot global (caller applies global).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @returns {string|null}
*/
export function resolveAccessAgentPreset(grant, context = {}) {
const doc = normalizeAccessGrant(grant);
if (!doc) return null;
if (context.kind === 'group') {
const groupJid = typeof context.groupJid === 'string' && context.groupJid.trim()
? context.groupJid.trim()
: typeof context.conversationId === 'string' ? context.conversationId.trim() : '';
if (!groupJid || !ACCESS_GRANT_GROUP_JID_PATTERN.test(groupJid)) return null;
return normalizeOptionalAgentPresetId(doc.groups[groupJid]?.agentPreset) ?? null;
}
const phone = normalizeAccessPhone(context.phone ?? context.senderId);
if (!phone) return null;
const member = doc.directMembers.find((entry) => entry.phone === phone);
return normalizeOptionalAgentPresetId(member?.agentPreset) ?? null;
}
/**
* Seed owner phone as the first global admin when missing.
* @param {object|null} grant

View file

@ -1288,9 +1288,12 @@ export function createBotWorkspaceScope(
throw error;
}
const generation = workspaces.generationFor(botId);
const agentPreset = workspaces.agentPresetFor(botId);
const { agentPreset: requestedPreset, ...createOptions } = options ?? {};
const agentPreset = requestedPreset !== undefined
? requestedPreset
: workspaces.agentPresetFor(botId);
const sessionId = await target.createSession({
...options,
...createOptions,
workspace: workspaces.workspaceFor(botId),
...(agentPreset == null ? {} : { agentPreset }),
});

View file

@ -148,6 +148,8 @@ export class TextHarnessBridge {
#logger;
#replyTimeoutMs;
#signal;
/** @type {((message: object) => string|null|undefined)|null} */
#resolveAgentPreset;
#queues = new Map();
#pendingInteractions = new Map();
#interactionKeys = new Map();
@ -170,6 +172,7 @@ export class TextHarnessBridge {
logger = console,
replyTimeoutMs = 600_000,
signal,
resolveAgentPreset,
}) {
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
@ -185,6 +188,7 @@ export class TextHarnessBridge {
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#signal = signal;
this.#resolveAgentPreset = typeof resolveAgentPreset === 'function' ? resolveAgentPreset : null;
this.#approvals = new HarnessApprovalQueue({
label: descriptor.key,
logger,
@ -735,7 +739,13 @@ export class TextHarnessBridge {
text,
content,
contextEnhanced,
createOptions: this.#signal ? { signal: this.#signal } : undefined,
createOptions: (() => {
const agentPreset = this.#resolveAgentPreset?.(message);
const options = {};
if (this.#signal) options.signal = this.#signal;
if (typeof agentPreset === 'string' && agentPreset) options.agentPreset = agentPreset;
return Object.keys(options).length > 0 ? options : undefined;
})(),
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
askOptions: {
timeoutMs: this.#replyTimeoutMs,

View file

@ -7,7 +7,7 @@ import {
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { emptyAccessGrant, ensureGroupBucket } from '../shared/access-grant.mjs';
import { emptyAccessGrant, ensureGroupBucket, normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from '../shared/access-grant.mjs';
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { t } from '../shared/i18n.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
@ -823,6 +823,20 @@ export class WhatsappRuntime {
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
signal: controller.signal,
resolveAgentPreset: (message) => {
if (!this.#workspaces || !this.#botId) return null;
const grant = this.#workspaces.accessGrantFor(this.#botId);
return resolveAccessAgentPreset(grant, {
kind: message?.kind === 'group' ? 'group' : 'direct',
groupJid: message?.conversationId,
conversationId: message?.conversationId,
phone: phoneFromWhatsappJid(message?.senderId)
?? normalizeAccessPhone(message?.senderId)
?? phoneFromWhatsappJid(message?.senderAlternateId)
?? normalizeAccessPhone(message?.senderAlternateId),
senderId: message?.senderId,
});
},
});
const now = Date.now();
this.#status.ready = true;

View file

@ -15,6 +15,7 @@ import {
parsePendingIdFromNotifyText,
resolveAccessPending,
upsertAccessContact,
resolveAccessAgentPreset,
validateAccessGrant,
} from '../../../src/channels/shared/access-grant.mjs';
@ -194,3 +195,55 @@ test('quote approval helpers parse intent, pending id, and notify refs', () => {
};
assert.equal(findPendingByNotifyMessageId(withRef, 'MSG1')?.id, 'p_abc123');
});
test('resolveAccessAgentPreset prefers direct-member and group overrides over global', () => {
const doc = grant({
directMembers: [
{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'ops-user' },
{ phone: '8618333333333', canExecuteCommands: true },
],
groups: {
[GROUP_A]: {
title: 'A',
agentPreset: 'ops-group',
admins: [],
members: [{ phone: '8618444444444', canExecuteCommands: true }],
},
},
});
assert.equal(resolveAccessAgentPreset(doc, {
kind: 'direct',
phone: '8618222222222',
}), 'ops-user');
assert.equal(resolveAccessAgentPreset(doc, {
kind: 'direct',
phone: '8618333333333',
}), null);
assert.equal(resolveAccessAgentPreset(doc, {
kind: 'group',
groupJid: GROUP_A,
}), 'ops-group');
assert.equal(resolveAccessAgentPreset(doc, {
kind: 'group',
groupJid: GROUP_B,
}), null);
});
test('validateAccessGrant keeps optional agentPreset on members and groups', () => {
const doc = grant({
directMembers: [{ phone: '8618222222222', canExecuteCommands: false, agentPreset: 'desk' }],
groups: {
[GROUP_A]: {
agentPreset: 'night',
admins: ['8618111111111'],
members: [],
},
},
});
assert.equal(doc.directMembers[0].agentPreset, 'desk');
assert.equal(doc.groups[GROUP_A].agentPreset, 'night');
assert.throws(() => grant({
directMembers: [{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'BAD Preset' }],
}), /Agent Preset/);
});