mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
Refresh missing/forced group titles via groupMetadata and participating catalog so cards show real names instead of bare JIDs. Co-authored-by: Cursor <cursoragent@cursor.com>
715 lines
28 KiB
JavaScript
715 lines
28 KiB
JavaScript
import * as React from 'react';
|
||
|
||
import {
|
||
normalizeAccessGrant,
|
||
normalizeAccessPhone,
|
||
validateAccessGrant,
|
||
} from '../../src/channels/shared/access-grant.mjs';
|
||
import {
|
||
DEFAULT_GROUP_SESSION_SCOPE,
|
||
normalizeGroupSessionScope,
|
||
validateGroupSessionScope,
|
||
} from '../../src/channels/shared/session-scope.mjs';
|
||
import { h, localizeText } from './i18n.js';
|
||
|
||
export const ACCESS_GRANT_ENDPOINT = 'bot.access-grant.set';
|
||
export const ACCESS_PENDING_RESOLVE_ENDPOINT = 'bot.access-pending.resolve';
|
||
export const ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = 'bot.access-group-titles.refresh';
|
||
export const GROUP_SESSION_SCOPE_ENDPOINT = 'bot.group-session-scope.set';
|
||
|
||
function unwrapRpcResult(result) {
|
||
if (result?.ok === true) return result.value;
|
||
if (result?.ok === false) {
|
||
const error = new Error(result.error?.message || '访问授权保存失败,请稍后重试。');
|
||
error.code = result.error?.code;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function grantFromSnapshot(value, botId) {
|
||
const source = value?.snapshot ?? value;
|
||
const bot = Array.isArray(source?.bots)
|
||
? source.bots.find((entry) => entry?.botId === botId)
|
||
: null;
|
||
return normalizeAccessGrant(bot?.accessGrant ?? source?.accessGrant ?? source?.grant);
|
||
}
|
||
|
||
function ownerPhoneFromAccount(account) {
|
||
return normalizeAccessPhone(account?.accountJid) ?? normalizeAccessPhone(account?.phone) ?? '';
|
||
}
|
||
|
||
function emptyDraft(ownerPhone) {
|
||
return {
|
||
version: 1,
|
||
globalAdmins: ownerPhone ? [ownerPhone] : [],
|
||
directMembers: [],
|
||
groups: {},
|
||
pending: [],
|
||
contacts: [],
|
||
};
|
||
}
|
||
|
||
function cloneGrant(grant, ownerPhone) {
|
||
const base = grant ?? emptyDraft(ownerPhone);
|
||
return {
|
||
version: 1,
|
||
globalAdmins: [...(base.globalAdmins ?? [])],
|
||
directMembers: (base.directMembers ?? []).map((m) => ({ ...m })),
|
||
groups: Object.fromEntries(Object.entries(base.groups ?? {}).map(([jid, group]) => [jid, {
|
||
title: group.title ?? '',
|
||
admins: [...(group.admins ?? [])],
|
||
members: (group.members ?? []).map((m) => ({ ...m })),
|
||
}])),
|
||
pending: [...(base.pending ?? [])],
|
||
contacts: [...(base.contacts ?? [])],
|
||
};
|
||
}
|
||
|
||
function contactLabel(contact) {
|
||
const name = contact.pushName || '未命名';
|
||
const phone = contact.phone || '待补电话';
|
||
return `${name} · ${phone}`;
|
||
}
|
||
|
||
function nicknameForPhone(contacts, phone) {
|
||
const digits = digitsOnly(phone);
|
||
if (!digits) return '';
|
||
const hit = (contacts ?? []).find((contact) => contact.phone === digits || contact.phone === phone);
|
||
return typeof hit?.pushName === 'string' ? hit.pushName.trim() : '';
|
||
}
|
||
|
||
function NicknameHint({ contacts, phone }) {
|
||
const nickname = nicknameForPhone(contacts, phone);
|
||
return h('span', {
|
||
className: nickname ? 'dim-accessNickname' : 'dim-accessNickname dim-accessNicknameEmpty',
|
||
}, nickname || '暂无昵称(私聊/@ 后会自动补齐)');
|
||
}
|
||
|
||
function groupDisplayName(groupJid, groups) {
|
||
const title = groups?.[groupJid]?.title;
|
||
if (typeof title === 'string' && title.trim()) return title.trim();
|
||
return '未命名群';
|
||
}
|
||
|
||
function digitsOnly(value) {
|
||
return String(value ?? '').replace(/[^\d]/g, '');
|
||
}
|
||
|
||
function PhoneTypeahead({
|
||
value,
|
||
onChange,
|
||
contacts,
|
||
placeholder,
|
||
disabled,
|
||
requirePhone = true,
|
||
}) {
|
||
const [query, setQuery] = React.useState(value || '');
|
||
const [open, setOpen] = React.useState(false);
|
||
const blurTimer = React.useRef(null);
|
||
React.useEffect(() => { setQuery(value || ''); }, [value]);
|
||
React.useEffect(() => () => {
|
||
if (blurTimer.current) window.clearTimeout(blurTimer.current);
|
||
}, []);
|
||
|
||
const q = query.trim().toLowerCase();
|
||
const queryDigits = digitsOnly(query);
|
||
const suggestions = (contacts ?? [])
|
||
.filter((contact) => {
|
||
if (requirePhone && !contact.phone) return false;
|
||
// Exact current value: hide the redundant chip that was covering the next field.
|
||
if (contact.phone && contact.phone === queryDigits && contact.phone === digitsOnly(value)) {
|
||
return false;
|
||
}
|
||
if (!q) return Boolean(contact.phone);
|
||
return (contact.phone ?? '').includes(queryDigits)
|
||
|| (contact.pushName ?? '').toLowerCase().includes(q);
|
||
})
|
||
.slice(0, 8);
|
||
const showSuggestions = open && !disabled && suggestions.length > 0;
|
||
|
||
return h('div', { className: 'dim-accessTypeahead' },
|
||
h('input', {
|
||
value: query,
|
||
disabled,
|
||
placeholder,
|
||
maxLength: 32,
|
||
autoCapitalize: 'none',
|
||
autoCorrect: 'off',
|
||
spellCheck: false,
|
||
autoComplete: 'off',
|
||
onFocus: () => {
|
||
if (blurTimer.current) window.clearTimeout(blurTimer.current);
|
||
setOpen(true);
|
||
},
|
||
onBlur: () => {
|
||
blurTimer.current = window.setTimeout(() => setOpen(false), 120);
|
||
},
|
||
onChange: (event) => {
|
||
setQuery(event.target.value);
|
||
onChange(event.target.value);
|
||
setOpen(true);
|
||
},
|
||
}),
|
||
showSuggestions ? h('ul', { className: 'dim-accessSuggestList', role: 'listbox' },
|
||
suggestions.map((contact) => h('li', { key: `${contact.phone}-${contact.lids?.[0] ?? ''}` },
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-accessSuggestItem',
|
||
disabled: disabled || (requirePhone && !contact.phone),
|
||
onMouseDown: (event) => event.preventDefault(),
|
||
onClick: () => {
|
||
if (!contact.phone) return;
|
||
setQuery(contact.phone);
|
||
onChange(contact.phone);
|
||
setOpen(false);
|
||
},
|
||
}, contactLabel(contact))))) : null);
|
||
}
|
||
|
||
function MemberRows({
|
||
title,
|
||
members,
|
||
contacts,
|
||
disabled,
|
||
onChange,
|
||
nested = false,
|
||
}) {
|
||
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('ul', { className: 'dim-accessUserList' }, members.map((member, index) =>
|
||
h('li', { key: `member-${index}`, className: 'dim-accessUserRow' },
|
||
h('label', { className: 'dim-accessField dim-accessUserId' },
|
||
h('span', null, '电话'),
|
||
h(PhoneTypeahead, {
|
||
value: member.phone,
|
||
contacts,
|
||
disabled,
|
||
placeholder: '8613800000000',
|
||
onChange: (phone) => onChange(members.map((entry, i) => (
|
||
i === index ? { ...entry, phone } : entry
|
||
))),
|
||
}),
|
||
h(NicknameHint, { contacts, phone: member.phone })),
|
||
h('label', { className: 'dim-accessField dim-accessUserCommand' },
|
||
h('span', null, '命令权限'),
|
||
h('select', {
|
||
value: member.canExecuteCommands ? 'allow' : 'deny',
|
||
onChange: (event) => onChange(members.map((entry, i) => (
|
||
i === index
|
||
? { ...entry, canExecuteCommands: event.target.value === 'allow' }
|
||
: entry
|
||
))),
|
||
},
|
||
h('option', { value: 'allow' }, '可以执行命令'),
|
||
h('option', { value: 'deny' }, '不可以执行命令'))),
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
'data-kind': 'danger',
|
||
onClick: () => onChange(members.filter((_, i) => i !== index)),
|
||
}, '删除')))),
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
onClick: () => onChange([...members, { phone: '', canExecuteCommands: true }]),
|
||
}, '新增用户'));
|
||
}
|
||
|
||
function AdminPhones({
|
||
title,
|
||
help,
|
||
phones,
|
||
contacts,
|
||
disabled,
|
||
lockedPhone,
|
||
onChange,
|
||
nested = false,
|
||
}) {
|
||
return h(nested ? 'div' : 'fieldset', {
|
||
className: nested ? 'dim-accessSubblock' : 'dim-accessScene',
|
||
...(nested ? {} : { disabled }),
|
||
},
|
||
nested ? h('h4', { className: 'dim-accessSubblockTitle' }, title) : h('legend', null, title),
|
||
help ? h('p', { className: 'dim-accessHint' }, help) : null,
|
||
h('ul', { className: 'dim-accessUserList' }, phones.map((phone, index) => {
|
||
const locked = lockedPhone && phone === lockedPhone;
|
||
return h('li', { key: `admin-${index}`, className: 'dim-accessUserRow' },
|
||
h('label', { className: 'dim-accessField dim-accessUserId' },
|
||
h('span', null, locked ? '绑定账号(全局管理员)' : '电话'),
|
||
h(PhoneTypeahead, {
|
||
value: phone,
|
||
contacts,
|
||
disabled: disabled || locked,
|
||
placeholder: '8613800000000',
|
||
onChange: (next) => onChange(phones.map((entry, i) => (i === index ? next : entry))),
|
||
}),
|
||
locked ? null : h(NicknameHint, { contacts, phone })),
|
||
locked ? null : h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
'data-kind': 'danger',
|
||
onClick: () => onChange(phones.filter((_, i) => i !== index)),
|
||
}, '删除'));
|
||
})),
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
onClick: () => onChange([...phones, '']),
|
||
}, '新增管理员'));
|
||
}
|
||
|
||
/**
|
||
* WhatsApp graded access settings (phone-canonical).
|
||
*/
|
||
export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) {
|
||
if (channel !== 'whatsapp') {
|
||
return h('div', { className: 'dim-accessState', role: 'alert' },
|
||
'当前渠道仍使用旧版访问设置。');
|
||
}
|
||
const ownerPhone = ownerPhoneFromAccount(account);
|
||
const initialGrant = normalizeAccessGrant(account?.accessGrant);
|
||
const initialKey = JSON.stringify(initialGrant);
|
||
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
|
||
const [draft, setDraft] = React.useState(() => cloneGrant(initialGrant, ownerPhone));
|
||
const [groupSessionScope, setGroupSessionScope] = React.useState(initialScope);
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [feedback, setFeedback] = React.useState(null);
|
||
const [newGroupJid, setNewGroupJid] = React.useState('');
|
||
const [loadingGrant, setLoadingGrant] = React.useState(!initialGrant);
|
||
|
||
React.useEffect(() => {
|
||
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
|
||
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
|
||
}, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]);
|
||
|
||
// Settings open payload historically omitted accessGrant; always refresh from status.
|
||
React.useEffect(() => {
|
||
if (typeof rpcCall !== 'function' || !account?.botId) return undefined;
|
||
let cancelled = false;
|
||
setLoadingGrant(true);
|
||
void (async () => {
|
||
try {
|
||
const value = unwrapRpcResult(await rpcCall('connection.status', {}));
|
||
if (cancelled) return;
|
||
const bot = Array.isArray(value?.bots)
|
||
? value.bots.find((entry) => entry?.botId === account.botId)
|
||
: null;
|
||
let grant = normalizeAccessGrant(bot?.accessGrant);
|
||
if (grant) {
|
||
setDraft(cloneGrant(grant, ownerPhone));
|
||
onSaved?.(grant);
|
||
}
|
||
if (bot?.groupSessionScope) {
|
||
setGroupSessionScope(normalizeGroupSessionScope(bot.groupSessionScope));
|
||
}
|
||
|
||
const missingTitles = Object.entries(grant?.groups ?? {})
|
||
.filter(([, group]) => !(typeof group?.title === 'string' && group.title.trim()))
|
||
.map(([jid]) => jid);
|
||
const pendingGroupJids = (grant?.pending ?? [])
|
||
.map((entry) => entry.groupJid)
|
||
.filter((jid) => typeof jid === 'string' && jid.endsWith('@g.us'));
|
||
const toSync = [...new Set([...missingTitles, ...pendingGroupJids])];
|
||
if (toSync.length > 0) {
|
||
try {
|
||
const refreshed = unwrapRpcResult(await rpcCall(ACCESS_GROUP_TITLES_REFRESH_ENDPOINT, {
|
||
botId: account.botId,
|
||
groupJids: toSync,
|
||
}));
|
||
if (cancelled) return;
|
||
const next = grantFromSnapshot(refreshed, account.botId);
|
||
if (next) {
|
||
setDraft(cloneGrant(next, ownerPhone));
|
||
onSaved?.(next);
|
||
}
|
||
} catch {
|
||
// Title sync is best-effort while the bot is offline.
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (!cancelled) {
|
||
setFeedback({
|
||
tone: 'error',
|
||
message: error?.message || '无法读取访问授权,请返回刷新后重试。',
|
||
});
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoadingGrant(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [account?.botId, rpcCall, ownerPhone]);
|
||
|
||
const contacts = draft.contacts ?? [];
|
||
const knownGroupJids = React.useMemo(() => {
|
||
const set = new Set(Object.keys(draft.groups ?? {}));
|
||
for (const contact of contacts) {
|
||
for (const jid of contact.groupJids ?? []) set.add(jid);
|
||
}
|
||
for (const pending of draft.pending ?? []) {
|
||
if (pending.groupJid) set.add(pending.groupJid);
|
||
}
|
||
return [...set].sort();
|
||
}, [draft.groups, draft.pending, contacts]);
|
||
|
||
const resolvePending = async (pendingId, action) => {
|
||
setFeedback(null);
|
||
setSaving(true);
|
||
try {
|
||
if (typeof rpcCall !== 'function') throw new Error('访问授权暂不可用。');
|
||
const resolvedByPhone = ownerPhone || draft.globalAdmins[0];
|
||
if (!resolvedByPhone) throw new Error('缺少可用于审批的全局管理员电话。');
|
||
const value = unwrapRpcResult(await rpcCall(ACCESS_PENDING_RESOLVE_ENDPOINT, {
|
||
botId: account.botId,
|
||
pendingId,
|
||
action,
|
||
resolvedByPhone,
|
||
}));
|
||
const saved = grantFromSnapshot(value, account.botId);
|
||
if (!saved) throw new Error('服务没有返回已保存的访问授权。');
|
||
setDraft(cloneGrant(saved, ownerPhone));
|
||
onSaved?.(saved);
|
||
setFeedback({
|
||
tone: 'success',
|
||
message: action === 'approve' ? '已批准申请。' : '已拒绝申请。',
|
||
});
|
||
} catch (error) {
|
||
setFeedback({ tone: 'error', message: error?.message || '审批失败。' });
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const save = async (event) => {
|
||
event.preventDefault();
|
||
setFeedback(null);
|
||
setSaving(true);
|
||
try {
|
||
const admins = [...new Set(draft.globalAdmins
|
||
.map((phone) => normalizeAccessPhone(phone))
|
||
.filter(Boolean))];
|
||
if (ownerPhone && !admins.includes(ownerPhone)) admins.unshift(ownerPhone);
|
||
const grant = validateAccessGrant({
|
||
...draft,
|
||
globalAdmins: admins,
|
||
directMembers: draft.directMembers
|
||
.map((m) => ({ ...m, phone: normalizeAccessPhone(m.phone) }))
|
||
.filter((m) => m.phone),
|
||
groups: Object.fromEntries(Object.entries(draft.groups).map(([jid, group]) => [jid, {
|
||
title: group.title,
|
||
admins: group.admins.map(normalizeAccessPhone).filter(Boolean),
|
||
members: group.members
|
||
.map((m) => ({ ...m, phone: normalizeAccessPhone(m.phone) }))
|
||
.filter((m) => m.phone),
|
||
}])),
|
||
});
|
||
const scope = validateGroupSessionScope(groupSessionScope);
|
||
if (typeof rpcCall !== 'function') throw new Error('访问授权暂不可用。');
|
||
const value = unwrapRpcResult(await rpcCall(ACCESS_GRANT_ENDPOINT, {
|
||
botId: account.botId,
|
||
grant,
|
||
}));
|
||
unwrapRpcResult(await rpcCall(GROUP_SESSION_SCOPE_ENDPOINT, {
|
||
botId: account.botId,
|
||
groupSessionScope: scope,
|
||
}));
|
||
const saved = grantFromSnapshot(value, account.botId) ?? grant;
|
||
setDraft(cloneGrant(saved, ownerPhone));
|
||
setGroupSessionScope(scope);
|
||
onSaved?.(saved);
|
||
setFeedback({ tone: 'success', message: '分级访问设置已保存。' });
|
||
} catch (error) {
|
||
setFeedback({
|
||
tone: 'error',
|
||
message: error?.message || '访问授权保存失败,请稍后重试。',
|
||
});
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return h('form', {
|
||
className: 'dim-accessPage',
|
||
onSubmit: (event) => void save(event),
|
||
},
|
||
loadingGrant
|
||
? h('div', { className: 'dim-accessState', 'aria-busy': 'true' }, '正在读取分级访问设置…')
|
||
: null,
|
||
h('p', { className: 'dim-accessUsersEmpty' },
|
||
'WhatsApp 按电话号码授权:全局管理员管私聊;群管理员只批本群。成员不可跨群、有群权也不自动获得私聊权。'),
|
||
h(AdminPhones, {
|
||
title: '全局管理员',
|
||
help: '绑定账号自动为全局管理员,至少保留一位。',
|
||
phones: draft.globalAdmins,
|
||
contacts,
|
||
disabled: saving,
|
||
lockedPhone: ownerPhone || null,
|
||
onChange: (globalAdmins) => {
|
||
setDraft((current) => ({ ...current, globalAdmins }));
|
||
setFeedback(null);
|
||
},
|
||
}),
|
||
h(MemberRows, {
|
||
title: '私聊授权用户',
|
||
members: draft.directMembers,
|
||
contacts,
|
||
disabled: saving,
|
||
onChange: (directMembers) => {
|
||
setDraft((current) => ({ ...current, directMembers }));
|
||
setFeedback(null);
|
||
},
|
||
}),
|
||
h('fieldset', { className: 'dim-accessScene', disabled: saving },
|
||
h('legend', null, '待审批'),
|
||
(draft.pending ?? []).filter((entry) => entry.status === 'pending' || !entry.status).length === 0
|
||
? h('div', { className: 'dim-accessUsersEmpty' }, '暂无待批申请')
|
||
: h('ul', { className: 'dim-accessUserList' },
|
||
(draft.pending ?? [])
|
||
.filter((entry) => entry.status === 'pending' || !entry.status)
|
||
.map((entry) => h('li', { key: entry.id, className: 'dim-accessUserRow' },
|
||
h('div', { className: 'dim-accessField' },
|
||
h('span', null, ...(entry.kind === 'group'
|
||
? ['群聊 · ', groupDisplayName(entry.groupJid, draft.groups)]
|
||
: ['私聊'])),
|
||
h('strong', null, entry.pushName || entry.phone || entry.lid || entry.id),
|
||
entry.unresolved
|
||
? h('span', null, '(电话未解析,请先在联系人中确认)')
|
||
: null,
|
||
entry.requestText
|
||
? h('p', null, entry.requestText.slice(0, 120))
|
||
: null),
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
'data-kind': 'primary',
|
||
disabled: saving || entry.unresolved,
|
||
onClick: () => void resolvePending(entry.id, 'approve'),
|
||
}, '批准'),
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
'data-kind': 'danger',
|
||
disabled: saving,
|
||
onClick: () => void resolvePending(entry.id, 'deny'),
|
||
}, '拒绝'))))),
|
||
h('fieldset', { className: 'dim-accessScene', disabled: saving },
|
||
h('legend', null, '群授权'),
|
||
knownGroupJids.length === 0
|
||
? h('div', { className: 'dim-accessUsersEmpty' },
|
||
'尚无已知群。可在下方粘贴群 JID(…@g.us),或等群内有人 @ 机器人后自动出现。')
|
||
: knownGroupJids.map((groupJid) => {
|
||
const group = draft.groups[groupJid] ?? { title: '', admins: [], members: [] };
|
||
return h('div', { key: groupJid, className: 'dim-accessGroupCard' },
|
||
h('div', { className: 'dim-accessGroupHeading' },
|
||
h('h3', null, groupDisplayName(groupJid, draft.groups)),
|
||
h('code', { className: 'dim-accessGroupJid' }, groupJid)),
|
||
h('div', { className: 'dim-accessGroupTitleRow' },
|
||
h('label', { className: 'dim-accessField' },
|
||
h('span', null, '群名称'),
|
||
h('input', {
|
||
value: group.title ?? '',
|
||
maxLength: 128,
|
||
placeholder: '填写便于识别的群名称',
|
||
onChange: (event) => setDraft((current) => ({
|
||
...current,
|
||
groups: {
|
||
...current.groups,
|
||
[groupJid]: { ...group, title: event.target.value },
|
||
},
|
||
})),
|
||
})),
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
disabled: saving,
|
||
onClick: () => void (async () => {
|
||
setSaving(true);
|
||
setFeedback(null);
|
||
try {
|
||
const refreshed = unwrapRpcResult(await rpcCall(
|
||
ACCESS_GROUP_TITLES_REFRESH_ENDPOINT,
|
||
{ botId: account.botId, groupJids: [groupJid] },
|
||
));
|
||
const next = grantFromSnapshot(refreshed, account.botId);
|
||
if (!next) throw new Error('服务没有返回已保存的访问授权。');
|
||
setDraft(cloneGrant(next, ownerPhone));
|
||
onSaved?.(next);
|
||
const title = next.groups?.[groupJid]?.title;
|
||
setFeedback({
|
||
tone: title ? 'success' : 'error',
|
||
message: title
|
||
? ['已同步群名:', title].join('')
|
||
: '未能从 WhatsApp 读取群名,请确认机器人在线且仍在该群。',
|
||
});
|
||
} catch (error) {
|
||
setFeedback({
|
||
tone: 'error',
|
||
message: error?.message || '同步群名失败,请稍后重试。',
|
||
});
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}),
|
||
}, '同步群名')),
|
||
h(AdminPhones, {
|
||
title: '本群管理员',
|
||
help: '未配置时,本群申请回落给全局管理员审批(仍只授予本群权)。',
|
||
phones: group.admins ?? [],
|
||
contacts,
|
||
disabled: saving,
|
||
lockedPhone: null,
|
||
nested: true,
|
||
onChange: (admins) => setDraft((current) => ({
|
||
...current,
|
||
groups: {
|
||
...current.groups,
|
||
[groupJid]: { ...group, admins },
|
||
},
|
||
})),
|
||
}),
|
||
h(MemberRows, {
|
||
title: '本群授权成员',
|
||
members: group.members ?? [],
|
||
contacts,
|
||
disabled: saving,
|
||
nested: true,
|
||
onChange: (members) => setDraft((current) => ({
|
||
...current,
|
||
groups: {
|
||
...current.groups,
|
||
[groupJid]: { ...group, members },
|
||
},
|
||
})),
|
||
}));
|
||
}),
|
||
h('div', { className: 'dim-accessControls' },
|
||
h('label', { className: 'dim-accessField' },
|
||
h('span', null, '添加群 JID'),
|
||
h('input', {
|
||
value: newGroupJid,
|
||
placeholder: '120363…@g.us',
|
||
onChange: (event) => setNewGroupJid(event.target.value),
|
||
})),
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-deliveryButton',
|
||
onClick: () => {
|
||
const jid = newGroupJid.trim();
|
||
if (!/^\d{5,32}@g\.us$/.test(jid)) {
|
||
setFeedback({ tone: 'error', message: '群 JID 格式无效。' });
|
||
return;
|
||
}
|
||
setDraft((current) => ({
|
||
...current,
|
||
groups: {
|
||
...current.groups,
|
||
[jid]: current.groups[jid] ?? { title: '', admins: [], members: [] },
|
||
},
|
||
}));
|
||
setNewGroupJid('');
|
||
setFeedback(null);
|
||
},
|
||
}, '添加群'))),
|
||
h('fieldset', { className: 'dim-accessScene', disabled: saving },
|
||
h('legend', null, '最近联系人(自动沉淀)'),
|
||
h('p', { className: 'dim-accessUsersEmpty' },
|
||
'仅记录私聊机器人,或在群里 @ 机器人的人。电话是唯一授权键,可一键加入私聊/本群授权。'),
|
||
contacts.length === 0
|
||
? h('div', { className: 'dim-accessUsersEmpty' }, '暂无联系人。有人私聊或 @ 机器人后会出现在此。')
|
||
: h('ul', { className: 'dim-accessUserList' }, contacts.slice(0, 30).map((contact) => {
|
||
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(',')}`,
|
||
className: 'dim-accessUserRow',
|
||
},
|
||
h('div', { className: 'dim-accessField' },
|
||
h('strong', null, contactLabel(contact)),
|
||
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 name = groupDisplayName(groupJid, draft.groups);
|
||
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: ['已加入群授权:', name, '。记得点保存。'].join(''),
|
||
});
|
||
},
|
||
}, [already ? '已在群:' : '加入群:', name].join(''));
|
||
}));
|
||
}))),
|
||
h('fieldset', { className: 'dim-accessScene', disabled: saving },
|
||
h('legend', null, '群会话策略'),
|
||
h('label', { className: 'dim-accessField' },
|
||
h('span', null, '群内 Session 绑定'),
|
||
h('select', {
|
||
value: groupSessionScope || DEFAULT_GROUP_SESSION_SCOPE,
|
||
onChange: (event) => setGroupSessionScope(event.target.value),
|
||
},
|
||
h('option', { value: 'user_in_chat' }, '按发言人拆分(推荐)'),
|
||
h('option', { value: 'chat' }, '整群共享一个 Session')))),
|
||
feedback ? h('p', {
|
||
className: 'dim-accessFeedback',
|
||
'data-tone': feedback.tone,
|
||
role: feedback.tone === 'error' ? 'alert' : 'status',
|
||
}, feedback.message) : null,
|
||
h('div', { className: 'dim-accessActions' },
|
||
h('button', {
|
||
type: 'submit',
|
||
className: 'dim-deliveryButton',
|
||
'data-kind': 'primary',
|
||
disabled: saving,
|
||
}, saving ? '正在保存…' : '保存分级访问设置')));
|
||
}
|