mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
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:
parent
2718586fda
commit
37742043a9
15 changed files with 852 additions and 326 deletions
319
lib/client.js
319
lib/client.js
|
|
@ -566,12 +566,12 @@ __export(index_exports, {
|
|||
name: () => name
|
||||
});
|
||||
module.exports = __toCommonJS(index_exports);
|
||||
var React24 = __toESM(require("react"), 1);
|
||||
var React25 = __toESM(require("react"), 1);
|
||||
|
||||
// package.json
|
||||
var package_default = {
|
||||
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.",
|
||||
keywords: [
|
||||
"deepseek-harness",
|
||||
|
|
@ -852,6 +852,7 @@ var EN = Object.freeze({
|
|||
"\u673A\u5668\u4EBA\u8BBE\u7F6E\u9875\u7B7E": "Bot settings tabs",
|
||||
"\u6295\u9012\u8BBE\u7F6E": "Delivery settings",
|
||||
"\u8BBF\u95EE\u8BBE\u7F6E": "Access settings",
|
||||
"\u6E20\u9053\u7528\u6237": "Channel user",
|
||||
"\uFF08\u4E0D\u53EF\u7528\uFF09": " (unavailable)",
|
||||
"\u7FA4 Agent Preset": "Group Agent Preset",
|
||||
"\u8DDF\u968F\u5168\u5C40": "Follow global",
|
||||
|
|
@ -11353,7 +11354,8 @@ var WHATSAPP_ENDPOINTS = Object.freeze({
|
|||
setGroupSessionScope: "bot.group-session-scope.set",
|
||||
setWorkspace: "bot.workspace.set",
|
||||
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 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
|
||||
var React22 = __toESM(require("react"), 1);
|
||||
var React23 = __toESM(require("react"), 1);
|
||||
|
||||
// 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 GROUP_SESSION_SCOPE_ENDPOINT = "bot.group-session-scope.set";
|
||||
var ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
|
||||
|
|
@ -12220,8 +12294,8 @@ function ScenePolicyEditor({
|
|||
unsupported = false,
|
||||
onChange
|
||||
}) {
|
||||
const ownerHelpId = React20.useId();
|
||||
const emptyAllowlistHelpId = React20.useId();
|
||||
const ownerHelpId = React21.useId();
|
||||
const emptyAllowlistHelpId = React21.useId();
|
||||
const allowlist = policy.mode === "allowlist";
|
||||
const collectionKey = allowlist ? "users" : "commandPermissionOverrides";
|
||||
const branchKey = allowlist ? "allowlist" : "open";
|
||||
|
|
@ -12273,7 +12347,7 @@ function ScenePolicyEditor({
|
|||
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(
|
||||
React20.Fragment,
|
||||
React21.Fragment,
|
||||
null,
|
||||
h2(
|
||||
"div",
|
||||
|
|
@ -12415,18 +12489,18 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
|
|||
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
|
||||
const initialKey = JSON.stringify(initialPolicy);
|
||||
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
|
||||
const [draft, setDraft] = React20.useState(() => clonePolicy(
|
||||
const [draft, setDraft] = React21.useState(() => clonePolicy(
|
||||
initialPolicy ?? DEFAULT_ACCESS_POLICY
|
||||
));
|
||||
const [groupSessionScope, setGroupSessionScope] = React20.useState(initialScope);
|
||||
const [saving, setSaving] = React20.useState(false);
|
||||
const [feedback, setFeedback] = React20.useState(null);
|
||||
React20.useEffect(() => {
|
||||
const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope);
|
||||
const [saving, setSaving] = React21.useState(false);
|
||||
const [feedback, setFeedback] = React21.useState(null);
|
||||
React21.useEffect(() => {
|
||||
const next = normalizeAccessPolicy(account?.accessPolicy);
|
||||
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
|
||||
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
|
||||
}, [account?.botId, initialKey, account?.groupSessionScope]);
|
||||
React20.useEffect(() => {
|
||||
React21.useEffect(() => {
|
||||
setFeedback(null);
|
||||
}, [account?.botId]);
|
||||
if (!definition) {
|
||||
|
|
@ -12579,7 +12653,7 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
|
|||
}
|
||||
|
||||
// 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_PENDING_RESOLVE_ENDPOINT = "bot.access-pending.resolve";
|
||||
var ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = "bot.access-group-titles.refresh";
|
||||
|
|
@ -12671,13 +12745,13 @@ function PhoneTypeahead({
|
|||
disabled,
|
||||
requirePhone = true
|
||||
}) {
|
||||
const [query, setQuery] = React21.useState(value || "");
|
||||
const [open, setOpen] = React21.useState(false);
|
||||
const blurTimer = React21.useRef(null);
|
||||
React21.useEffect(() => {
|
||||
const [query, setQuery] = React22.useState(value || "");
|
||||
const [open, setOpen] = React22.useState(false);
|
||||
const blurTimer = React22.useRef(null);
|
||||
React22.useEffect(() => {
|
||||
setQuery(value || "");
|
||||
}, [value]);
|
||||
React21.useEffect(() => () => {
|
||||
React22.useEffect(() => () => {
|
||||
if (blurTimer.current) window.clearTimeout(blurTimer.current);
|
||||
}, []);
|
||||
const q = query.trim().toLowerCase();
|
||||
|
|
@ -12985,21 +13059,21 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
|
|||
const initialGrant = normalizeAccessGrant(account?.accessGrant);
|
||||
const initialKey = JSON.stringify(initialGrant);
|
||||
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
|
||||
const [draft, setDraft] = React21.useState(() => cloneGrant(initialGrant, ownerPhone));
|
||||
const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope);
|
||||
const [saving, setSaving] = React21.useState(false);
|
||||
const [feedback, setFeedback] = React21.useState(null);
|
||||
const [newGroupJid, setNewGroupJid] = React21.useState("");
|
||||
const [loadingGrant, setLoadingGrant] = React21.useState(!initialGrant);
|
||||
const contextCatalog = React21.useContext(AgentPresetCatalogContext);
|
||||
const [presetCatalog, setPresetCatalog] = React21.useState(() => normalizeAgentPresetCatalog(
|
||||
const [draft, setDraft] = React22.useState(() => cloneGrant(initialGrant, ownerPhone));
|
||||
const [groupSessionScope, setGroupSessionScope] = React22.useState(initialScope);
|
||||
const [saving, setSaving] = React22.useState(false);
|
||||
const [feedback, setFeedback] = React22.useState(null);
|
||||
const [newGroupJid, setNewGroupJid] = React22.useState("");
|
||||
const [loadingGrant, setLoadingGrant] = React22.useState(!initialGrant);
|
||||
const contextCatalog = React22.useContext(AgentPresetCatalogContext);
|
||||
const [presetCatalog, setPresetCatalog] = React22.useState(() => normalizeAgentPresetCatalog(
|
||||
account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG
|
||||
));
|
||||
React21.useEffect(() => {
|
||||
React22.useEffect(() => {
|
||||
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
|
||||
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
|
||||
}, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]);
|
||||
React21.useEffect(() => {
|
||||
React22.useEffect(() => {
|
||||
if (typeof rpcCall !== "function" || !account?.botId) return void 0;
|
||||
let cancelled = false;
|
||||
setLoadingGrant(true);
|
||||
|
|
@ -13053,7 +13127,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
|
|||
};
|
||||
}, [account?.botId, rpcCall, ownerPhone]);
|
||||
const contacts = draft.contacts ?? [];
|
||||
const knownGroupJids = React21.useMemo(() => {
|
||||
const knownGroupJids = React22.useMemo(() => {
|
||||
const set = new Set(Object.keys(draft.groups ?? {}));
|
||||
for (const contact of contacts) {
|
||||
for (const jid of contact.groupJids ?? []) set.add(jid);
|
||||
|
|
@ -13063,7 +13137,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
|
|||
}
|
||||
return [...set].sort();
|
||||
}, [draft.groups, draft.pending, contacts]);
|
||||
const actionableContacts = React21.useMemo(
|
||||
const actionableContacts = React22.useMemo(
|
||||
() => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)),
|
||||
[contacts, draft, knownGroupJids]
|
||||
);
|
||||
|
|
@ -13742,13 +13816,13 @@ function TargetForm({
|
|||
}) {
|
||||
const editing = mode === "edit";
|
||||
const initialKind = initialValue?.kind && definition.fields[initialValue.kind] ? initialValue.kind : definition.kinds[0].value;
|
||||
const [targetId, setTargetId] = React22.useState(initialValue?.targetId ?? "");
|
||||
const [name2, setName] = React22.useState(initialValue?.name ?? "");
|
||||
const [kind, setKind] = React22.useState(initialKind);
|
||||
const [route, setRoute] = React22.useState(initialValue?.route ?? {});
|
||||
const [error, setError] = React22.useState(null);
|
||||
const [testing, setTesting] = React22.useState(false);
|
||||
const [testState, setTestState] = React22.useState(null);
|
||||
const [targetId, setTargetId] = React23.useState(initialValue?.targetId ?? "");
|
||||
const [name2, setName] = React23.useState(initialValue?.name ?? "");
|
||||
const [kind, setKind] = React23.useState(initialKind);
|
||||
const [route, setRoute] = React23.useState(initialValue?.route ?? {});
|
||||
const [error, setError] = React23.useState(null);
|
||||
const [testing, setTesting] = React23.useState(false);
|
||||
const [testState, setTestState] = React23.useState(null);
|
||||
const currentTarget = () => {
|
||||
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
|
||||
const raw = String(route[field.key] ?? "").trim();
|
||||
|
|
@ -13965,7 +14039,7 @@ function TargetSuggestionPicker({
|
|||
suggestions.map((suggestion, index) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
const added = configured.has(identity);
|
||||
return React22.createElement("option", {
|
||||
return React23.createElement("option", {
|
||||
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
|
||||
value: String(index),
|
||||
disabled: added
|
||||
|
|
@ -13982,10 +14056,10 @@ function TargetSuggestionPicker({
|
|||
);
|
||||
}
|
||||
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
|
||||
const [action, setAction] = React22.useState(null);
|
||||
const [testState, setTestState] = React22.useState(null);
|
||||
const [copyState, setCopyState] = React22.useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = React22.useState(false);
|
||||
const [action, setAction] = React23.useState(null);
|
||||
const [testState, setTestState] = React23.useState(null);
|
||||
const [copyState, setCopyState] = React23.useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = React23.useState(false);
|
||||
const testTarget = async () => {
|
||||
setAction("test");
|
||||
setTestState(null);
|
||||
|
|
@ -14027,7 +14101,7 @@ function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, o
|
|||
h2(
|
||||
"div",
|
||||
{ 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("code", null, `targetId: ${target.targetId}`)
|
||||
|
|
@ -14090,28 +14164,28 @@ function DeliveryTargetSettingsPage({
|
|||
onBack
|
||||
}) {
|
||||
const definition = CHANNEL_DEFINITIONS[channel4];
|
||||
const [activeTabId, setActiveTabId] = React22.useState(BOT_SETTINGS_TABS[0].id);
|
||||
const [phase, setPhase] = React22.useState("loading");
|
||||
const [targets, setTargets] = React22.useState([]);
|
||||
const [suggestionPhase, setSuggestionPhase] = React22.useState("idle");
|
||||
const [suggestions, setSuggestions] = React22.useState([]);
|
||||
const [suggestionError, setSuggestionError] = React22.useState(null);
|
||||
const [error, setError] = React22.useState(null);
|
||||
const [editor, setEditor] = React22.useState(null);
|
||||
const [saving, setSaving] = React22.useState(false);
|
||||
const [botCopyState, setBotCopyState] = React22.useState(null);
|
||||
const [accessPolicy, setAccessPolicy] = React22.useState(account.accessPolicy);
|
||||
const [accessGrant, setAccessGrant] = React22.useState(account.accessGrant);
|
||||
const mounted = React22.useRef(true);
|
||||
React22.useEffect(() => {
|
||||
const [activeTabId, setActiveTabId] = React23.useState(BOT_SETTINGS_TABS[0].id);
|
||||
const [phase, setPhase] = React23.useState("loading");
|
||||
const [targets, setTargets] = React23.useState([]);
|
||||
const [suggestionPhase, setSuggestionPhase] = React23.useState("idle");
|
||||
const [suggestions, setSuggestions] = React23.useState([]);
|
||||
const [suggestionError, setSuggestionError] = React23.useState(null);
|
||||
const [error, setError] = React23.useState(null);
|
||||
const [editor, setEditor] = React23.useState(null);
|
||||
const [saving, setSaving] = React23.useState(false);
|
||||
const [botCopyState, setBotCopyState] = React23.useState(null);
|
||||
const [accessPolicy, setAccessPolicy] = React23.useState(account.accessPolicy);
|
||||
const [accessGrant, setAccessGrant] = React23.useState(account.accessGrant);
|
||||
const mounted = React23.useRef(true);
|
||||
React23.useEffect(() => {
|
||||
setAccessPolicy(account.accessPolicy);
|
||||
setAccessGrant(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");
|
||||
return unwrapRpcResult12(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
const loadTargets = React22.useCallback(async ({ signal, silent = false } = {}) => {
|
||||
const loadTargets = React23.useCallback(async ({ signal, silent = false } = {}) => {
|
||||
if (!silent) setPhase("loading");
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -14125,7 +14199,7 @@ function DeliveryTargetSettingsPage({
|
|||
setPhase("error");
|
||||
}
|
||||
}, [account.botId, invoke]);
|
||||
const loadSuggestions = React22.useCallback(async () => {
|
||||
const loadSuggestions = React23.useCallback(async () => {
|
||||
setSuggestionPhase("loading");
|
||||
setSuggestionError(null);
|
||||
try {
|
||||
|
|
@ -14139,7 +14213,7 @@ function DeliveryTargetSettingsPage({
|
|||
setSuggestionPhase("error");
|
||||
}
|
||||
}, [account.botId, definition, invoke]);
|
||||
React22.useEffect(() => {
|
||||
React23.useEffect(() => {
|
||||
mounted.current = true;
|
||||
const controller = new AbortController();
|
||||
void loadTargets({ signal: controller.signal });
|
||||
|
|
@ -14266,7 +14340,7 @@ function DeliveryTargetSettingsPage({
|
|||
rpcCall: accessRpcCall,
|
||||
onSaved: setAccessPolicy
|
||||
}) : h2(
|
||||
React22.Fragment,
|
||||
React23.Fragment,
|
||||
null,
|
||||
h2(
|
||||
"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-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-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-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
|
||||
.dim-panel .ddt-headingCopy { display: none; }
|
||||
|
|
@ -15052,7 +15154,7 @@ function installImStyles() {
|
|||
}
|
||||
|
||||
// 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_valid = __toESM(require_valid(), 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}`;
|
||||
}
|
||||
function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
|
||||
const [copyState, setCopyState] = React23.useState("idle");
|
||||
const commandRef = React23.useRef(null);
|
||||
const mounted = React23.useRef(false);
|
||||
const copying = React23.useRef(false);
|
||||
React23.useEffect(() => {
|
||||
const [copyState, setCopyState] = React24.useState("idle");
|
||||
const commandRef = React24.useRef(null);
|
||||
const mounted = React24.useRef(false);
|
||||
const copying = React24.useRef(false);
|
||||
React24.useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
|
|
@ -15178,7 +15280,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
|
|||
{ className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" },
|
||||
h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"),
|
||||
command ? h2(
|
||||
React23.Fragment,
|
||||
React24.Fragment,
|
||||
null,
|
||||
h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"),
|
||||
h2(
|
||||
|
|
@ -15214,7 +15316,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
|
|||
"aria-hidden": "true",
|
||||
focusable: "false"
|
||||
}, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2(
|
||||
React23.Fragment,
|
||||
React24.Fragment,
|
||||
null,
|
||||
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" })
|
||||
|
|
@ -15239,10 +15341,10 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
|
|||
);
|
||||
}
|
||||
function UpdateDialog({ children, onClose }) {
|
||||
const dialogRef = React23.useRef(null);
|
||||
const titleId = React23.useId();
|
||||
const descriptionId = React23.useId();
|
||||
React23.useEffect(() => {
|
||||
const dialogRef = React24.useRef(null);
|
||||
const titleId = React24.useId();
|
||||
const descriptionId = React24.useId();
|
||||
React24.useEffect(() => {
|
||||
const previous = globalThis.document?.activeElement;
|
||||
dialogRef.current?.focus?.();
|
||||
return () => {
|
||||
|
|
@ -15300,19 +15402,19 @@ function UpdateDialog({ children, onClose }) {
|
|||
return typeof document !== "undefined" && document.body ? (0, import_react_dom3.createPortal)(content, document.body) : content;
|
||||
}
|
||||
function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
||||
const [snapshot, setSnapshot] = React23.useState(null);
|
||||
const [action, setAction] = React23.useState("status");
|
||||
const [error, setError] = React23.useState(null);
|
||||
const [open, setOpen] = React23.useState(false);
|
||||
const [uncertainInstall, setUncertainInstall] = React23.useState(false);
|
||||
const mounted = React23.useRef(false);
|
||||
const busy = React23.useRef(false);
|
||||
const readController = React23.useRef(null);
|
||||
const pollReadController = React23.useRef(null);
|
||||
const installRequest = React23.useRef(null);
|
||||
const onStatusRef = React23.useRef(onStatus);
|
||||
const [snapshot, setSnapshot] = React24.useState(null);
|
||||
const [action, setAction] = React24.useState("status");
|
||||
const [error, setError] = React24.useState(null);
|
||||
const [open, setOpen] = React24.useState(false);
|
||||
const [uncertainInstall, setUncertainInstall] = React24.useState(false);
|
||||
const mounted = React24.useRef(false);
|
||||
const busy = React24.useRef(false);
|
||||
const readController = React24.useRef(null);
|
||||
const pollReadController = React24.useRef(null);
|
||||
const installRequest = React24.useRef(null);
|
||||
const onStatusRef = React24.useRef(onStatus);
|
||||
onStatusRef.current = onStatus;
|
||||
const accept = React23.useCallback((next) => {
|
||||
const accept = React24.useCallback((next) => {
|
||||
setSnapshot(next);
|
||||
onStatusRef.current?.(next);
|
||||
}, []);
|
||||
|
|
@ -15321,7 +15423,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
|||
setUncertainInstall(false);
|
||||
accept(next);
|
||||
};
|
||||
const invoke = React23.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
const invoke = React24.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
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");
|
||||
unavailable.code = "update-unavailable";
|
||||
|
|
@ -15329,7 +15431,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
|||
}
|
||||
return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
React23.useEffect(() => {
|
||||
React24.useEffect(() => {
|
||||
mounted.current = true;
|
||||
busy.current = true;
|
||||
const controller = new AbortController();
|
||||
|
|
@ -15352,7 +15454,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
|||
const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state);
|
||||
const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart";
|
||||
const shouldPoll = activeJob || uncertainInstall;
|
||||
React23.useEffect(() => {
|
||||
React24.useEffect(() => {
|
||||
if (!shouldPoll) return void 0;
|
||||
let controller;
|
||||
const scheduler = createPollScheduler({
|
||||
|
|
@ -15450,7 +15552,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
|||
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";
|
||||
return h2(
|
||||
React23.Fragment,
|
||||
React24.Fragment,
|
||||
null,
|
||||
h2("button", {
|
||||
type: "button",
|
||||
|
|
@ -15475,13 +15577,13 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
|||
h2("dt", null, "\u8FD0\u884C\u7248\u672C"),
|
||||
h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`),
|
||||
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2(
|
||||
React23.Fragment,
|
||||
React24.Fragment,
|
||||
null,
|
||||
h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"),
|
||||
h2("dd", null, `v${snapshot.installedVersion}`)
|
||||
) : null,
|
||||
targetVersion ? h2(
|
||||
React23.Fragment,
|
||||
React24.Fragment,
|
||||
null,
|
||||
h2("dt", null, "\u76EE\u6807\u7248\u672C"),
|
||||
h2("dd", null, `v${targetVersion}`)
|
||||
|
|
@ -15685,20 +15787,20 @@ function IMSettingsTab({
|
|||
browserLocation = globalThis.location,
|
||||
navigateToRecoveryUrl = replacePageLocation
|
||||
}) {
|
||||
const [selected, setSelected] = React24.useState("weixin");
|
||||
const [loopbackRecovery, setLoopbackRecovery] = React24.useState(null);
|
||||
const [runningVersion, setRunningVersion] = React24.useState(IM_PLUGIN_VERSION);
|
||||
const [deliverySettings, setDeliverySettings] = React24.useState(null);
|
||||
const githubTooltipId = React24.useId();
|
||||
const channelSelectId = React24.useId();
|
||||
const [selected, setSelected] = React25.useState("weixin");
|
||||
const [loopbackRecovery, setLoopbackRecovery] = React25.useState(null);
|
||||
const [runningVersion, setRunningVersion] = React25.useState(IM_PLUGIN_VERSION);
|
||||
const [deliverySettings, setDeliverySettings] = React25.useState(null);
|
||||
const githubTooltipId = React25.useId();
|
||||
const channelSelectId = React25.useId();
|
||||
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);
|
||||
}, []);
|
||||
const reportUpdateStatus = React24.useCallback((snapshot) => {
|
||||
const reportUpdateStatus = React25.useCallback((snapshot) => {
|
||||
setRunningVersion(snapshot.runningVersion);
|
||||
}, []);
|
||||
const rpcCalls = React24.useMemo(() => createLoopbackAwareRpcCalls({
|
||||
const rpcCalls = React25.useMemo(() => createLoopbackAwareRpcCalls({
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
feishuRpcCall,
|
||||
|
|
@ -15730,7 +15832,7 @@ function IMSettingsTab({
|
|||
weixinRpcCall,
|
||||
whatsappRpcCall
|
||||
]);
|
||||
const botSettingsContext = React24.useMemo(() => Object.freeze({
|
||||
const botSettingsContext = React25.useMemo(() => Object.freeze({
|
||||
openBotSettings: setDeliverySettings
|
||||
}), []);
|
||||
return h2(
|
||||
|
|
@ -15881,6 +15983,15 @@ function apply(ctx) {
|
|||
listDirectory: (path, signal) => callWorkspaceDirectoryApi(ctx, "listDirectory", path, signal),
|
||||
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({
|
||||
name: "settings.section",
|
||||
id: "xmanrui-dsh-im",
|
||||
|
|
|
|||
416
lib/index.js
416
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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.",
|
||||
"keywords": [
|
||||
"deepseek-harness",
|
||||
|
|
|
|||
95
plugin-src/client/channel-peer-label.js
Normal file
95
plugin-src/client/channel-peer-label.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
resolveChannelPeer: 'bot.session.channel-peer',
|
||||
});
|
||||
|
||||
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const EN = Object.freeze({
|
|||
'机器人设置页签': 'Bot settings tabs',
|
||||
'投递设置': 'Delivery settings',
|
||||
'访问设置': 'Access settings',
|
||||
'渠道用户': 'Channel user',
|
||||
"(不可用)": " (unavailable)",
|
||||
"群 Agent Preset": "Group Agent Preset",
|
||||
"跟随全局": "Follow global",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
|
|||
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
|
||||
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
|
||||
import { BotSettingsContext } from './channel-card-meta.js';
|
||||
import {
|
||||
ChannelPeerLabel,
|
||||
resolveWhatsappChannelPeer,
|
||||
} from './channel-peer-label.js';
|
||||
import {
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
DeliveryTargetSettingsPage,
|
||||
|
|
@ -376,6 +380,18 @@ export function apply(ctx) {
|
|||
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({
|
||||
name: 'settings.section',
|
||||
id: 'xmanrui-dsh-im',
|
||||
|
|
|
|||
|
|
@ -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-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-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-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
|
||||
.dim-panel .ddt-headingCopy { display: none; }
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog,
|
||||
channel: 'whatsapp',
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
channel: 'whatsapp',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
resolveChannelPeer: 'bot.session.channel-peer',
|
||||
});
|
||||
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
|
||||
|
||||
|
|
@ -99,6 +100,13 @@ function payloadFailure(endpoint, payload) {
|
|||
return validContextEnhancementPayload(payload)
|
||||
? 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.';
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +260,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
payload.groupSessionScope,
|
||||
(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 {
|
||||
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
resolveAccessPending,
|
||||
validateAccessGrant,
|
||||
} from './access-grant.mjs';
|
||||
import { resolveChannelPeerFromBinding } from './channel-peer.mjs';
|
||||
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
|
||||
import {
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
|
|
@ -1458,10 +1459,16 @@ export function createBotScopedHarness(harness, options) {
|
|||
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') {
|
||||
throw new TypeError('controller, workspaces, and stateFor are required');
|
||||
}
|
||||
const channelId = typeof channel === 'string' && channel.trim() ? channel.trim() : null;
|
||||
const transitions = new Map();
|
||||
const withBotTransition = (botId, operation) => {
|
||||
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, {
|
||||
get(target, property) {
|
||||
if (property === 'updateWorkspace') return updateWorkspace;
|
||||
|
|
@ -1683,6 +1719,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
|||
if (property === 'updateGroupSessionScope') return updateGroupSessionScope;
|
||||
if (property === 'updateAccessGrant') return updateAccessGrant;
|
||||
if (property === 'resolveAccessPending') return resolveAccessPendingProxy;
|
||||
if (property === 'resolveChannelPeer') return resolveChannelPeer;
|
||||
const value = Reflect.get(target, property, target);
|
||||
if (typeof value !== 'function') return value;
|
||||
if (property === 'deleteBot') {
|
||||
|
|
|
|||
115
src/channels/shared/channel-peer.mjs
Normal file
115
src/channels/shared/channel-peer.mjs
Normal 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}`,
|
||||
});
|
||||
}
|
||||
|
|
@ -47,6 +47,19 @@ export class ConversationStateStore {
|
|||
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) {
|
||||
this.#state.sessions[key] = sessionId;
|
||||
await this.#persist();
|
||||
|
|
|
|||
80
test/channels/shared/channel-peer.test.mjs
Normal file
80
test/channels/shared/channel-peer.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -764,7 +764,10 @@ test('client registers one top-level bilingual IM settings section with a direct
|
|||
},
|
||||
slots: {
|
||||
inject(name, install) {
|
||||
assert.equal(name, 'settings.section');
|
||||
assert.ok(
|
||||
name === 'settings.section' || name === 'conversation.session.header.actions',
|
||||
`unexpected slot inject: ${name}`,
|
||||
);
|
||||
install();
|
||||
},
|
||||
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.equal(dictionaries[0].namespace, IM_LOCALE_NAMESPACE);
|
||||
assert.deepEqual(Object.keys(dictionaries[0].value.en).sort(), Object.keys(dictionaries[0].value.zh).sort());
|
||||
assert.equal(registrations.length, 1);
|
||||
assert.equal(registrations[0].options.name, 'settings.section');
|
||||
assert.equal(registrations[0].options.id, 'xmanrui-dsh-im');
|
||||
assert.equal(registrations[0].options.order, 21);
|
||||
assert.equal(registrations[0].options.locale, IM_LOCALE_NAMESPACE);
|
||||
assert.equal(registrations[0].options.label(), 'IM bots');
|
||||
assert.equal(registrations[0].component, IMSettingsTab);
|
||||
assert.equal(registrations.length, 2);
|
||||
const header = registrations.find((entry) => entry.options.name === 'conversation.session.header.actions');
|
||||
const settings = registrations.find((entry) => entry.options.name === 'settings.section');
|
||||
assert.ok(header);
|
||||
assert.equal(header.options.id, 'im-channel-peer');
|
||||
assert.equal(header.options.order, 0);
|
||||
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;
|
||||
await injected.updateRpcCall('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(
|
||||
registrations[0].component,
|
||||
settings.component,
|
||||
injected,
|
||||
));
|
||||
assert.match(markup, /Connecting DeepSeek Harness/);
|
||||
|
|
@ -846,7 +856,10 @@ test('client directory picker uses the current DSH uiWorkspace service', async (
|
|||
},
|
||||
slots: {
|
||||
inject(name, install) {
|
||||
assert.equal(name, 'settings.section');
|
||||
assert.ok(
|
||||
name === 'settings.section' || name === 'conversation.session.header.actions',
|
||||
`unexpected slot inject: ${name}`,
|
||||
);
|
||||
install();
|
||||
},
|
||||
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;
|
||||
assert.deepEqual(
|
||||
await injected.workspaceDirectoryPicker.listDirectory('/workspace/current', signal),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue