mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +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
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue