Show WhatsApp channel peer next to Agent Preset in session header (4.9.1-ops.20).

Plugins inject conversation.session.header.actions; resolve sessionId back to the bound chat and render nickname/phone or group title.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 19:10:07 +08:00
parent 2718586fda
commit 37742043a9
15 changed files with 852 additions and 326 deletions

View file

@ -566,12 +566,12 @@ __export(index_exports, {
name: () => name name: () => name
}); });
module.exports = __toCommonJS(index_exports); module.exports = __toCommonJS(index_exports);
var React24 = __toESM(require("react"), 1); var React25 = __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.19", version: "4.9.1-ops.20",
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,7 @@ 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",
"\u6E20\u9053\u7528\u6237": "Channel user",
"\uFF08\u4E0D\u53EF\u7528\uFF09": " (unavailable)", "\uFF08\u4E0D\u53EF\u7528\uFF09": " (unavailable)",
"\u7FA4 Agent Preset": "Group Agent Preset", "\u7FA4 Agent Preset": "Group Agent Preset",
"\u8DDF\u968F\u5168\u5C40": "Follow global", "\u8DDF\u968F\u5168\u5C40": "Follow global",
@ -11353,7 +11354,8 @@ var WHATSAPP_ENDPOINTS = Object.freeze({
setGroupSessionScope: "bot.group-session-scope.set", 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",
resolveChannelPeer: "bot.session.channel-peer"
}); });
var PROVISION_STATES5 = /* @__PURE__ */ new Set(["starting", "pending", "connecting", "connected", "failed", "cancelled"]); var PROVISION_STATES5 = /* @__PURE__ */ new Set(["starting", "pending", "connecting", "connected", "failed", "cancelled"]);
var BOT_STATES = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); var BOT_STATES = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]);
@ -12113,11 +12115,83 @@ function WhatsappSettingsTab({ rpcCall }) {
)); ));
} }
// plugin-src/client/channel-peer-label.js
var React20 = __toESM(require("react"), 1);
var CHANNEL_LABELS = Object.freeze({
whatsapp: "WhatsApp",
feishu: "\u98DE\u4E66",
dingtalk: "\u9489\u9489",
wecom: "\u4F01\u4E1A\u5FAE\u4FE1",
weixin: "\u5FAE\u4FE1",
qq: "QQ",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
office: "AI Office"
});
function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
const [peer, setPeer] = React20.useState(null);
React20.useEffect(() => {
if (typeof resolveChannelPeer !== "function" || typeof sessionId !== "string" || !sessionId) {
setPeer(null);
return void 0;
}
const controller = new AbortController();
let active = true;
void (async () => {
try {
const next = await resolveChannelPeer(sessionId, controller.signal);
if (active && !controller.signal.aborted) setPeer(next && typeof next === "object" ? next : null);
} catch {
if (active && !controller.signal.aborted) setPeer(null);
}
})();
return () => {
active = false;
controller.abort();
};
}, [sessionId, resolveChannelPeer]);
if (!peer?.label) return null;
const channelName2 = CHANNEL_LABELS[peer.channel] || peer.channel || "IM";
const title = peer.kind === "group" ? `${channelName2} \xB7 ${peer.label}` : `${channelName2} \xB7 ${peer.label}`;
return h2(
"span",
{
className: "dim-channelPeerLabel",
title,
"aria-label": localizeText("\u6E20\u9053\u7528\u6237") + ` \xB7 ${title}`
},
h2("span", { className: "dim-channelPeerChannel", "aria-hidden": "true" }, channelName2),
h2("span", { className: "dim-channelPeerText" }, peer.label)
);
}
async function resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal) {
if (typeof whatsappRpcCall !== "function") return null;
const result = await whatsappRpcCall(
WHATSAPP_ENDPOINTS.resolveChannelPeer,
{ sessionId },
signal
);
const value = unwrapRpcResult9(result);
if (!value || typeof value !== "object") return null;
const label = typeof value.label === "string" ? value.label.trim() : "";
if (!label) return null;
return {
channel: typeof value.channel === "string" ? value.channel : "whatsapp",
botId: typeof value.botId === "string" ? value.botId : "",
kind: value.kind === "group" ? "group" : "direct",
label,
phone: typeof value.phone === "string" ? value.phone : null,
pushName: typeof value.pushName === "string" ? value.pushName : null,
groupTitle: typeof value.groupTitle === "string" ? value.groupTitle : null
};
}
// plugin-src/client/delivery-settings.js // plugin-src/client/delivery-settings.js
var React22 = __toESM(require("react"), 1); var React23 = __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 React21 = __toESM(require("react"), 1);
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({
@ -12220,8 +12294,8 @@ function ScenePolicyEditor({
unsupported = false, unsupported = false,
onChange onChange
}) { }) {
const ownerHelpId = React20.useId(); const ownerHelpId = React21.useId();
const emptyAllowlistHelpId = React20.useId(); const emptyAllowlistHelpId = React21.useId();
const allowlist = policy.mode === "allowlist"; const allowlist = policy.mode === "allowlist";
const collectionKey = allowlist ? "users" : "commandPermissionOverrides"; const collectionKey = allowlist ? "users" : "commandPermissionOverrides";
const branchKey = allowlist ? "allowlist" : "open"; const branchKey = allowlist ? "allowlist" : "open";
@ -12273,7 +12347,7 @@ function ScenePolicyEditor({
h2("strong", null, "\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A"), h2("strong", null, "\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A"),
h2("p", null, "\u6B64\u533A\u57DF\u65E0\u9700\u914D\u7F6E\uFF0C\u4FDD\u5B58\u79C1\u804A\u8BBE\u7F6E\u65F6\u4F1A\u4FDD\u7559\u73B0\u6709\u7FA4\u804A\u7B56\u7565\u3002") h2("p", null, "\u6B64\u533A\u57DF\u65E0\u9700\u914D\u7F6E\uFF0C\u4FDD\u5B58\u79C1\u804A\u8BBE\u7F6E\u65F6\u4F1A\u4FDD\u7559\u73B0\u6709\u7FA4\u804A\u7B56\u7565\u3002")
) : h2( ) : h2(
React20.Fragment, React21.Fragment,
null, null,
h2( h2(
"div", "div",
@ -12415,18 +12489,18 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy); const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
const initialKey = JSON.stringify(initialPolicy); const initialKey = JSON.stringify(initialPolicy);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope); const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React20.useState(() => clonePolicy( const [draft, setDraft] = React21.useState(() => clonePolicy(
initialPolicy ?? DEFAULT_ACCESS_POLICY initialPolicy ?? DEFAULT_ACCESS_POLICY
)); ));
const [groupSessionScope, setGroupSessionScope] = React20.useState(initialScope); const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope);
const [saving, setSaving] = React20.useState(false); const [saving, setSaving] = React21.useState(false);
const [feedback, setFeedback] = React20.useState(null); const [feedback, setFeedback] = React21.useState(null);
React20.useEffect(() => { React21.useEffect(() => {
const next = normalizeAccessPolicy(account?.accessPolicy); const next = normalizeAccessPolicy(account?.accessPolicy);
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY)); setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope)); setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope]); }, [account?.botId, initialKey, account?.groupSessionScope]);
React20.useEffect(() => { React21.useEffect(() => {
setFeedback(null); setFeedback(null);
}, [account?.botId]); }, [account?.botId]);
if (!definition) { if (!definition) {
@ -12579,7 +12653,7 @@ 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 React22 = __toESM(require("react"), 1);
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 ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = "bot.access-group-titles.refresh"; var ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = "bot.access-group-titles.refresh";
@ -12671,13 +12745,13 @@ function PhoneTypeahead({
disabled, disabled,
requirePhone = true requirePhone = true
}) { }) {
const [query, setQuery] = React21.useState(value || ""); const [query, setQuery] = React22.useState(value || "");
const [open, setOpen] = React21.useState(false); const [open, setOpen] = React22.useState(false);
const blurTimer = React21.useRef(null); const blurTimer = React22.useRef(null);
React21.useEffect(() => { React22.useEffect(() => {
setQuery(value || ""); setQuery(value || "");
}, [value]); }, [value]);
React21.useEffect(() => () => { React22.useEffect(() => () => {
if (blurTimer.current) window.clearTimeout(blurTimer.current); if (blurTimer.current) window.clearTimeout(blurTimer.current);
}, []); }, []);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@ -12985,21 +13059,21 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialGrant = normalizeAccessGrant(account?.accessGrant); const initialGrant = normalizeAccessGrant(account?.accessGrant);
const initialKey = JSON.stringify(initialGrant); const initialKey = JSON.stringify(initialGrant);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope); const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React21.useState(() => cloneGrant(initialGrant, ownerPhone)); const [draft, setDraft] = React22.useState(() => cloneGrant(initialGrant, ownerPhone));
const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope); const [groupSessionScope, setGroupSessionScope] = React22.useState(initialScope);
const [saving, setSaving] = React21.useState(false); const [saving, setSaving] = React22.useState(false);
const [feedback, setFeedback] = React21.useState(null); const [feedback, setFeedback] = React22.useState(null);
const [newGroupJid, setNewGroupJid] = React21.useState(""); const [newGroupJid, setNewGroupJid] = React22.useState("");
const [loadingGrant, setLoadingGrant] = React21.useState(!initialGrant); const [loadingGrant, setLoadingGrant] = React22.useState(!initialGrant);
const contextCatalog = React21.useContext(AgentPresetCatalogContext); const contextCatalog = React22.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React21.useState(() => normalizeAgentPresetCatalog( const [presetCatalog, setPresetCatalog] = React22.useState(() => normalizeAgentPresetCatalog(
account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG
)); ));
React21.useEffect(() => { React22.useEffect(() => {
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone)); setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope)); setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]); }, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]);
React21.useEffect(() => { React22.useEffect(() => {
if (typeof rpcCall !== "function" || !account?.botId) return void 0; if (typeof rpcCall !== "function" || !account?.botId) return void 0;
let cancelled = false; let cancelled = false;
setLoadingGrant(true); setLoadingGrant(true);
@ -13053,7 +13127,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
}; };
}, [account?.botId, rpcCall, ownerPhone]); }, [account?.botId, rpcCall, ownerPhone]);
const contacts = draft.contacts ?? []; const contacts = draft.contacts ?? [];
const knownGroupJids = React21.useMemo(() => { const knownGroupJids = React22.useMemo(() => {
const set = new Set(Object.keys(draft.groups ?? {})); const set = new Set(Object.keys(draft.groups ?? {}));
for (const contact of contacts) { for (const contact of contacts) {
for (const jid of contact.groupJids ?? []) set.add(jid); for (const jid of contact.groupJids ?? []) set.add(jid);
@ -13063,7 +13137,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
} }
return [...set].sort(); return [...set].sort();
}, [draft.groups, draft.pending, contacts]); }, [draft.groups, draft.pending, contacts]);
const actionableContacts = React21.useMemo( const actionableContacts = React22.useMemo(
() => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)), () => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)),
[contacts, draft, knownGroupJids] [contacts, draft, knownGroupJids]
); );
@ -13742,13 +13816,13 @@ function TargetForm({
}) { }) {
const editing = mode === "edit"; const editing = mode === "edit";
const initialKind = initialValue?.kind && definition.fields[initialValue.kind] ? initialValue.kind : definition.kinds[0].value; const initialKind = initialValue?.kind && definition.fields[initialValue.kind] ? initialValue.kind : definition.kinds[0].value;
const [targetId, setTargetId] = React22.useState(initialValue?.targetId ?? ""); const [targetId, setTargetId] = React23.useState(initialValue?.targetId ?? "");
const [name2, setName] = React22.useState(initialValue?.name ?? ""); const [name2, setName] = React23.useState(initialValue?.name ?? "");
const [kind, setKind] = React22.useState(initialKind); const [kind, setKind] = React23.useState(initialKind);
const [route, setRoute] = React22.useState(initialValue?.route ?? {}); const [route, setRoute] = React23.useState(initialValue?.route ?? {});
const [error, setError] = React22.useState(null); const [error, setError] = React23.useState(null);
const [testing, setTesting] = React22.useState(false); const [testing, setTesting] = React23.useState(false);
const [testState, setTestState] = React22.useState(null); const [testState, setTestState] = React23.useState(null);
const currentTarget = () => { const currentTarget = () => {
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => { const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
const raw = String(route[field.key] ?? "").trim(); const raw = String(route[field.key] ?? "").trim();
@ -13965,7 +14039,7 @@ function TargetSuggestionPicker({
suggestions.map((suggestion, index) => { suggestions.map((suggestion, index) => {
const identity = routeIdentity(definition, suggestion); const identity = routeIdentity(definition, suggestion);
const added = configured.has(identity); const added = configured.has(identity);
return React22.createElement("option", { return React23.createElement("option", {
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`, key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
value: String(index), value: String(index),
disabled: added disabled: added
@ -13982,10 +14056,10 @@ function TargetSuggestionPicker({
); );
} }
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) { function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
const [action, setAction] = React22.useState(null); const [action, setAction] = React23.useState(null);
const [testState, setTestState] = React22.useState(null); const [testState, setTestState] = React23.useState(null);
const [copyState, setCopyState] = React22.useState(null); const [copyState, setCopyState] = React23.useState(null);
const [confirmDelete, setConfirmDelete] = React22.useState(false); const [confirmDelete, setConfirmDelete] = React23.useState(false);
const testTarget = async () => { const testTarget = async () => {
setAction("test"); setAction("test");
setTestState(null); setTestState(null);
@ -14027,7 +14101,7 @@ function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, o
h2( h2(
"div", "div",
{ className: "dim-targetTitle" }, { className: "dim-targetTitle" },
React22.createElement("strong", null, target.name || target.targetId), React23.createElement("strong", null, target.name || target.targetId),
h2("span", null, kindLabel(definition, target.kind)) h2("span", null, kindLabel(definition, target.kind))
), ),
h2("code", null, `targetId: ${target.targetId}`) h2("code", null, `targetId: ${target.targetId}`)
@ -14090,28 +14164,28 @@ function DeliveryTargetSettingsPage({
onBack onBack
}) { }) {
const definition = CHANNEL_DEFINITIONS[channel4]; const definition = CHANNEL_DEFINITIONS[channel4];
const [activeTabId, setActiveTabId] = React22.useState(BOT_SETTINGS_TABS[0].id); const [activeTabId, setActiveTabId] = React23.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React22.useState("loading"); const [phase, setPhase] = React23.useState("loading");
const [targets, setTargets] = React22.useState([]); const [targets, setTargets] = React23.useState([]);
const [suggestionPhase, setSuggestionPhase] = React22.useState("idle"); const [suggestionPhase, setSuggestionPhase] = React23.useState("idle");
const [suggestions, setSuggestions] = React22.useState([]); const [suggestions, setSuggestions] = React23.useState([]);
const [suggestionError, setSuggestionError] = React22.useState(null); const [suggestionError, setSuggestionError] = React23.useState(null);
const [error, setError] = React22.useState(null); const [error, setError] = React23.useState(null);
const [editor, setEditor] = React22.useState(null); const [editor, setEditor] = React23.useState(null);
const [saving, setSaving] = React22.useState(false); const [saving, setSaving] = React23.useState(false);
const [botCopyState, setBotCopyState] = React22.useState(null); const [botCopyState, setBotCopyState] = React23.useState(null);
const [accessPolicy, setAccessPolicy] = React22.useState(account.accessPolicy); const [accessPolicy, setAccessPolicy] = React23.useState(account.accessPolicy);
const [accessGrant, setAccessGrant] = React22.useState(account.accessGrant); const [accessGrant, setAccessGrant] = React23.useState(account.accessGrant);
const mounted = React22.useRef(true); const mounted = React23.useRef(true);
React22.useEffect(() => { React23.useEffect(() => {
setAccessPolicy(account.accessPolicy); setAccessPolicy(account.accessPolicy);
setAccessGrant(account.accessGrant); setAccessGrant(account.accessGrant);
}, [account.botId, account.accessPolicy, account.accessGrant]); }, [account.botId, account.accessPolicy, account.accessGrant]);
const invoke = React22.useCallback(async (endpoint, payload = {}, signal) => { const invoke = React23.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") throw new Error("\u6295\u9012\u76EE\u6807\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002"); if (typeof rpcCall !== "function") throw new Error("\u6295\u9012\u76EE\u6807\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002");
return unwrapRpcResult12(await rpcCall(endpoint, payload, signal)); return unwrapRpcResult12(await rpcCall(endpoint, payload, signal));
}, [rpcCall]); }, [rpcCall]);
const loadTargets = React22.useCallback(async ({ signal, silent = false } = {}) => { const loadTargets = React23.useCallback(async ({ signal, silent = false } = {}) => {
if (!silent) setPhase("loading"); if (!silent) setPhase("loading");
setError(null); setError(null);
try { try {
@ -14125,7 +14199,7 @@ function DeliveryTargetSettingsPage({
setPhase("error"); setPhase("error");
} }
}, [account.botId, invoke]); }, [account.botId, invoke]);
const loadSuggestions = React22.useCallback(async () => { const loadSuggestions = React23.useCallback(async () => {
setSuggestionPhase("loading"); setSuggestionPhase("loading");
setSuggestionError(null); setSuggestionError(null);
try { try {
@ -14139,7 +14213,7 @@ function DeliveryTargetSettingsPage({
setSuggestionPhase("error"); setSuggestionPhase("error");
} }
}, [account.botId, definition, invoke]); }, [account.botId, definition, invoke]);
React22.useEffect(() => { React23.useEffect(() => {
mounted.current = true; mounted.current = true;
const controller = new AbortController(); const controller = new AbortController();
void loadTargets({ signal: controller.signal }); void loadTargets({ signal: controller.signal });
@ -14266,7 +14340,7 @@ function DeliveryTargetSettingsPage({
rpcCall: accessRpcCall, rpcCall: accessRpcCall,
onSaved: setAccessPolicy onSaved: setAccessPolicy
}) : h2( }) : h2(
React22.Fragment, React23.Fragment,
null, null,
h2( h2(
"section", "section",
@ -14970,6 +15044,34 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
.dim-accessGroupTitleRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; align-items: end; } .dim-accessGroupTitleRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; align-items: end; }
.dim-accessNickname { display: block; margin-top: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 15px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dim-accessNickname { display: block; margin-top: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 15px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dim-accessNicknameEmpty { color: var(--dsw-alias-label-tertiary, #8f959e); font-weight: 400; } .dim-accessNicknameEmpty { color: var(--dsw-alias-label-tertiary, #8f959e); font-weight: 400; }
.dim-channelPeerLabel {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
min-width: 0;
height: 22px;
padding: 0 8px 0 6px;
border-radius: 6px;
background: var(--dsw-alias-fill-tsp-secondary, color-mix(in srgb, var(--dsw-alias-label-primary, #1f2329) 6%, transparent));
color: var(--dsw-alias-label-secondary, #646a73);
font-size: 12px;
line-height: 22px;
white-space: nowrap;
overflow: hidden;
}
.dim-channelPeerChannel {
flex: none;
color: var(--dsw-alias-label-tertiary, #8f959e);
font-weight: 560;
}
.dim-channelPeerText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: var(--dsw-alias-label-secondary, #646a73);
}
.dim-accessUserRow { position: relative; z-index: 0; overflow: visible; } .dim-accessUserRow { position: relative; z-index: 0; overflow: visible; }
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; } .dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
.dim-panel .ddt-headingCopy { display: none; } .dim-panel .ddt-headingCopy { display: none; }
@ -15052,7 +15154,7 @@ function installImStyles() {
} }
// plugin-src/client/update-panel.js // plugin-src/client/update-panel.js
var React23 = __toESM(require("react"), 1); var React24 = __toESM(require("react"), 1);
var import_react_dom3 = require("react-dom"); var import_react_dom3 = require("react-dom");
var import_valid = __toESM(require_valid(), 1); var import_valid = __toESM(require_valid(), 1);
var import_rcompare = __toESM(require_rcompare(), 1); var import_rcompare = __toESM(require_rcompare(), 1);
@ -15142,11 +15244,11 @@ function manualUpdateCommand(snapshot) {
return `dsh plugin --profile ${profileArgument} add -w dsh-im-ops@${version}`; return `dsh plugin --profile ${profileArgument} add -w dsh-im-ops@${version}`;
} }
function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) { function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
const [copyState, setCopyState] = React23.useState("idle"); const [copyState, setCopyState] = React24.useState("idle");
const commandRef = React23.useRef(null); const commandRef = React24.useRef(null);
const mounted = React23.useRef(false); const mounted = React24.useRef(false);
const copying = React23.useRef(false); const copying = React24.useRef(false);
React23.useEffect(() => { React24.useEffect(() => {
mounted.current = true; mounted.current = true;
return () => { return () => {
mounted.current = false; mounted.current = false;
@ -15178,7 +15280,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
{ className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" }, { className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" },
h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"), h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"),
command ? h2( command ? h2(
React23.Fragment, React24.Fragment,
null, null,
h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"), h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"),
h2( h2(
@ -15214,7 +15316,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
"aria-hidden": "true", "aria-hidden": "true",
focusable: "false" focusable: "false"
}, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2( }, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2(
React23.Fragment, React24.Fragment,
null, null,
h2("rect", { x: 7, y: 7, width: 10, height: 11, rx: 1.5 }), h2("rect", { x: 7, y: 7, width: 10, height: 11, rx: 1.5 }),
h2("path", { d: "M5 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V5" }) h2("path", { d: "M5 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V5" })
@ -15239,10 +15341,10 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
); );
} }
function UpdateDialog({ children, onClose }) { function UpdateDialog({ children, onClose }) {
const dialogRef = React23.useRef(null); const dialogRef = React24.useRef(null);
const titleId = React23.useId(); const titleId = React24.useId();
const descriptionId = React23.useId(); const descriptionId = React24.useId();
React23.useEffect(() => { React24.useEffect(() => {
const previous = globalThis.document?.activeElement; const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.(); dialogRef.current?.focus?.();
return () => { return () => {
@ -15300,19 +15402,19 @@ function UpdateDialog({ children, onClose }) {
return typeof document !== "undefined" && document.body ? (0, import_react_dom3.createPortal)(content, document.body) : content; return typeof document !== "undefined" && document.body ? (0, import_react_dom3.createPortal)(content, document.body) : content;
} }
function UpdatePanel({ rpcCall, clientVersion, onStatus }) { function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React23.useState(null); const [snapshot, setSnapshot] = React24.useState(null);
const [action, setAction] = React23.useState("status"); const [action, setAction] = React24.useState("status");
const [error, setError] = React23.useState(null); const [error, setError] = React24.useState(null);
const [open, setOpen] = React23.useState(false); const [open, setOpen] = React24.useState(false);
const [uncertainInstall, setUncertainInstall] = React23.useState(false); const [uncertainInstall, setUncertainInstall] = React24.useState(false);
const mounted = React23.useRef(false); const mounted = React24.useRef(false);
const busy = React23.useRef(false); const busy = React24.useRef(false);
const readController = React23.useRef(null); const readController = React24.useRef(null);
const pollReadController = React23.useRef(null); const pollReadController = React24.useRef(null);
const installRequest = React23.useRef(null); const installRequest = React24.useRef(null);
const onStatusRef = React23.useRef(onStatus); const onStatusRef = React24.useRef(onStatus);
onStatusRef.current = onStatus; onStatusRef.current = onStatus;
const accept = React23.useCallback((next) => { const accept = React24.useCallback((next) => {
setSnapshot(next); setSnapshot(next);
onStatusRef.current?.(next); onStatusRef.current?.(next);
}, []); }, []);
@ -15321,7 +15423,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
setUncertainInstall(false); setUncertainInstall(false);
accept(next); accept(next);
}; };
const invoke = React23.useCallback(async (endpoint, payload = {}, signal) => { const invoke = React24.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") { if (typeof rpcCall !== "function") {
const unavailable = new Error("\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002"); const unavailable = new Error("\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002");
unavailable.code = "update-unavailable"; unavailable.code = "update-unavailable";
@ -15329,7 +15431,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
} }
return unwrapSnapshot(await rpcCall(endpoint, payload, signal)); return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]); }, [rpcCall]);
React23.useEffect(() => { React24.useEffect(() => {
mounted.current = true; mounted.current = true;
busy.current = true; busy.current = true;
const controller = new AbortController(); const controller = new AbortController();
@ -15352,7 +15454,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state); const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart"; const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart";
const shouldPoll = activeJob || uncertainInstall; const shouldPoll = activeJob || uncertainInstall;
React23.useEffect(() => { React24.useEffect(() => {
if (!shouldPoll) return void 0; if (!shouldPoll) return void 0;
let controller; let controller;
const scheduler = createPollScheduler({ const scheduler = createPollScheduler({
@ -15450,7 +15552,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const manualCommand = manualUpdateCommand(snapshot); const manualCommand = manualUpdateCommand(snapshot);
const buttonLabel = action === "checking" ? "\u68C0\u67E5\u4E2D\u2026" : action === "starting" || activeJob ? "\u6B63\u5728\u66F4\u65B0\u2026" : restartRequired ? "\u5F85\u624B\u52A8\u91CD\u542F" : snapshot?.canInstall ? "\u66F4\u65B0\u81F3" : "\u68C0\u67E5\u66F4\u65B0"; const buttonLabel = action === "checking" ? "\u68C0\u67E5\u4E2D\u2026" : action === "starting" || activeJob ? "\u6B63\u5728\u66F4\u65B0\u2026" : restartRequired ? "\u5F85\u624B\u52A8\u91CD\u542F" : snapshot?.canInstall ? "\u66F4\u65B0\u81F3" : "\u68C0\u67E5\u66F4\u65B0";
return h2( return h2(
React23.Fragment, React24.Fragment,
null, null,
h2("button", { h2("button", {
type: "button", type: "button",
@ -15475,13 +15577,13 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
h2("dt", null, "\u8FD0\u884C\u7248\u672C"), h2("dt", null, "\u8FD0\u884C\u7248\u672C"),
h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`), h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2( snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2(
React23.Fragment, React24.Fragment,
null, null,
h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"), h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"),
h2("dd", null, `v${snapshot.installedVersion}`) h2("dd", null, `v${snapshot.installedVersion}`)
) : null, ) : null,
targetVersion ? h2( targetVersion ? h2(
React23.Fragment, React24.Fragment,
null, null,
h2("dt", null, "\u76EE\u6807\u7248\u672C"), h2("dt", null, "\u76EE\u6807\u7248\u672C"),
h2("dd", null, `v${targetVersion}`) h2("dd", null, `v${targetVersion}`)
@ -15685,20 +15787,20 @@ function IMSettingsTab({
browserLocation = globalThis.location, browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation navigateToRecoveryUrl = replacePageLocation
}) { }) {
const [selected, setSelected] = React24.useState("weixin"); const [selected, setSelected] = React25.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React24.useState(null); const [loopbackRecovery, setLoopbackRecovery] = React25.useState(null);
const [runningVersion, setRunningVersion] = React24.useState(IM_PLUGIN_VERSION); const [runningVersion, setRunningVersion] = React25.useState(IM_PLUGIN_VERSION);
const [deliverySettings, setDeliverySettings] = React24.useState(null); const [deliverySettings, setDeliverySettings] = React25.useState(null);
const githubTooltipId = React24.useId(); const githubTooltipId = React25.useId();
const channelSelectId = React24.useId(); const channelSelectId = React25.useId();
const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0]; const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0];
const reportLoopbackRecovery = React24.useCallback((recovery) => { const reportLoopbackRecovery = React25.useCallback((recovery) => {
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery); setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
}, []); }, []);
const reportUpdateStatus = React24.useCallback((snapshot) => { const reportUpdateStatus = React25.useCallback((snapshot) => {
setRunningVersion(snapshot.runningVersion); setRunningVersion(snapshot.runningVersion);
}, []); }, []);
const rpcCalls = React24.useMemo(() => createLoopbackAwareRpcCalls({ const rpcCalls = React25.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall, dingtalkRpcCall,
discordRpcCall, discordRpcCall,
feishuRpcCall, feishuRpcCall,
@ -15730,7 +15832,7 @@ function IMSettingsTab({
weixinRpcCall, weixinRpcCall,
whatsappRpcCall whatsappRpcCall
]); ]);
const botSettingsContext = React24.useMemo(() => Object.freeze({ const botSettingsContext = React25.useMemo(() => Object.freeze({
openBotSettings: setDeliverySettings openBotSettings: setDeliverySettings
}), []); }), []);
return h2( return h2(
@ -15881,6 +15983,15 @@ function apply(ctx) {
listDirectory: (path, signal) => callWorkspaceDirectoryApi(ctx, "listDirectory", path, signal), listDirectory: (path, signal) => callWorkspaceDirectoryApi(ctx, "listDirectory", path, signal),
pickDirectory: () => callWorkspaceDirectoryApi(ctx, "pickDirectory") pickDirectory: () => callWorkspaceDirectoryApi(ctx, "pickDirectory")
}); });
const resolveChannelPeer = (sessionId, signal) => resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal);
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
name: "conversation.session.header.actions",
id: "im-channel-peer",
// Immediately after Agent Preset (−10); schedule/jobs follow at 10+.
order: 0,
locale: IM_LOCALE_NAMESPACE,
inject: () => ({ resolveChannelPeer })
}, ChannelPeerLabel));
ctx.slots.inject("settings.section", () => ctx.slots.register({ ctx.slots.inject("settings.section", () => ctx.slots.register({
name: "settings.section", name: "settings.section",
id: "xmanrui-dsh-im", id: "xmanrui-dsh-im",

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.19", "version": "4.9.1-ops.20",
"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

@ -0,0 +1,95 @@
import * as React from 'react';
import { h, localizeText } from './i18n.js';
import {
unwrapRpcResult,
WHATSAPP_ENDPOINTS,
} from './channels/whatsapp/api.js';
const CHANNEL_LABELS = Object.freeze({
whatsapp: 'WhatsApp',
feishu: '飞书',
dingtalk: '钉钉',
wecom: '企业微信',
weixin: '微信',
qq: 'QQ',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
office: 'AI Office',
});
/**
* Session-header chip: channel peer for IM-bound conversations.
* Renders nothing when the current session is not bound to a channel chat.
* @param {{
* sessionId: string,
* resolveChannelPeer?: (sessionId: string, signal?: AbortSignal) => Promise<object|null>,
* }} props
*/
export function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
const [peer, setPeer] = React.useState(null);
React.useEffect(() => {
if (typeof resolveChannelPeer !== 'function' || typeof sessionId !== 'string' || !sessionId) {
setPeer(null);
return undefined;
}
const controller = new AbortController();
let active = true;
void (async () => {
try {
const next = await resolveChannelPeer(sessionId, controller.signal);
if (active && !controller.signal.aborted) setPeer(next && typeof next === 'object' ? next : null);
} catch {
if (active && !controller.signal.aborted) setPeer(null);
}
})();
return () => {
active = false;
controller.abort();
};
}, [sessionId, resolveChannelPeer]);
if (!peer?.label) return null;
const channelName = CHANNEL_LABELS[peer.channel] || peer.channel || 'IM';
const title = peer.kind === 'group'
? `${channelName} · ${peer.label}`
: `${channelName} · ${peer.label}`;
return h('span', {
className: 'dim-channelPeerLabel',
title,
'aria-label': localizeText('渠道用户') + ` · ${title}`,
},
h('span', { className: 'dim-channelPeerChannel', 'aria-hidden': 'true' }, channelName),
h('span', { className: 'dim-channelPeerText' }, peer.label));
}
/**
* Resolve the channel peer for a session via WhatsApp (ops-first).
* Returns null when the session is not bound to a WhatsApp conversation.
* @param {(endpoint: string, payload: object, signal?: AbortSignal) => Promise<unknown>} whatsappRpcCall
* @param {string} sessionId
* @param {AbortSignal} [signal]
*/
export async function resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal) {
if (typeof whatsappRpcCall !== 'function') return null;
const result = await whatsappRpcCall(
WHATSAPP_ENDPOINTS.resolveChannelPeer,
{ sessionId },
signal,
);
const value = unwrapRpcResult(result);
if (!value || typeof value !== 'object') return null;
const label = typeof value.label === 'string' ? value.label.trim() : '';
if (!label) return null;
return {
channel: typeof value.channel === 'string' ? value.channel : 'whatsapp',
botId: typeof value.botId === 'string' ? value.botId : '',
kind: value.kind === 'group' ? 'group' : 'direct',
label,
phone: typeof value.phone === 'string' ? value.phone : null,
pushName: typeof value.pushName === 'string' ? value.pushName : null,
groupTitle: typeof value.groupTitle === 'string' ? value.groupTitle : null,
};
}

View file

@ -22,6 +22,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
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',
resolveChannelPeer: 'bot.session.channel-peer',
}); });
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']); const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);

View file

@ -11,6 +11,7 @@ const EN = Object.freeze({
'机器人设置页签': 'Bot settings tabs', '机器人设置页签': 'Bot settings tabs',
'投递设置': 'Delivery settings', '投递设置': 'Delivery settings',
'访问设置': 'Access settings', '访问设置': 'Access settings',
'渠道用户': 'Channel user',
"(不可用)": " (unavailable)", "(不可用)": " (unavailable)",
"群 Agent Preset": "Group Agent Preset", "群 Agent Preset": "Group Agent Preset",
"跟随全局": "Follow global", "跟随全局": "Follow global",

View file

@ -44,6 +44,10 @@ import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
import { installWhatsappStyles } from './channels/whatsapp/styles.js'; import { installWhatsappStyles } from './channels/whatsapp/styles.js';
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js'; import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
import { BotSettingsContext } from './channel-card-meta.js'; import { BotSettingsContext } from './channel-card-meta.js';
import {
ChannelPeerLabel,
resolveWhatsappChannelPeer,
} from './channel-peer-label.js';
import { import {
DELIVERY_RPC_CHANNEL, DELIVERY_RPC_CHANNEL,
DeliveryTargetSettingsPage, DeliveryTargetSettingsPage,
@ -376,6 +380,18 @@ export function apply(ctx) {
pickDirectory: () => callWorkspaceDirectoryApi(ctx, 'pickDirectory'), pickDirectory: () => callWorkspaceDirectoryApi(ctx, 'pickDirectory'),
}); });
const resolveChannelPeer = (sessionId, signal) =>
resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal);
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'im-channel-peer',
// Immediately after Agent Preset (−10); schedule/jobs follow at 10+.
order: 0,
locale: IM_LOCALE_NAMESPACE,
inject: () => ({ resolveChannelPeer }),
}, ChannelPeerLabel));
ctx.slots.inject('settings.section', () => ctx.slots.register({ ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section', name: 'settings.section',
id: 'xmanrui-dsh-im', id: 'xmanrui-dsh-im',

View file

@ -537,6 +537,34 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
.dim-accessGroupTitleRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; align-items: end; } .dim-accessGroupTitleRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; align-items: end; }
.dim-accessNickname { display: block; margin-top: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 15px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dim-accessNickname { display: block; margin-top: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 15px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dim-accessNicknameEmpty { color: var(--dsw-alias-label-tertiary, #8f959e); font-weight: 400; } .dim-accessNicknameEmpty { color: var(--dsw-alias-label-tertiary, #8f959e); font-weight: 400; }
.dim-channelPeerLabel {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
min-width: 0;
height: 22px;
padding: 0 8px 0 6px;
border-radius: 6px;
background: var(--dsw-alias-fill-tsp-secondary, color-mix(in srgb, var(--dsw-alias-label-primary, #1f2329) 6%, transparent));
color: var(--dsw-alias-label-secondary, #646a73);
font-size: 12px;
line-height: 22px;
white-space: nowrap;
overflow: hidden;
}
.dim-channelPeerChannel {
flex: none;
color: var(--dsw-alias-label-tertiary, #8f959e);
font-weight: 560;
}
.dim-channelPeerText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: var(--dsw-alias-label-secondary, #646a73);
}
.dim-accessUserRow { position: relative; z-index: 0; overflow: visible; } .dim-accessUserRow { position: relative; z-index: 0; overflow: visible; }
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; } .dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
.dim-panel .ddt-headingCopy { display: none; } .dim-panel .ddt-headingCopy { display: none; }

View file

@ -158,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
workspaces, workspaces,
stateFor, stateFor,
agentPresetCatalog, agentPresetCatalog,
channel: 'whatsapp',
}); });
const supervisor = createSupervisor({ const supervisor = createSupervisor({
channel: 'whatsapp', channel: 'whatsapp',

View file

@ -32,6 +32,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT, setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT, setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
resolveChannelPeer: 'bot.session.channel-peer',
}); });
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS)); export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
@ -99,6 +100,13 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload) return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。'; ? null : '请提交有效的上下文增强设置。';
} }
if (endpoint === WHATSAPP_ENDPOINTS.resolveChannelPeer) {
return exactKeys(payload, ['sessionId'])
&& typeof payload.sessionId === 'string'
&& payload.sessionId.trim()
&& payload.sessionId.trim().length <= 256
? null : 'bot.session.channel-peer requires a sessionId.';
}
return 'Unknown WhatsApp endpoint.'; return 'Unknown WhatsApp endpoint.';
} }
@ -252,6 +260,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
payload.groupSessionScope, payload.groupSessionScope,
(status) => publicStatus(status, cachedEncode), (status) => publicStatus(status, cachedEncode),
); );
} else if (endpoint === WHATSAPP_ENDPOINTS.resolveChannelPeer) {
if (typeof controller.resolveChannelPeer !== 'function') {
throw new Error('Channel peer resolve is unavailable');
}
value = await controller.resolveChannelPeer(payload.sessionId.trim());
} else { } else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode); value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
} }

