Fix WhatsApp access settings showing empty grant state (4.9.1-ops.6).

Client normalizeBot dropped accessGrant/groupSessionScope, so pending, contacts, and group cards never appeared. Also record contacts only on DM/@, auto-create group buckets on @, and add one-click grant buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 17:05:16 +08:00
parent a0c49707a1
commit 93672dfd91
8 changed files with 685 additions and 444 deletions

View file

@ -571,7 +571,7 @@ var React24 = __toESM(require("react"), 1);
// package.json // package.json
var package_default = { var package_default = {
name: "dsh-im-ops", name: "dsh-im-ops",
version: "4.9.1-ops.5", version: "4.9.1-ops.6",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 \u2014 access/session policies owned here.", description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 \u2014 access/session policies owned here.",
keywords: [ keywords: [
"deepseek-harness", "deepseek-harness",
@ -852,6 +852,14 @@ var EN = Object.freeze({
"\u673A\u5668\u4EBA\u8BBE\u7F6E\u9875\u7B7E": "Bot settings tabs", "\u673A\u5668\u4EBA\u8BBE\u7F6E\u9875\u7B7E": "Bot settings tabs",
"\u6295\u9012\u8BBE\u7F6E": "Delivery settings", "\u6295\u9012\u8BBE\u7F6E": "Delivery settings",
"\u8BBF\u95EE\u8BBE\u7F6E": "Access settings", "\u8BBF\u95EE\u8BBE\u7F6E": "Access settings",
"\u52A0\u672C\u7FA4": "Add to group",
"\u5DF2\u5728\u672C\u7FA4": "Already in group",
"\u52A0\u79C1\u804A": "Add to DMs",
"\u5DF2\u5728\u79C1\u804A": "Already in DMs",
"\u5DF2\u52A0\u5165\u672C\u7FA4\u6388\u6743\uFF0C\u8BB0\u5F97\u70B9\u4FDD\u5B58\u3002": "Added to this group. Remember to save.",
"\u5DF2\u52A0\u5165\u79C1\u804A\u6388\u6743\uFF0C\u8BB0\u5F97\u70B9\u4FDD\u5B58\u3002": "Added to DM access. Remember to save.",
"\u6682\u65E0\u8054\u7CFB\u4EBA\u3002\u6709\u4EBA\u79C1\u804A\u6216 @ \u673A\u5668\u4EBA\u540E\u4F1A\u51FA\u73B0\u5728\u6B64\u3002": "No contacts yet. They appear here after someone DMs or @mentions the bot.",
"\u4EC5\u8BB0\u5F55\u79C1\u804A\u673A\u5668\u4EBA\uFF0C\u6216\u5728\u7FA4\u91CC @ \u673A\u5668\u4EBA\u7684\u4EBA\u3002\u7535\u8BDD\u662F\u552F\u4E00\u6388\u6743\u952E\uFF0C\u53EF\u4E00\u952E\u52A0\u5165\u79C1\u804A/\u672C\u7FA4\u6388\u6743\u3002": "Only people who DM the bot or @mention it in a group are recorded. Phone is the sole auth key; use one-click to grant DM or group access.",
"WhatsApp \u6309\u7535\u8BDD\u53F7\u7801\u6388\u6743\uFF1A\u5168\u5C40\u7BA1\u7406\u5458\u7BA1\u79C1\u804A\uFF1B\u7FA4\u7BA1\u7406\u5458\u53EA\u6279\u672C\u7FA4\u3002\u6210\u5458\u4E0D\u53EF\u8DE8\u7FA4\u3001\u6709\u7FA4\u6743\u4E5F\u4E0D\u81EA\u52A8\u83B7\u5F97\u79C1\u804A\u6743\u3002": "WhatsApp authorizes by phone number: global admins manage DMs; group admins approve only their group. Members do not cross groups, and group access does not grant DMs.", "WhatsApp \u6309\u7535\u8BDD\u53F7\u7801\u6388\u6743\uFF1A\u5168\u5C40\u7BA1\u7406\u5458\u7BA1\u79C1\u804A\uFF1B\u7FA4\u7BA1\u7406\u5458\u53EA\u6279\u672C\u7FA4\u3002\u6210\u5458\u4E0D\u53EF\u8DE8\u7FA4\u3001\u6709\u7FA4\u6743\u4E5F\u4E0D\u81EA\u52A8\u83B7\u5F97\u79C1\u804A\u6743\u3002": "WhatsApp authorizes by phone number: global admins manage DMs; group admins approve only their group. Members do not cross groups, and group access does not grant DMs.",
"\u4FDD\u5B58\u5206\u7EA7\u8BBF\u95EE\u8BBE\u7F6E": "Save graded access settings", "\u4FDD\u5B58\u5206\u7EA7\u8BBF\u95EE\u8BBE\u7F6E": "Save graded access settings",
"\u4FDD\u5B58\u8BBF\u95EE\u4E0E\u4F1A\u8BDD\u8BBE\u7F6E": "Save access and session settings", "\u4FDD\u5B58\u8BBF\u95EE\u4E0E\u4F1A\u8BDD\u8BBE\u7F6E": "Save access and session settings",
@ -11067,6 +11075,207 @@ function installWeixinStyles() {
return () => style.remove(); return () => style.remove();
} }
// src/channels/shared/access-grant.mjs
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 CONTROL_CHARACTERS2 = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
function invalidGrant(message) {
const error = new TypeError(message);
error.code = "access-grant-invalid";
throw error;
}
function normalizeAccessPhone(value) {
if (typeof value === "number" && Number.isFinite(value)) value = String(value);
if (typeof value === "bigint") value = String(value);
if (typeof value !== "string") return null;
let raw = value.trim();
if (!raw || CONTROL_CHARACTERS2.test(raw)) return null;
const at = raw.indexOf("@");
if (at > 0) {
const user = raw.slice(0, at).split(":")[0];
const server = raw.slice(at + 1).toLowerCase();
if (!["s.whatsapp.net", "c.us", "hosted"].includes(server)) return null;
raw = user;
}
const digits = raw.replace(/[^\d]/g, "");
if (digits.length < 5 || digits.length > ACCESS_GRANT_PHONE_MAX_LENGTH) return null;
return digits;
}
function validateAccessPhone(value) {
const phone = normalizeAccessPhone(value);
if (!phone) invalidGrant("\u7535\u8BDD\u53F7\u7801\u65E0\u6548\u3002");
return phone;
}
function validateGroupJid(value) {
if (typeof value !== "string" || !ACCESS_GRANT_GROUP_JID_PATTERN.test(value.trim())) {
invalidGrant("\u7FA4 JID \u65E0\u6548\u3002");
}
return value.trim();
}
function validateMember(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u6210\u5458\u6761\u76EE\u65E0\u6548\u3002");
}
const phone = validateAccessPhone(input.phone ?? input.id);
const canExecuteCommands = input.canExecuteCommands === void 0 ? true : Boolean(input.canExecuteCommands);
return Object.freeze({ phone, canExecuteCommands });
}
function validatePhoneList(input, { emptyMessage, label }) {
if (!Array.isArray(input)) invalidGrant(`${label}\u5FC5\u987B\u662F\u6570\u7EC4\u3002`);
const phones = input.map((entry) => typeof entry === "string" || typeof entry === "number" || typeof entry === "bigint" ? validateAccessPhone(entry) : validateAccessPhone(entry?.phone ?? entry?.id));
if (new Set(phones).size !== phones.length) invalidGrant(`${label}\u4E0D\u80FD\u5305\u542B\u91CD\u590D\u7535\u8BDD\u3002`);
if (phones.length === 0 && emptyMessage) invalidGrant(emptyMessage);
return Object.freeze(phones);
}
function validateGroupGrant(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u7FA4\u6388\u6743\u6761\u76EE\u65E0\u6548\u3002");
}
const admins = validatePhoneList(input.admins ?? [], { label: "\u7FA4\u7BA1\u7406\u5458" });
const members = Object.freeze((Array.isArray(input.members) ? input.members : []).map(validateMember));
if (new Set(members.map((m) => m.phone)).size !== members.length) {
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) : "";
return Object.freeze({
...title ? { title } : {},
admins,
members
});
}
function validatePending(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u5F85\u6279\u6761\u76EE\u65E0\u6548\u3002");
}
const id5 = typeof input.id === "string" ? input.id.trim() : "";
if (!/^[A-Za-z0-9_-]{6,64}$/.test(id5)) invalidGrant("\u5F85\u6279\u7F16\u53F7\u65E0\u6548\u3002");
const kind = input.kind === "group" ? "group" : input.kind === "direct" ? "direct" : null;
if (!kind) invalidGrant("\u5F85\u6279\u573A\u666F\u65E0\u6548\u3002");
const phone = input.phone ? validateAccessPhone(input.phone) : "";
const lid = typeof input.lid === "string" ? input.lid.trim().slice(0, 128) : "";
if (!phone && !lid) invalidGrant("\u5F85\u6279\u7F3A\u5C11\u7535\u8BDD\u6216 LID\u3002");
const groupJid = kind === "group" ? validateGroupJid(input.groupJid) : void 0;
const pushName = typeof input.pushName === "string" ? input.pushName.trim().slice(0, 128) : "";
const requestText = typeof input.requestText === "string" ? input.requestText.trim().slice(0, 500) : "";
const createdAt = typeof input.createdAt === "string" && input.createdAt ? input.createdAt : (/* @__PURE__ */ new Date()).toISOString();
const unresolved = input.unresolved === true || !phone;
const notifyRefs = Array.isArray(input.notifyRefs) ? Object.freeze(input.notifyRefs.map((ref) => Object.freeze({
adminPhone: validateAccessPhone(ref.adminPhone),
providerMessageId: String(ref.providerMessageId ?? "").trim().slice(0, 128)
})).filter((ref) => ref.providerMessageId)) : Object.freeze([]);
const resolvedByPhone = input.resolvedByPhone ? validateAccessPhone(input.resolvedByPhone) : void 0;
const resolvedAt = typeof input.resolvedAt === "string" ? input.resolvedAt : void 0;
const status = ["pending", "approved", "denied", "expired"].includes(input.status) ? input.status : "pending";
return Object.freeze({
id: id5,
kind,
...groupJid ? { groupJid } : {},
phone: phone || "",
...lid ? { lid } : {},
...pushName ? { pushName } : {},
...requestText ? { requestText } : {},
createdAt,
unresolved,
notifyRefs,
status,
...resolvedByPhone ? { resolvedByPhone } : {},
...resolvedAt ? { resolvedAt } : {}
});
}
function validateContact(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u8054\u7CFB\u4EBA\u6761\u76EE\u65E0\u6548\u3002");
}
const phone = input.phone ? validateAccessPhone(input.phone) : void 0;
const lids = Object.freeze([...Array.isArray(input.lids) ? input.lids : []].map((lid) => String(lid ?? "").trim()).filter((lid) => lid && lid.length <= 128).slice(0, 8));
const pushName = typeof input.pushName === "string" ? input.pushName.trim().slice(0, 128) : "";
const lastSeenAt = typeof input.lastSeenAt === "string" && input.lastSeenAt ? input.lastSeenAt : (/* @__PURE__ */ new Date()).toISOString();
const scenes = Object.freeze([...Array.isArray(input.scenes) ? input.scenes : []].filter((scene) => scene === "direct" || scene === "group").slice(0, 2));
const groupJids = Object.freeze([...Array.isArray(input.groupJids) ? input.groupJids : []].map((jid) => String(jid ?? "").trim()).filter((jid) => ACCESS_GRANT_GROUP_JID_PATTERN.test(jid)).slice(0, 32));
if (!phone && lids.length === 0) invalidGrant("\u8054\u7CFB\u4EBA\u7F3A\u5C11\u7535\u8BDD\u6216 LID\u3002");
return Object.freeze({
...phone ? { phone } : {},
lids,
...pushName ? { pushName } : {},
lastSeenAt,
scenes: scenes.length > 0 ? scenes : Object.freeze(["direct"]),
...groupJids.length > 0 ? { groupJids } : {}
});
}
function validateAccessGrant(input, { requireGlobalAdmin = true } = {}) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u8BBF\u95EE\u6388\u6743\u6587\u6863\u65E0\u6548\u3002");
}
const version = input.version === void 0 ? ACCESS_GRANT_VERSION : Number(input.version);
if (version !== ACCESS_GRANT_VERSION) invalidGrant("\u8BBF\u95EE\u6388\u6743\u7248\u672C\u4E0D\u652F\u6301\u3002");
const globalAdmins = validatePhoneList(input.globalAdmins ?? [], {
label: "\u5168\u5C40\u7BA1\u7406\u5458",
emptyMessage: requireGlobalAdmin ? "\u81F3\u5C11\u4FDD\u7559\u4E00\u4F4D\u5168\u5C40\u7BA1\u7406\u5458\u3002" : void 0
});
if (requireGlobalAdmin && globalAdmins.length === 0) {
invalidGrant("\u81F3\u5C11\u4FDD\u7559\u4E00\u4F4D\u5168\u5C40\u7BA1\u7406\u5458\u3002");
}
const directMembers = Object.freeze((Array.isArray(input.directMembers) ? input.directMembers : []).map(validateMember));
if (new Set(directMembers.map((m) => m.phone)).size !== directMembers.length) {
invalidGrant("\u79C1\u804A\u6388\u6743\u7528\u6237\u4E0D\u80FD\u5305\u542B\u91CD\u590D\u7535\u8BDD\u3002");
}
const groups = /* @__PURE__ */ Object.create(null);
if (input.groups && typeof input.groups === "object" && !Array.isArray(input.groups)) {
for (const [groupJid, grant] of Object.entries(input.groups)) {
groups[validateGroupJid(groupJid)] = validateGroupGrant(grant);
}
}
const pending = Object.freeze((Array.isArray(input.pending) ? input.pending : []).map(validatePending).slice(0, 200));
const contacts = Object.freeze((Array.isArray(input.contacts) ? input.contacts : []).map(validateContact).slice(0, 500));
return Object.freeze({
version: ACCESS_GRANT_VERSION,
globalAdmins,
directMembers,
groups: Object.freeze(groups),
pending,
contacts
});
}
function normalizeAccessGrant(input) {
try {
return validateAccessGrant(input, { requireGlobalAdmin: false });
} catch {
return null;
}
}
var ACCESS_GRANT_COPY = Object.freeze({
pendingAckDirect: "\u5DF2\u63D0\u4EA4\u79C1\u804A\u8BBF\u95EE\u7533\u8BF7\uFF0C\u8BF7\u7B49\u5F85\u5168\u5C40\u7BA1\u7406\u5458\u5BA1\u6279\u3002",
pendingAckGroup: "\u5DF2\u63D0\u4EA4\u672C\u7FA4\u8BBF\u95EE\u7533\u8BF7\uFF0C\u8BF7\u7B49\u5F85\u7BA1\u7406\u5458\u5BA1\u6279\u3002",
pendingUnresolved: "\u5DF2\u8BB0\u5F55\u8BBF\u95EE\u7533\u8BF7\uFF0C\u4F46\u5C1A\u672A\u89E3\u6790\u5230\u7535\u8BDD\u53F7\u7801\uFF1B\u8BF7\u7BA1\u7406\u5458\u5728\u8BBE\u7F6E\u4E2D\u8865\u5168\u540E\u518D\u6279\u51C6\u3002",
notifyTitle: "[dsh-im-ops] \u8BBF\u95EE\u7533\u8BF7",
approvedDirect: "\u79C1\u804A\u8BBF\u95EE\u5DF2\u6279\u51C6\uFF0C\u73B0\u5728\u53EF\u4EE5\u76F4\u63A5\u5BF9\u8BDD\u3002",
approvedGroup: "\u672C\u7FA4\u8BBF\u95EE\u5DF2\u6279\u51C6\uFF0C\u8BF7\u5728\u672C\u7FA4 @ \u673A\u5668\u4EBA\u7EE7\u7EED\u3002",
denied: "\u8BBF\u95EE\u7533\u8BF7\u672A\u901A\u8FC7\u3002",
adminApproved: "\u5DF2\u6279\u51C6\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002",
adminDenied: "\u5DF2\u62D2\u7EDD\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002"
});
// src/channels/shared/session-scope.mjs
var GROUP_SESSION_SCOPES = Object.freeze(["chat", "user_in_chat"]);
var DEFAULT_GROUP_SESSION_SCOPE = "user_in_chat";
function normalizeGroupSessionScope(value) {
const scope = String(value ?? "").trim().toLowerCase();
if (scope === "chat" || scope === "shared" || scope === "shared_chat") return "chat";
if (scope === "user_in_chat" || scope === "user" || scope === "per_user" || scope === "member") {
return "user_in_chat";
}
return DEFAULT_GROUP_SESSION_SCOPE;
}
function validateGroupSessionScope(value) {
const scope = String(value ?? "").trim().toLowerCase();
if (scope === "chat" || scope === "user_in_chat") return scope;
const error = new TypeError(`Invalid group session scope: ${String(value)}`);
error.code = "invalid-group-session-scope";
throw error;
}
// plugin-src/client/channels/whatsapp/api.js // plugin-src/client/channels/whatsapp/api.js
var WHATSAPP_RPC_CHANNEL = "/whatsapp"; var WHATSAPP_RPC_CHANNEL = "/whatsapp";
var WHATSAPP_ENDPOINTS = Object.freeze({ var WHATSAPP_ENDPOINTS = Object.freeze({
@ -11077,6 +11286,9 @@ var WHATSAPP_ENDPOINTS = Object.freeze({
reconnectBot: "bot.reconnect", reconnectBot: "bot.reconnect",
deleteBot: "bot.delete", deleteBot: "bot.delete",
setAccessPolicy: "bot.access-policy.set", setAccessPolicy: "bot.access-policy.set",
setAccessGrant: "bot.access-grant.set",
resolveAccessPending: "bot.access-pending.resolve",
setGroupSessionScope: "bot.group-session-scope.set",
setWorkspace: "bot.workspace.set", setWorkspace: "bot.workspace.set",
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: "bot.context-enhancement.set" setContextEnhancement: "bot.context-enhancement.set"
@ -11147,6 +11359,8 @@ function normalizeBot6(value) {
agentPreset: normalizeAgentPresetId(value.agentPreset), agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement), contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...Object.hasOwn(value, "accessPolicy") ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) } : {}, ...Object.hasOwn(value, "accessPolicy") ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) } : {},
...Object.hasOwn(value, "accessGrant") ? { accessGrant: normalizeAccessGrant(value.accessGrant) } : {},
...Object.hasOwn(value, "groupSessionScope") ? { groupSessionScope: normalizeGroupSessionScope(value.groupSessionScope) } : {},
bot: { bot: {
name: text5(value.bot?.name, "WhatsApp\u673A\u5668\u4EBA", 100), name: text5(value.bot?.name, "WhatsApp\u673A\u5668\u4EBA", 100),
idMasked: text5(value.bot?.idMasked, "WhatsApp\u8D26\u53F7", 140) idMasked: text5(value.bot?.idMasked, "WhatsApp\u8D26\u53F7", 140)
@ -11840,27 +12054,6 @@ var React22 = __toESM(require("react"), 1);
// plugin-src/client/access-policy-settings.js // plugin-src/client/access-policy-settings.js
var React20 = __toESM(require("react"), 1); var React20 = __toESM(require("react"), 1);
// src/channels/shared/session-scope.mjs
var GROUP_SESSION_SCOPES = Object.freeze(["chat", "user_in_chat"]);
var DEFAULT_GROUP_SESSION_SCOPE = "user_in_chat";
function normalizeGroupSessionScope(value) {
const scope = String(value ?? "").trim().toLowerCase();
if (scope === "chat" || scope === "shared" || scope === "shared_chat") return "chat";
if (scope === "user_in_chat" || scope === "user" || scope === "per_user" || scope === "member") {
return "user_in_chat";
}
return DEFAULT_GROUP_SESSION_SCOPE;
}
function validateGroupSessionScope(value) {
const scope = String(value ?? "").trim().toLowerCase();
if (scope === "chat" || scope === "user_in_chat") return scope;
const error = new TypeError(`Invalid group session scope: ${String(value)}`);
error.code = "invalid-group-session-scope";
throw error;
}
// plugin-src/client/access-policy-settings.js
var ACCESS_POLICY_ENDPOINT = "bot.access-policy.set"; var ACCESS_POLICY_ENDPOINT = "bot.access-policy.set";
var GROUP_SESSION_SCOPE_ENDPOINT = "bot.group-session-scope.set"; var GROUP_SESSION_SCOPE_ENDPOINT = "bot.group-session-scope.set";
var ACCESS_CHANNEL_DEFINITIONS = Object.freeze({ var ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
@ -12323,190 +12516,6 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
// plugin-src/client/access-grant-settings.js // plugin-src/client/access-grant-settings.js
var React21 = __toESM(require("react"), 1); var React21 = __toESM(require("react"), 1);
// src/channels/shared/access-grant.mjs
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 CONTROL_CHARACTERS2 = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
function invalidGrant(message) {
const error = new TypeError(message);
error.code = "access-grant-invalid";
throw error;
}
function normalizeAccessPhone(value) {
if (typeof value === "number" && Number.isFinite(value)) value = String(value);
if (typeof value === "bigint") value = String(value);
if (typeof value !== "string") return null;
let raw = value.trim();
if (!raw || CONTROL_CHARACTERS2.test(raw)) return null;
const at = raw.indexOf("@");
if (at > 0) {
const user = raw.slice(0, at).split(":")[0];
const server = raw.slice(at + 1).toLowerCase();
if (!["s.whatsapp.net", "c.us", "hosted"].includes(server)) return null;
raw = user;
}
const digits = raw.replace(/[^\d]/g, "");
if (digits.length < 5 || digits.length > ACCESS_GRANT_PHONE_MAX_LENGTH) return null;
return digits;
}
function validateAccessPhone(value) {
const phone = normalizeAccessPhone(value);
if (!phone) invalidGrant("\u7535\u8BDD\u53F7\u7801\u65E0\u6548\u3002");
return phone;
}
function validateGroupJid(value) {
if (typeof value !== "string" || !ACCESS_GRANT_GROUP_JID_PATTERN.test(value.trim())) {
invalidGrant("\u7FA4 JID \u65E0\u6548\u3002");
}
return value.trim();
}
function validateMember(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u6210\u5458\u6761\u76EE\u65E0\u6548\u3002");
}
const phone = validateAccessPhone(input.phone ?? input.id);
const canExecuteCommands = input.canExecuteCommands === void 0 ? true : Boolean(input.canExecuteCommands);
return Object.freeze({ phone, canExecuteCommands });
}
function validatePhoneList(input, { emptyMessage, label }) {
if (!Array.isArray(input)) invalidGrant(`${label}\u5FC5\u987B\u662F\u6570\u7EC4\u3002`);
const phones = input.map((entry) => typeof entry === "string" || typeof entry === "number" || typeof entry === "bigint" ? validateAccessPhone(entry) : validateAccessPhone(entry?.phone ?? entry?.id));
if (new Set(phones).size !== phones.length) invalidGrant(`${label}\u4E0D\u80FD\u5305\u542B\u91CD\u590D\u7535\u8BDD\u3002`);
if (phones.length === 0 && emptyMessage) invalidGrant(emptyMessage);
return Object.freeze(phones);
}
function validateGroupGrant(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u7FA4\u6388\u6743\u6761\u76EE\u65E0\u6548\u3002");
}
const admins = validatePhoneList(input.admins ?? [], { label: "\u7FA4\u7BA1\u7406\u5458" });
const members = Object.freeze((Array.isArray(input.members) ? input.members : []).map(validateMember));
if (new Set(members.map((m) => m.phone)).size !== members.length) {
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) : "";
return Object.freeze({
...title ? { title } : {},
admins,
members
});
}
function validatePending(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u5F85\u6279\u6761\u76EE\u65E0\u6548\u3002");
}
const id5 = typeof input.id === "string" ? input.id.trim() : "";
if (!/^[A-Za-z0-9_-]{6,64}$/.test(id5)) invalidGrant("\u5F85\u6279\u7F16\u53F7\u65E0\u6548\u3002");
const kind = input.kind === "group" ? "group" : input.kind === "direct" ? "direct" : null;
if (!kind) invalidGrant("\u5F85\u6279\u573A\u666F\u65E0\u6548\u3002");
const phone = input.phone ? validateAccessPhone(input.phone) : "";
const lid = typeof input.lid === "string" ? input.lid.trim().slice(0, 128) : "";
if (!phone && !lid) invalidGrant("\u5F85\u6279\u7F3A\u5C11\u7535\u8BDD\u6216 LID\u3002");
const groupJid = kind === "group" ? validateGroupJid(input.groupJid) : void 0;
const pushName = typeof input.pushName === "string" ? input.pushName.trim().slice(0, 128) : "";
const requestText = typeof input.requestText === "string" ? input.requestText.trim().slice(0, 500) : "";
const createdAt = typeof input.createdAt === "string" && input.createdAt ? input.createdAt : (/* @__PURE__ */ new Date()).toISOString();
const unresolved = input.unresolved === true || !phone;
const notifyRefs = Array.isArray(input.notifyRefs) ? Object.freeze(input.notifyRefs.map((ref) => Object.freeze({
adminPhone: validateAccessPhone(ref.adminPhone),
providerMessageId: String(ref.providerMessageId ?? "").trim().slice(0, 128)
})).filter((ref) => ref.providerMessageId)) : Object.freeze([]);
const resolvedByPhone = input.resolvedByPhone ? validateAccessPhone(input.resolvedByPhone) : void 0;
const resolvedAt = typeof input.resolvedAt === "string" ? input.resolvedAt : void 0;
const status = ["pending", "approved", "denied", "expired"].includes(input.status) ? input.status : "pending";
return Object.freeze({
id: id5,
kind,
...groupJid ? { groupJid } : {},
phone: phone || "",
...lid ? { lid } : {},
...pushName ? { pushName } : {},
...requestText ? { requestText } : {},
createdAt,
unresolved,
notifyRefs,
status,
...resolvedByPhone ? { resolvedByPhone } : {},
...resolvedAt ? { resolvedAt } : {}
});
}
function validateContact(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u8054\u7CFB\u4EBA\u6761\u76EE\u65E0\u6548\u3002");
}
const phone = input.phone ? validateAccessPhone(input.phone) : void 0;
const lids = Object.freeze([...Array.isArray(input.lids) ? input.lids : []].map((lid) => String(lid ?? "").trim()).filter((lid) => lid && lid.length <= 128).slice(0, 8));
const pushName = typeof input.pushName === "string" ? input.pushName.trim().slice(0, 128) : "";
const lastSeenAt = typeof input.lastSeenAt === "string" && input.lastSeenAt ? input.lastSeenAt : (/* @__PURE__ */ new Date()).toISOString();
const scenes = Object.freeze([...Array.isArray(input.scenes) ? input.scenes : []].filter((scene) => scene === "direct" || scene === "group").slice(0, 2));
const groupJids = Object.freeze([...Array.isArray(input.groupJids) ? input.groupJids : []].map((jid) => String(jid ?? "").trim()).filter((jid) => ACCESS_GRANT_GROUP_JID_PATTERN.test(jid)).slice(0, 32));
if (!phone && lids.length === 0) invalidGrant("\u8054\u7CFB\u4EBA\u7F3A\u5C11\u7535\u8BDD\u6216 LID\u3002");
return Object.freeze({
...phone ? { phone } : {},
lids,
...pushName ? { pushName } : {},
lastSeenAt,
scenes: scenes.length > 0 ? scenes : Object.freeze(["direct"]),
...groupJids.length > 0 ? { groupJids } : {}
});
}
function validateAccessGrant(input, { requireGlobalAdmin = true } = {}) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
invalidGrant("\u8BBF\u95EE\u6388\u6743\u6587\u6863\u65E0\u6548\u3002");
}
const version = input.version === void 0 ? ACCESS_GRANT_VERSION : Number(input.version);
if (version !== ACCESS_GRANT_VERSION) invalidGrant("\u8BBF\u95EE\u6388\u6743\u7248\u672C\u4E0D\u652F\u6301\u3002");
const globalAdmins = validatePhoneList(input.globalAdmins ?? [], {
label: "\u5168\u5C40\u7BA1\u7406\u5458",
emptyMessage: requireGlobalAdmin ? "\u81F3\u5C11\u4FDD\u7559\u4E00\u4F4D\u5168\u5C40\u7BA1\u7406\u5458\u3002" : void 0
});
if (requireGlobalAdmin && globalAdmins.length === 0) {
invalidGrant("\u81F3\u5C11\u4FDD\u7559\u4E00\u4F4D\u5168\u5C40\u7BA1\u7406\u5458\u3002");
}
const directMembers = Object.freeze((Array.isArray(input.directMembers) ? input.directMembers : []).map(validateMember));
if (new Set(directMembers.map((m) => m.phone)).size !== directMembers.length) {
invalidGrant("\u79C1\u804A\u6388\u6743\u7528\u6237\u4E0D\u80FD\u5305\u542B\u91CD\u590D\u7535\u8BDD\u3002");
}
const groups = /* @__PURE__ */ Object.create(null);
if (input.groups && typeof input.groups === "object" && !Array.isArray(input.groups)) {
for (const [groupJid, grant] of Object.entries(input.groups)) {
groups[validateGroupJid(groupJid)] = validateGroupGrant(grant);
}
}
const pending = Object.freeze((Array.isArray(input.pending) ? input.pending : []).map(validatePending).slice(0, 200));
const contacts = Object.freeze((Array.isArray(input.contacts) ? input.contacts : []).map(validateContact).slice(0, 500));
return Object.freeze({
version: ACCESS_GRANT_VERSION,
globalAdmins,
directMembers,
groups: Object.freeze(groups),
pending,
contacts
});
}
function normalizeAccessGrant(input) {
try {
return validateAccessGrant(input, { requireGlobalAdmin: false });
} catch {
return null;
}
}
var ACCESS_GRANT_COPY = Object.freeze({
pendingAckDirect: "\u5DF2\u63D0\u4EA4\u79C1\u804A\u8BBF\u95EE\u7533\u8BF7\uFF0C\u8BF7\u7B49\u5F85\u5168\u5C40\u7BA1\u7406\u5458\u5BA1\u6279\u3002",
pendingAckGroup: "\u5DF2\u63D0\u4EA4\u672C\u7FA4\u8BBF\u95EE\u7533\u8BF7\uFF0C\u8BF7\u7B49\u5F85\u7BA1\u7406\u5458\u5BA1\u6279\u3002",
pendingUnresolved: "\u5DF2\u8BB0\u5F55\u8BBF\u95EE\u7533\u8BF7\uFF0C\u4F46\u5C1A\u672A\u89E3\u6790\u5230\u7535\u8BDD\u53F7\u7801\uFF1B\u8BF7\u7BA1\u7406\u5458\u5728\u8BBE\u7F6E\u4E2D\u8865\u5168\u540E\u518D\u6279\u51C6\u3002",
notifyTitle: "[dsh-im-ops] \u8BBF\u95EE\u7533\u8BF7",
approvedDirect: "\u79C1\u804A\u8BBF\u95EE\u5DF2\u6279\u51C6\uFF0C\u73B0\u5728\u53EF\u4EE5\u76F4\u63A5\u5BF9\u8BDD\u3002",
approvedGroup: "\u672C\u7FA4\u8BBF\u95EE\u5DF2\u6279\u51C6\uFF0C\u8BF7\u5728\u672C\u7FA4 @ \u673A\u5668\u4EBA\u7EE7\u7EED\u3002",
denied: "\u8BBF\u95EE\u7533\u8BF7\u672A\u901A\u8FC7\u3002",
adminApproved: "\u5DF2\u6279\u51C6\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002",
adminDenied: "\u5DF2\u62D2\u7EDD\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002"
});
// plugin-src/client/access-grant-settings.js
var ACCESS_GRANT_ENDPOINT = "bot.access-grant.set"; var ACCESS_GRANT_ENDPOINT = "bot.access-grant.set";
var ACCESS_PENDING_RESOLVE_ENDPOINT = "bot.access-pending.resolve"; var ACCESS_PENDING_RESOLVE_ENDPOINT = "bot.access-pending.resolve";
var GROUP_SESSION_SCOPE_ENDPOINT2 = "bot.group-session-scope.set"; var GROUP_SESSION_SCOPE_ENDPOINT2 = "bot.group-session-scope.set";
@ -12978,16 +12987,81 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
"fieldset", "fieldset",
{ className: "dim-accessScene", disabled: saving }, { className: "dim-accessScene", disabled: saving },
h2("legend", null, "\u6700\u8FD1\u8054\u7CFB\u4EBA\uFF08\u81EA\u52A8\u6C89\u6DC0\uFF09"), h2("legend", null, "\u6700\u8FD1\u8054\u7CFB\u4EBA\uFF08\u81EA\u52A8\u6C89\u6DC0\uFF09"),
contacts.length === 0 ? h2("div", { className: "dim-accessUsersEmpty" }, "\u6682\u65E0\u8054\u7CFB\u4EBA\u3002\u79C1\u804A\u6216\u7FA4\u5185\u89E6\u53D1\u540E\u4F1A\u51FA\u73B0\u5728\u6B64\uFF0C\u4FBF\u4E8E\u8865\u9F50\u7535\u8BDD\u4E0E\u6635\u79F0\u3002") : h2("ul", { className: "dim-accessUserList" }, contacts.slice(0, 30).map((contact) => h2( h2(
"li", "p",
{ { className: "dim-accessUsersEmpty" },
key: `${contact.phone ?? ""}-${(contact.lids ?? []).join(",")}`, "\u4EC5\u8BB0\u5F55\u79C1\u804A\u673A\u5668\u4EBA\uFF0C\u6216\u5728\u7FA4\u91CC @ \u673A\u5668\u4EBA\u7684\u4EBA\u3002\u7535\u8BDD\u662F\u552F\u4E00\u6388\u6743\u952E\uFF0C\u53EF\u4E00\u952E\u52A0\u5165\u79C1\u804A/\u672C\u7FA4\u6388\u6743\u3002"
className: "dim-accessUserRow" ),
}, contacts.length === 0 ? h2("div", { className: "dim-accessUsersEmpty" }, "\u6682\u65E0\u8054\u7CFB\u4EBA\u3002\u6709\u4EBA\u79C1\u804A\u6216 @ \u673A\u5668\u4EBA\u540E\u4F1A\u51FA\u73B0\u5728\u6B64\u3002") : h2("ul", { className: "dim-accessUserList" }, contacts.slice(0, 30).map((contact) => {
h2("span", null, contactLabel(contact)), const phone = contact.phone || "";
h2("span", null, (contact.scenes ?? []).join("/")), const alreadyDirect = phone && draft.directMembers.some((m) => m.phone === phone);
contact.phone ? null : h2("span", null, "\u5F85\u8865\u7535\u8BDD") const groupTargets = (contact.groupJids ?? []).filter((jid) => knownGroupJids.includes(jid));
))) return h2(
"li",
{
key: `${contact.phone ?? ""}-${(contact.lids ?? []).join(",")}`,
className: "dim-accessUserRow"
},
h2(
"div",
{ className: "dim-accessField" },
h2("strong", null, contactLabel(contact)),
h2("span", null, (contact.scenes ?? []).join(" / ")),
phone ? null : h2("span", null, "\u5F85\u8865\u7535\u8BDD")
),
phone ? h2("button", {
type: "button",
className: "dim-deliveryButton",
"data-kind": "primary",
disabled: saving || alreadyDirect,
onClick: () => {
setDraft((current) => {
if (current.directMembers.some((m) => m.phone === phone)) return current;
return {
...current,
directMembers: [
...current.directMembers,
{ phone, canExecuteCommands: true }
]
};
});
setFeedback({ tone: "success", message: "\u5DF2\u52A0\u5165\u79C1\u804A\u6388\u6743\uFF0C\u8BB0\u5F97\u70B9\u4FDD\u5B58\u3002" });
}
}, alreadyDirect ? "\u5DF2\u5728\u79C1\u804A" : "\u52A0\u79C1\u804A") : null,
...groupTargets.map((groupJid) => {
const group = draft.groups[groupJid] ?? { admins: [], members: [] };
const already = group.admins.includes(phone) || group.members.some((m) => m.phone === phone);
return h2("button", {
key: `add-${groupJid}`,
type: "button",
className: "dim-deliveryButton",
disabled: saving || !phone || already,
onClick: () => {
setDraft((current) => {
const existing = current.groups[groupJid] ?? { title: "", admins: [], members: [] };
if (existing.admins.includes(phone) || existing.members.some((m) => m.phone === phone)) {
return current;
}
return {
...current,
groups: {
...current.groups,
[groupJid]: {
...existing,
members: [
...existing.members,
{ phone, canExecuteCommands: true }
]
}
}
};
});
setFeedback({ tone: "success", message: "\u5DF2\u52A0\u5165\u672C\u7FA4\u6388\u6743\uFF0C\u8BB0\u5F97\u70B9\u4FDD\u5B58\u3002" });
}
}, already ? "\u5DF2\u5728\u672C\u7FA4" : "\u52A0\u672C\u7FA4");
})
);
}))
), ),
h2( h2(
"fieldset", "fieldset",

File diff suppressed because one or more lines are too long

View file

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

View file

@ -457,16 +457,76 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved })
}, '添加群'))), }, '添加群'))),
h('fieldset', { className: 'dim-accessScene', disabled: saving }, h('fieldset', { className: 'dim-accessScene', disabled: saving },
h('legend', null, '最近联系人(自动沉淀)'), h('legend', null, '最近联系人(自动沉淀)'),
h('p', { className: 'dim-accessUsersEmpty' },
'仅记录私聊机器人,或在群里 @ 机器人的人。电话是唯一授权键,可一键加入私聊/本群授权。'),
contacts.length === 0 contacts.length === 0
? h('div', { className: 'dim-accessUsersEmpty' }, '暂无联系人。私聊或群内触发后会出现在此,便于补齐电话与昵称。') ? h('div', { className: 'dim-accessUsersEmpty' }, '暂无联系人。有人私聊或 @ 机器人后会出现在此。')
: h('ul', { className: 'dim-accessUserList' }, contacts.slice(0, 30).map((contact) => : h('ul', { className: 'dim-accessUserList' }, contacts.slice(0, 30).map((contact) => {
h('li', { const phone = contact.phone || '';
const alreadyDirect = phone && draft.directMembers.some((m) => m.phone === phone);
const groupTargets = (contact.groupJids ?? []).filter((jid) => knownGroupJids.includes(jid));
return h('li', {
key: `${contact.phone ?? ''}-${(contact.lids ?? []).join(',')}`, key: `${contact.phone ?? ''}-${(contact.lids ?? []).join(',')}`,
className: 'dim-accessUserRow', className: 'dim-accessUserRow',
}, },
h('span', null, contactLabel(contact)), h('div', { className: 'dim-accessField' },
h('span', null, (contact.scenes ?? []).join('/')), h('strong', null, contactLabel(contact)),
contact.phone ? null : h('span', null, '待补电话'))))), h('span', null, (contact.scenes ?? []).join(' / ')),
phone ? null : h('span', null, '待补电话')),
phone ? h('button', {
type: 'button',
className: 'dim-deliveryButton',
'data-kind': 'primary',
disabled: saving || alreadyDirect,
onClick: () => {
setDraft((current) => {
if (current.directMembers.some((m) => m.phone === phone)) return current;
return {
...current,
directMembers: [
...current.directMembers,
{ phone, canExecuteCommands: true },
],
};
});
setFeedback({ tone: 'success', message: '已加入私聊授权,记得点保存。' });
},
}, alreadyDirect ? '已在私聊' : '加私聊') : null,
...groupTargets.map((groupJid) => {
const group = draft.groups[groupJid] ?? { admins: [], members: [] };
const already = group.admins.includes(phone)
|| group.members.some((m) => m.phone === phone);
return h('button', {
key: `add-${groupJid}`,
type: 'button',
className: 'dim-deliveryButton',
disabled: saving || !phone || already,
onClick: () => {
setDraft((current) => {
const existing = current.groups[groupJid] ?? { title: '', admins: [], members: [] };
if (existing.admins.includes(phone)
|| existing.members.some((m) => m.phone === phone)) {
return current;
}
return {
...current,
groups: {
...current.groups,
[groupJid]: {
...existing,
members: [
...existing.members,
{ phone, canExecuteCommands: true },
],
},
},
};
});
setFeedback({ tone: 'success', message: '已加入本群授权,记得点保存。' });
},
}, already ? '已在本群' : '加本群');
}));
}))),
h('fieldset', { className: 'dim-accessScene', disabled: saving }, h('fieldset', { className: 'dim-accessScene', disabled: saving },
h('legend', null, '群会话策略'), h('legend', null, '群会话策略'),
h('label', { className: 'dim-accessField' }, h('label', { className: 'dim-accessField' },

View file

@ -1,7 +1,9 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js'; import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js'; import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessGrant } from '../../../../src/channels/shared/access-grant.mjs';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs'; import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs'; import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
import { normalizeGroupSessionScope } from '../../../../src/channels/shared/session-scope.mjs';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp'; export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
@ -13,6 +15,9 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect', reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete', deleteBot: 'bot.delete',
setAccessPolicy: 'bot.access-policy.set', setAccessPolicy: 'bot.access-policy.set',
setAccessGrant: 'bot.access-grant.set',
resolveAccessPending: 'bot.access-pending.resolve',
setGroupSessionScope: 'bot.group-session-scope.set',
setWorkspace: 'bot.workspace.set', setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set', setContextEnhancement: 'bot.context-enhancement.set',
@ -95,6 +100,12 @@ function normalizeBot(value) {
...(Object.hasOwn(value, 'accessPolicy') ...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) } ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
: {}), : {}),
...(Object.hasOwn(value, 'accessGrant')
? { accessGrant: normalizeAccessGrant(value.accessGrant) }
: {}),
...(Object.hasOwn(value, 'groupSessionScope')
? { groupSessionScope: normalizeGroupSessionScope(value.groupSessionScope) }
: {}),
bot: { bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100), name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140), idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),