View file

@ -28,6 +28,7 @@ import {
resolveAccessPending, resolveAccessPending,
validateAccessGrant, validateAccessGrant,
} from './access-grant.mjs'; } from './access-grant.mjs';
import { resolveChannelPeerFromBinding } from './channel-peer.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs'; import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import { import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@ -1458,10 +1459,16 @@ export function createBotScopedHarness(harness, options) {
return createBotWorkspaceScope(harness, options).harness; return createBotWorkspaceScope(harness, options).harness;
} }
export function createWorkspaceAwareController(controller, { workspaces, stateFor, agentPresetCatalog } = {}) { export function createWorkspaceAwareController(controller, {
workspaces,
stateFor,
agentPresetCatalog,
channel = null,
} = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') { if (!controller || !workspaces || typeof stateFor !== 'function') {
throw new TypeError('controller, workspaces, and stateFor are required'); throw new TypeError('controller, workspaces, and stateFor are required');
} }
const channelId = typeof channel === 'string' && channel.trim() ? channel.trim() : null;
const transitions = new Map(); const transitions = new Map();
const withBotTransition = (botId, operation) => { const withBotTransition = (botId, operation) => {
const previous = transitions.get(botId) ?? Promise.resolve(); const previous = transitions.get(botId) ?? Promise.resolve();
@ -1674,6 +1681,35 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
} }
}); });
const resolveChannelPeer = async (sessionId) => {
if (!channelId) return null;
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
if (!id || id.length > 256) return null;
const snapshot = await controller.status();
const bots = Array.isArray(snapshot?.bots) ? snapshot.bots : [];
for (const bot of bots) {
const botId = typeof bot?.botId === 'string' ? bot.botId.trim() : '';
if (!botId) continue;
let state;
try {
state = await stateFor(botId);
} catch {
continue;
}
const conversationKey = typeof state?.keyForSession === 'function'
? state.keyForSession(id)
: null;
if (!conversationKey) continue;
return resolveChannelPeerFromBinding({
channel: channelId,
botId,
conversationKey,
grant: workspaces.accessGrantFor(botId),
});
}
return null;
};
return new Proxy(controller, { return new Proxy(controller, {
get(target, property) { get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace; if (property === 'updateWorkspace') return updateWorkspace;
@ -1683,6 +1719,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (property === 'updateGroupSessionScope') return updateGroupSessionScope; if (property === 'updateGroupSessionScope') return updateGroupSessionScope;
if (property === 'updateAccessGrant') return updateAccessGrant; if (property === 'updateAccessGrant') return updateAccessGrant;
if (property === 'resolveAccessPending') return resolveAccessPendingProxy; if (property === 'resolveAccessPending') return resolveAccessPendingProxy;
if (property === 'resolveChannelPeer') return resolveChannelPeer;
const value = Reflect.get(target, property, target); const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value; if (typeof value !== 'function') return value;
if (property === 'deleteBot') { if (property === 'deleteBot') {

View file

@ -0,0 +1,115 @@
import { normalizeAccessPhone, phoneFromWhatsappJid } from './access-grant.mjs';
/**
* Parse a durable conversation binding key into chat identity fields.
* @param {unknown} key
* @returns {{ kind: 'direct' | 'group', conversationId: string, senderId: string | null } | null}
*/
export function parseConversationKey(key) {
if (typeof key !== 'string' || !key) return null;
if (key.startsWith('direct:')) {
const conversationId = key.slice('direct:'.length).trim();
return conversationId ? { kind: 'direct', conversationId, senderId: null } : null;
}
if (!key.startsWith('group:')) return null;
const rest = key.slice('group:'.length);
const userMarker = ':user:';
const userAt = rest.indexOf(userMarker);
if (userAt >= 0) {
const conversationId = rest.slice(0, userAt).trim();
const senderId = rest.slice(userAt + userMarker.length).trim();
if (!conversationId || !senderId) return null;
return { kind: 'group', conversationId, senderId };
}
const conversationId = rest.trim();
return conversationId ? { kind: 'group', conversationId, senderId: null } : null;
}
function contactPushName(grant, phone) {
if (!phone || !grant || !Array.isArray(grant.contacts)) return '';
const hit = grant.contacts.find((contact) => contact?.phone === phone);
return typeof hit?.pushName === 'string' ? hit.pushName.trim() : '';
}
function groupTitle(grant, groupJid) {
const title = grant?.groups?.[groupJid]?.title;
return typeof title === 'string' && title.trim() ? title.trim() : '';
}
function phoneFromIdentity(value) {
return phoneFromWhatsappJid(value) ?? normalizeAccessPhone(value);
}
/**
* Build a public channel-peer summary for session-header display.
* @param {{
* channel: string,
* botId: string,
* conversationKey: string,
* grant?: object | null,
* }} input
* @returns {object | null}
*/
export function resolveChannelPeerFromBinding(input) {
const channel = typeof input?.channel === 'string' ? input.channel.trim() : '';
const botId = typeof input?.botId === 'string' ? input.botId.trim() : '';
const conversationKey = typeof input?.conversationKey === 'string'
? input.conversationKey.trim() : '';
if (!channel || !botId || !conversationKey) return null;
const parsed = parseConversationKey(conversationKey);
if (!parsed) return null;
const grant = input.grant && typeof input.grant === 'object' ? input.grant : null;
if (parsed.kind === 'direct') {
const phone = phoneFromIdentity(parsed.conversationId);
const pushName = contactPushName(grant, phone);
const who = pushName || phone || parsed.conversationId;
const label = phone && pushName && pushName !== phone
? `${pushName} · ${phone}`
: who;
return Object.freeze({
channel,
botId,
kind: 'direct',
conversationKey,
conversationId: parsed.conversationId,
senderId: null,
phone: phone || null,
pushName: pushName || null,
groupTitle: null,
label,
});
}
const title = groupTitle(grant, parsed.conversationId) || '未命名群';
if (!parsed.senderId) {
return Object.freeze({
channel,
botId,
kind: 'group',
conversationKey,
conversationId: parsed.conversationId,
senderId: null,
phone: null,
pushName: null,
groupTitle: title,
label: `群 · ${title}`,
});
}
const phone = phoneFromIdentity(parsed.senderId);
const pushName = contactPushName(grant, phone);
const who = pushName || phone || parsed.senderId;
return Object.freeze({
channel,
botId,
kind: 'group',
conversationKey,
conversationId: parsed.conversationId,
senderId: parsed.senderId,
phone: phone || null,
pushName: pushName || null,
groupTitle: title,
label: `${title} · ${who}`,
});
}

View file

@ -47,6 +47,19 @@ export class ConversationStateStore {
return this.#state.sessions[key] ?? null; return this.#state.sessions[key] ?? null;
} }
/**
* Reverse-map a Harness session id to its conversation binding key.
* @param {string} sessionId
* @returns {string|null}
*/
keyForSession(sessionId) {
if (typeof sessionId !== 'string' || !sessionId) return null;
for (const [key, bound] of Object.entries(this.#state.sessions)) {
if (bound === sessionId) return key;
}
return null;
}
async setSession(key, sessionId) { async setSession(key, sessionId) {
this.#state.sessions[key] = sessionId; this.#state.sessions[key] = sessionId;
await this.#persist(); await this.#persist();

View file

@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import {
parseConversationKey,
resolveChannelPeerFromBinding,
} from '../../../src/channels/shared/channel-peer.mjs';
import { ConversationStateStore } from '../../../src/channels/shared/conversation-state-store.mjs';
test('parseConversationKey covers direct, shared group, and per-user group', () => {
assert.deepEqual(parseConversationKey('direct:8618111111111@s.whatsapp.net'), {
kind: 'direct',
conversationId: '8618111111111@s.whatsapp.net',
senderId: null,
});
assert.deepEqual(parseConversationKey('group:120363@g.us'), {
kind: 'group',
conversationId: '120363@g.us',
senderId: null,
});
assert.deepEqual(parseConversationKey('group:120363@g.us:user:8618222222222@s.whatsapp.net'), {
kind: 'group',
conversationId: '120363@g.us',
senderId: '8618222222222@s.whatsapp.net',
});
assert.equal(parseConversationKey(''), null);
assert.equal(parseConversationKey('other:x'), null);
});
test('resolveChannelPeerFromBinding builds DM and group labels from access grant', () => {
const grant = {
contacts: [{ phone: '8618222222222', pushName: 'Alice' }],
groups: {
'120363@g.us': { title: 'Ops Room', admins: [], members: [] },
},
};
const direct = resolveChannelPeerFromBinding({
channel: 'whatsapp',
botId: 'bot-1',
conversationKey: 'direct:8618222222222@s.whatsapp.net',
grant,
});
assert.equal(direct.label, 'Alice · 8618222222222');
assert.equal(direct.kind, 'direct');
assert.equal(direct.phone, '8618222222222');
const groupUser = resolveChannelPeerFromBinding({
channel: 'whatsapp',
botId: 'bot-1',
conversationKey: 'group:120363@g.us:user:8618222222222@s.whatsapp.net',
grant,
});
assert.equal(groupUser.label, 'Ops Room · Alice');
assert.equal(groupUser.groupTitle, 'Ops Room');
const groupShared = resolveChannelPeerFromBinding({
channel: 'whatsapp',
botId: 'bot-1',
conversationKey: 'group:120363@g.us',
grant,
});
assert.equal(groupShared.label, '群 · Ops Room');
});
test('ConversationStateStore.keyForSession reverses session bindings', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-channel-peer-'));
try {
const store = await new ConversationStateStore(join(root, 'state.json')).load();
await store.setSession('direct:u1', 'session-a');
await store.setSession('group:g1:user:u2', 'session-b');
assert.equal(store.keyForSession('session-a'), 'direct:u1');
assert.equal(store.keyForSession('session-b'), 'group:g1:user:u2');
assert.equal(store.keyForSession('missing'), null);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -764,7 +764,10 @@ test('client registers one top-level bilingual IM settings section with a direct
}, },
slots: { slots: {
inject(name, install) { inject(name, install) {
assert.equal(name, 'settings.section'); assert.ok(
name === 'settings.section' || name === 'conversation.session.header.actions',
`unexpected slot inject: ${name}`,
);
install(); install();
}, },
register(options, component) { register(options, component) {
@ -783,15 +786,22 @@ test('client registers one top-level bilingual IM settings section with a direct
assert.deepEqual(clientInject, ['slots', 'connection', 'locale', 'workspaces']); assert.deepEqual(clientInject, ['slots', 'connection', 'locale', 'workspaces']);
assert.equal(dictionaries[0].namespace, IM_LOCALE_NAMESPACE); assert.equal(dictionaries[0].namespace, IM_LOCALE_NAMESPACE);
assert.deepEqual(Object.keys(dictionaries[0].value.en).sort(), Object.keys(dictionaries[0].value.zh).sort()); assert.deepEqual(Object.keys(dictionaries[0].value.en).sort(), Object.keys(dictionaries[0].value.zh).sort());
assert.equal(registrations.length, 1); assert.equal(registrations.length, 2);
assert.equal(registrations[0].options.name, 'settings.section'); const header = registrations.find((entry) => entry.options.name === 'conversation.session.header.actions');
assert.equal(registrations[0].options.id, 'xmanrui-dsh-im'); const settings = registrations.find((entry) => entry.options.name === 'settings.section');
assert.equal(registrations[0].options.order, 21); assert.ok(header);
assert.equal(registrations[0].options.locale, IM_LOCALE_NAMESPACE); assert.equal(header.options.id, 'im-channel-peer');
assert.equal(registrations[0].options.label(), 'IM bots'); assert.equal(header.options.order, 0);
assert.equal(registrations[0].component, IMSettingsTab); assert.equal(header.options.locale, IM_LOCALE_NAMESPACE);
assert.equal(typeof header.options.inject().resolveChannelPeer, 'function');
assert.ok(settings);
assert.equal(settings.options.id, 'xmanrui-dsh-im');
assert.equal(settings.options.order, 21);
assert.equal(settings.options.locale, IM_LOCALE_NAMESPACE);
assert.equal(settings.options.label(), 'IM bots');
assert.equal(settings.component, IMSettingsTab);
const injected = registrations[0].options.inject(); const injected = settings.options.inject();
const signal = new AbortController().signal; const signal = new AbortController().signal;
await injected.updateRpcCall('update.status', {}, signal); await injected.updateRpcCall('update.status', {}, signal);
assert.deepEqual(rpcCalls, [['/dsh-im', 'update.status', {}, signal]]); assert.deepEqual(rpcCalls, [['/dsh-im', 'update.status', {}, signal]]);
@ -806,7 +816,7 @@ test('client registers one top-level bilingual IM settings section with a direct
]); ]);
const markup = renderToStaticMarkup(React.createElement( const markup = renderToStaticMarkup(React.createElement(
registrations[0].component, settings.component,
injected, injected,
)); ));
assert.match(markup, /Connecting DeepSeek Harness/); assert.match(markup, /Connecting DeepSeek Harness/);
@ -846,7 +856,10 @@ test('client directory picker uses the current DSH uiWorkspace service', async (
}, },
slots: { slots: {
inject(name, install) { inject(name, install) {
assert.equal(name, 'settings.section'); assert.ok(
name === 'settings.section' || name === 'conversation.session.header.actions',
`unexpected slot inject: ${name}`,
);
install(); install();
}, },
register(options, component) { register(options, component) {
@ -869,7 +882,9 @@ test('client directory picker uses the current DSH uiWorkspace service', async (
}, },
}; };
const injected = registrations[0].options.inject(); const settings = registrations.find((entry) => entry.options.name === 'settings.section');
assert.ok(settings);
const injected = settings.options.inject();
const signal = new AbortController().signal; const signal = new AbortController().signal;
assert.deepEqual( assert.deepEqual(
await injected.workspaceDirectoryPicker.listDirectory('/workspace/current', signal), await injected.workspaceDirectoryPicker.listDirectory('/workspace/current', signal),