View file

@ -11,6 +11,22 @@ const EN = Object.freeze({
'机器人设置页签': 'Bot settings tabs', '机器人设置页签': 'Bot settings tabs',
'投递设置': 'Delivery settings', '投递设置': 'Delivery settings',
'访问设置': 'Access settings', '访问设置': 'Access settings',
"加本群": "Add to group",
"已在本群": "Already in group",
"加私聊": "Add to DMs",
"已在私聊": "Already in DMs",
"已加入本群授权,记得点保存。": "Added to this group. Remember to save.",
"已加入私聊授权,记得点保存。": "Added to DM access. Remember to save.",
"暂无联系人。有人私聊或 @ 机器人后会出现在此。": "No contacts yet. They appear here after someone DMs or @mentions the bot.",
"仅记录私聊机器人,或在群里 @ 机器人的人。电话是唯一授权键,可一键加入私聊/本群授权。": "Only people who DM the bot or @mention it in a group are recorded. Phone is the sole auth key; use one-click to grant DM or group access.",
"WhatsApp 按电话号码授权:全局管理员管私聊;群管理员只批本群。成员不可跨群、有群权也不自动获得私聊权。": "WhatsApp authorizes by phone number: global admins manage DMs; group admins approve only their group. Members do not cross groups, and group access does not grant DMs.", "WhatsApp 按电话号码授权:全局管理员管私聊;群管理员只批本群。成员不可跨群、有群权也不自动获得私聊权。": "WhatsApp authorizes by phone number: global admins manage DMs; group admins approve only their group. Members do not cross groups, and group access does not grant DMs.",
"保存分级访问设置": "Save graded access settings", "保存分级访问设置": "Save graded access settings",
"保存访问与会话设置": "Save access and session settings", "保存访问与会话设置": "Save access and session settings",

View file

@ -8,6 +8,7 @@ import {
attachPendingNotifyRefs, attachPendingNotifyRefs,
emptyAccessGrant, emptyAccessGrant,
enqueueAccessPending, enqueueAccessPending,
ensureGroupBucket,
ensureOwnerGlobalAdmin, ensureOwnerGlobalAdmin,
evaluateAccessGrant, evaluateAccessGrant,
findPendingByNotifyMessageId, findPendingByNotifyMessageId,
@ -96,16 +97,22 @@ export async function loadWhatsappAccessGrant({
* Persist contact sighting and return updated grant. * Persist contact sighting and return updated grant.
*/ */
export async function rememberWhatsappContact(workspaces, botId, grant, message, { phone, lid }) { export async function rememberWhatsappContact(workspaces, botId, grant, message, { phone, lid }) {
if (!phone && !lid) return grant;
const pushName = message.contextSource?.()?.senderName; const pushName = message.contextSource?.()?.senderName;
const next = upsertAccessContact(grant, { try {
phone, const next = upsertAccessContact(grant, {
lid, phone,
pushName, lid,
scene: message.kind === 'group' ? 'group' : 'direct', pushName,
groupJid: message.kind === 'group' ? message.conversationId : undefined, scene: message.kind === 'group' ? 'group' : 'direct',
}); groupJid: message.kind === 'group' ? message.conversationId : undefined,
await workspaces.setAccessGrant(botId, next); });
return next; await workspaces.setAccessGrant(botId, next);
return next;
} catch {
// Contact directory is best-effort and must not block chat.
return grant;
}
} }
/** /**
@ -189,9 +196,25 @@ export async function gateWhatsappInbound({
accountJid, accountJid,
}) { }) {
const { phone, lid } = resolveInboundPhone(message); const { phone, lid } = resolveInboundPhone(message);
let current = await rememberWhatsappContact(workspaces, botId, grant, message, { phone, lid });
const scene = message.kind === 'group' ? 'group' : 'direct'; const scene = message.kind === 'group' ? 'group' : 'direct';
const addressed = scene === 'direct' || message.addressed === true;
let current = grant;
// Only people who DM the bot or @ it in a group enter the contact directory.
if (addressed) {
current = await rememberWhatsappContact(workspaces, botId, current, message, { phone, lid });
}
if (scene === 'group' && addressed && message.conversationId) {
const title = message.contextSource?.()?.conversationTitle;
const withGroup = ensureGroupBucket(current, message.conversationId, {
...(title ? { title } : {}),
});
if (withGroup !== current) {
current = withGroup;
await workspaces.setAccessGrant(botId, current);
}
}
const isCommand = isSharedLocalCommand(message.content ?? '', { const isCommand = isSharedLocalCommand(message.content ?? '', {
hasImages: Array.isArray(message.images) && message.images.length > 0, hasImages: Array.isArray(message.images) && message.images.length > 0,
hasFiles: Array.isArray(message.files) && message.files.length > 0, hasFiles: Array.isArray(message.files) && message.files.length > 0,
@ -228,7 +251,7 @@ export async function gateWhatsappInbound({
} }
// Unaddressed group spam: do not create pending. // Unaddressed group spam: do not create pending.
if (scene === 'group' && message.addressed !== true) { if (!addressed) {
return { allowed: false, reason: 'group-unaddressed', grant: current }; return { allowed: false, reason: 'group-unaddressed', grant: current };
} }

View file

@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeSnapshot } from '../../../plugin-src/client/channels/whatsapp/api.js';
test('WhatsApp normalizeBot keeps accessGrant, groupSessionScope, and contacts', () => {
const snapshot = normalizeSnapshot({
revision: 1,
bots: [{
botId: 'wa-1',
connected: true,
state: 'connected',
workspace: '/tmp/ws',
groupSessionScope: 'user_in_chat',
accessGrant: {
version: 1,
globalAdmins: ['8618111111111'],
directMembers: [{ phone: '8618222222222', canExecuteCommands: true }],
groups: {
'120363111111111111@g.us': {
title: 'Ops',
admins: [],
members: [{ phone: '8618333333333', canExecuteCommands: true }],
},
},
pending: [{
id: 'p_test01',
kind: 'group',
groupJid: '120363111111111111@g.us',
phone: '8618444444444',
createdAt: '2026-01-01T00:00:00.000Z',
status: 'pending',
unresolved: false,
notifyRefs: [],
}],
contacts: [{
phone: '8618444444444',
lids: [],
pushName: 'Alice',
lastSeenAt: '2026-01-01T00:00:00.000Z',
scenes: ['group'],
groupJids: ['120363111111111111@g.us'],
}],
},
bot: { name: 'Bot', idMasked: '+86***' },
health: { summary: 'ok' },
}],
});
const bot = snapshot.bots[0];
assert.equal(bot.groupSessionScope, 'user_in_chat');
assert.deepEqual(bot.accessGrant.globalAdmins, ['8618111111111']);
assert.equal(bot.accessGrant.directMembers[0].phone, '8618222222222');
assert.ok(bot.accessGrant.groups['120363111111111111@g.us']);
assert.equal(bot.accessGrant.contacts[0].phone, '8618444444444');
assert.equal(bot.accessGrant.pending[0].id, 'p_test01');
});