mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
Improve WhatsApp delivery suggestions and peer labels (ops.30).
Surface chat/access-list contacts as selectable targets with clearer nicknames, and keep conversation peers usable for ops schedulers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e84630565c
commit
356538ac18
19 changed files with 827 additions and 324 deletions
|
|
@ -571,8 +571,8 @@ var React25 = __toESM(require("react"), 1);
|
|||
// package.json
|
||||
var package_default = {
|
||||
name: "dsh-im-ops",
|
||||
version: "4.9.1-ops.22",
|
||||
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 \u2014 access/session policies owned here.",
|
||||
version: "4.9.1-ops.30",
|
||||
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",
|
||||
"dsh",
|
||||
|
|
@ -1725,12 +1725,16 @@ var EN = Object.freeze({
|
|||
"\u5DF2\u804A\u4F1A\u8BDD": "Conversation",
|
||||
"\u4ECE\u4F1A\u8BDD\u9009\u62E9targetID": "Select a targetID from conversations",
|
||||
"\u9009\u62E9\u540E\u4F1A\u81EA\u52A8\u586B\u5199\u76EE\u6807\u4FE1\u606F\u548C\u8C03\u7528\u522B\u540D\uFF0C\u786E\u8BA4\u540E\u518D\u4FDD\u5B58\u3002": "Selecting a conversation fills the target details and call alias. Review them before saving.",
|
||||
"\u4F1A\u5217\u51FA\u5DF2\u804A\u8FC7\u7684\u79C1\u804A/\u7FA4\u804A\uFF0C\u4EE5\u53CA\u8BBF\u95EE\u540D\u5355\u91CC\u8BB0\u4F4F\u7684\u8054\u7CFB\u4EBA\u4E0E\u7FA4\uFF08\u663E\u793A\u4E3A\u6635\u79F0 \xB7 \u7535\u8BDD / \u7FA4\u540D\uFF09\u3002": "Lists known DMs and groups, plus remembered access-list contacts and groups (shown as nickname \xB7 phone / group name).",
|
||||
"\u53EF\u9009\u6295\u9012\u4F1A\u8BDD": "Available delivery conversations",
|
||||
"\u4F8B\u5982 \u5F20\u4E09 \xB7 13800138000": "For example, Alice \xB7 13800138000",
|
||||
"\u6B63\u5728\u5237\u65B0\u2026": "Refreshing\u2026",
|
||||
"\u5237\u65B0": "Refresh",
|
||||
"\u6B63\u5728\u8BFB\u53D6\u5DF2\u804A\u4F1A\u8BDD\u2026": "Loading known conversations\u2026",
|
||||
"\u65E0\u6CD5\u8BFB\u53D6\u5DF2\u804A\u4F1A\u8BDD\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002": "Could not load known conversations. Try again later.",
|
||||
"\u8FD8\u6CA1\u6709\u53EF\u9009\u62E9\u7684\u4F1A\u8BDD": "No conversations to choose from",
|
||||
"\u5148\u5728\u5BF9\u5E94\u5E73\u53F0\u4E0E\u673A\u5668\u4EBA\u804A\u4E00\u6761\u6D88\u606F\uFF0C\u518D\u5237\u65B0\u3002": "Send the bot a message on the platform, then refresh.",
|
||||
"\u5148\u5728\u5BF9\u5E94\u5E73\u53F0\u4E0E\u673A\u5668\u4EBA\u804A\u4E00\u6761\u6D88\u606F\uFF08\u79C1\u804A\u6216\u7FA4\u804A\uFF09\uFF0C\u6216\u5148\u5728\u300C\u8BBF\u95EE\u8BBE\u7F6E\u300D\u91CC\u51FA\u73B0\u8FC7\u8054\u7CFB\u4EBA/\u7FA4\uFF0C\u518D\u5237\u65B0\u3002": "Chat with the bot first (DM or group), or wait until contacts/groups appear under Access settings, then refresh.",
|
||||
"\u5DF2\u6DFB\u52A0": "Added",
|
||||
"\u624B\u52A8\u586B\u5199\uFF08\u9AD8\u7EA7\uFF09": "Enter manually (advanced)",
|
||||
"\u6D4B\u8BD5\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u8BF7\u5230\u76EE\u6807\u4F1A\u8BDD\u786E\u8BA4\u3002": "Test message sent. Confirm it in the target conversation.",
|
||||
|
|
@ -13901,7 +13905,7 @@ function TargetForm({
|
|||
value: name2,
|
||||
disabled: testing,
|
||||
maxLength: 80,
|
||||
placeholder: "\u4F8B\u5982 \u6BCF\u65E5\u6C47\u62A5\u7FA4",
|
||||
placeholder: definition.label === "WhatsApp" ? "\u4F8B\u5982 \u5F20\u4E09 \xB7 13800138000" : "\u4F8B\u5982 \u6BCF\u65E5\u6C47\u62A5\u7FA4",
|
||||
onChange: (event) => setName(event.target.value)
|
||||
})
|
||||
),
|
||||
|
|
@ -13975,12 +13979,6 @@ function TargetForm({
|
|||
)
|
||||
);
|
||||
}
|
||||
function suggestionOptionLabel(definition, suggestion, added) {
|
||||
const name2 = suggestionDisplayName(suggestion);
|
||||
const type = localizeText(kindLabel(definition, suggestion.kind));
|
||||
const route = fieldsFor(definition, suggestion.kind).map((field) => maskRouteValue(suggestion.route?.[field.key])).filter(Boolean).join(" \xB7 ");
|
||||
return [name2, type, route, added ? localizeText("\u5DF2\u6DFB\u52A0") : null].filter(Boolean).join(" \xB7 ");
|
||||
}
|
||||
function TargetSuggestionPicker({
|
||||
definition,
|
||||
phase,
|
||||
|
|
@ -13993,6 +13991,7 @@ function TargetSuggestionPicker({
|
|||
onCancel
|
||||
}) {
|
||||
const configured = new Set(targets.map((target) => routeIdentity(definition, target)).filter(Boolean));
|
||||
const channelHint = definition.label === "WhatsApp" ? "\u4F1A\u5217\u51FA\u5DF2\u804A\u8FC7\u7684\u79C1\u804A/\u7FA4\u804A\uFF0C\u4EE5\u53CA\u8BBF\u95EE\u540D\u5355\u91CC\u8BB0\u4F4F\u7684\u8054\u7CFB\u4EBA\u4E0E\u7FA4\uFF08\u663E\u793A\u4E3A\u6635\u79F0 \xB7 \u7535\u8BDD / \u7FA4\u540D\uFF09\u3002" : "\u9009\u62E9\u540E\u4F1A\u81EA\u52A8\u586B\u5199\u76EE\u6807\u4FE1\u606F\u548C\u8C03\u7528\u522B\u540D\uFF0C\u786E\u8BA4\u540E\u518D\u4FDD\u5B58\u3002";
|
||||
return h2(
|
||||
"section",
|
||||
{ className: "dim-targetSuggestions", "aria-label": "\u4ECE\u5DF2\u804A\u8FC7\u7684\u4F1A\u8BDD\u9009\u62E9" },
|
||||
|
|
@ -14003,7 +14002,7 @@ function TargetSuggestionPicker({
|
|||
"div",
|
||||
null,
|
||||
h2("h3", null, "\u4ECE\u5DF2\u804A\u8FC7\u7684\u4F1A\u8BDD\u9009\u62E9"),
|
||||
h2("p", null, "\u9009\u62E9\u540E\u4F1A\u81EA\u52A8\u586B\u5199\u76EE\u6807\u4FE1\u606F\u548C\u8C03\u7528\u522B\u540D\uFF0C\u786E\u8BA4\u540E\u518D\u4FDD\u5B58\u3002")
|
||||
h2("p", null, channelHint)
|
||||
),
|
||||
h2(DeliveryButton, {
|
||||
onClick: () => void onRefresh(),
|
||||
|
|
@ -14019,33 +14018,42 @@ function TargetSuggestionPicker({
|
|||
"div",
|
||||
{ className: "dim-targetSuggestionState" },
|
||||
h2("strong", null, "\u8FD8\u6CA1\u6709\u53EF\u9009\u62E9\u7684\u4F1A\u8BDD"),
|
||||
h2("p", null, "\u5148\u5728\u5BF9\u5E94\u5E73\u53F0\u4E0E\u673A\u5668\u4EBA\u804A\u4E00\u6761\u6D88\u606F\uFF0C\u518D\u5237\u65B0\u3002")
|
||||
h2("p", null, "\u5148\u5728\u5BF9\u5E94\u5E73\u53F0\u4E0E\u673A\u5668\u4EBA\u804A\u4E00\u6761\u6D88\u606F\uFF08\u79C1\u804A\u6216\u7FA4\u804A\uFF09\uFF0C\u6216\u5148\u5728\u300C\u8BBF\u95EE\u8BBE\u7F6E\u300D\u91CC\u51FA\u73B0\u8FC7\u8054\u7CFB\u4EBA/\u7FA4\uFF0C\u518D\u5237\u65B0\u3002")
|
||||
) : h2(
|
||||
"label",
|
||||
{ className: "dim-targetSuggestionField" },
|
||||
h2("span", null, "\u5DF2\u804A\u4F1A\u8BDD"),
|
||||
h2(
|
||||
"select",
|
||||
{
|
||||
name: "suggestion",
|
||||
value: "",
|
||||
onChange: (event) => {
|
||||
if (event.target.value === "") return;
|
||||
const suggestion = suggestions[Number(event.target.value)];
|
||||
if (suggestion) onSelect(suggestion);
|
||||
}
|
||||
},
|
||||
h2("option", { value: "", disabled: true }, "\u4ECE\u4F1A\u8BDD\u9009\u62E9targetID"),
|
||||
"ul",
|
||||
{ className: "dim-targetSuggestionList", role: "listbox", "aria-label": "\u53EF\u9009\u6295\u9012\u4F1A\u8BDD" },
|
||||
suggestions.map((suggestion, index) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
const added = configured.has(identity);
|
||||
return React23.createElement("option", {
|
||||
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
|
||||
value: String(index),
|
||||
disabled: added
|
||||
}, suggestionOptionLabel(definition, suggestion, added));
|
||||
})
|
||||
return h2(
|
||||
"li",
|
||||
{ key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}` },
|
||||
h2(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "dim-targetSuggestionItem",
|
||||
role: "option",
|
||||
"aria-disabled": added ? "true" : void 0,
|
||||
disabled: added,
|
||||
onClick: () => {
|
||||
if (!added) onSelect(suggestion);
|
||||
}
|
||||
},
|
||||
// Platform display names must stay raw (bypass h() i18n phrase rewrite).
|
||||
React23.createElement(
|
||||
"strong",
|
||||
null,
|
||||
suggestionDisplayName(suggestion) || suggestionFallbackName(definition, suggestion)
|
||||
),
|
||||
h2("span", null, [
|
||||
localizeText(kindLabel(definition, suggestion.kind)),
|
||||
added ? localizeText("\u5DF2\u6DFB\u52A0") : null
|
||||
].filter(Boolean).join(" \xB7 ")),
|
||||
React23.createElement("code", null, fieldsFor(definition, suggestion.kind).map((field) => maskRouteValue(suggestion.route?.[field.key])).filter(Boolean).join(" \xB7 "))
|
||||
)
|
||||
);
|
||||
})
|
||||
),
|
||||
h2(
|
||||
"div",
|
||||
|
|
@ -14932,6 +14940,13 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
|
|||
.dim-targetSuggestionField { min-width: 0; display: grid; gap: 5px; margin-top: 12px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
||||
.dim-targetSuggestionField select { width: 100%; min-width: 0; height: 38px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.dim-targetSuggestionField select:focus { outline: 2px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 28%, transparent); border-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-targetSuggestionList { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; max-height: 280px; overflow: auto; }
|
||||
.dim-targetSuggestionItem { width: 100%; box-sizing: border-box; display: grid; gap: 2px; text-align: left; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #fff); color: inherit; font: inherit; cursor: pointer; }
|
||||
.dim-targetSuggestionItem:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-targetSuggestionItem:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.dim-targetSuggestionItem strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; line-height: 20px; font-weight: 600; }
|
||||
.dim-targetSuggestionItem span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
||||
.dim-targetSuggestionItem code { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 16px; word-break: break-all; }
|
||||
.dim-targetForm { margin-top: 14px; padding: 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-targetFormHeading h3 { margin: 0; font-size: 14px; line-height: 21px; }
|
||||
.dim-targetFormHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
|
||||
|
|
|
|||
448
lib/index.js
448
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.23",
|
||||
"version": "4.9.1-ops.30",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ const CHANNEL_LABELS = Object.freeze({
|
|||
});
|
||||
|
||||
/**
|
||||
* Session-header chip: channel peer for IM-bound conversations.
|
||||
* Renders nothing when the current session is not bound to a channel chat.
|
||||
* Session-header chip: channel peer for IM conversations (live or formerly bound).
|
||||
* Renders nothing when the session has no channel provenance.
|
||||
* @param {{
|
||||
* sessionId: string,
|
||||
* resolveChannelPeer?: (sessionId: string, signal?: AbortSignal) => Promise<object|null>,
|
||||
|
|
@ -67,7 +67,7 @@ export function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
|
|||
|
||||
/**
|
||||
* Resolve the channel peer for a session via WhatsApp (ops-first).
|
||||
* Returns null when the session is not bound to a WhatsApp conversation.
|
||||
* Works for live bindings and sessions unbound by `/new` (provenance retained).
|
||||
* @param {(endpoint: string, payload: object, signal?: AbortSignal) => Promise<unknown>} whatsappRpcCall
|
||||
* @param {string} sessionId
|
||||
* @param {AbortSignal} [signal]
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ function TargetForm({
|
|||
value: name,
|
||||
disabled: testing,
|
||||
maxLength: 80,
|
||||
placeholder: '例如 每日汇报群',
|
||||
placeholder: definition.label === 'WhatsApp' ? '例如 张三 · 13800138000' : '例如 每日汇报群',
|
||||
onChange: (event) => setName(event.target.value),
|
||||
})),
|
||||
h('label', { className: 'dim-targetField' },
|
||||
|
|
@ -386,13 +386,12 @@ function TargetForm({
|
|||
}
|
||||
|
||||
function suggestionOptionLabel(definition, suggestion, added) {
|
||||
const name = suggestionDisplayName(suggestion);
|
||||
const name = suggestionDisplayName(suggestion) || suggestionFallbackName(definition, suggestion);
|
||||
const type = localizeText(kindLabel(definition, suggestion.kind));
|
||||
const route = fieldsFor(definition, suggestion.kind)
|
||||
.map((field) => maskRouteValue(suggestion.route?.[field.key]))
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return [name, type, route, added ? localizeText('已添加') : null]
|
||||
const firstField = fieldsFor(definition, suggestion.kind)[0];
|
||||
const route = firstField ? String(suggestion.route?.[firstField.key] ?? '').trim() : '';
|
||||
const showRoute = route && !name.includes(route) && !name.includes(route.split('@')[0] || '');
|
||||
return [name, type, showRoute ? maskRouteValue(route) : null, added ? localizeText('已添加') : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
|
@ -409,12 +408,15 @@ function TargetSuggestionPicker({
|
|||
onCancel,
|
||||
}) {
|
||||
const configured = new Set(targets.map((target) => routeIdentity(definition, target)).filter(Boolean));
|
||||
const channelHint = definition.label === 'WhatsApp'
|
||||
? '会列出已聊过的私聊/群聊,以及访问名单里记住的联系人与群(显示为昵称 · 电话 / 群名)。'
|
||||
: '选择后会自动填写目标信息和调用别名,确认后再保存。';
|
||||
|
||||
return h('section', { className: 'dim-targetSuggestions', 'aria-label': '从已聊过的会话选择' },
|
||||
h('div', { className: 'dim-targetSuggestionHeading' },
|
||||
h('div', null,
|
||||
h('h3', null, '从已聊过的会话选择'),
|
||||
h('p', null, '选择后会自动填写目标信息和调用别名,确认后再保存。')),
|
||||
h('p', null, channelHint)),
|
||||
h(DeliveryButton, {
|
||||
onClick: () => void onRefresh(),
|
||||
disabled: phase === 'loading',
|
||||
|
|
@ -428,28 +430,33 @@ function TargetSuggestionPicker({
|
|||
: suggestions.length === 0
|
||||
? h('div', { className: 'dim-targetSuggestionState' },
|
||||
h('strong', null, '还没有可选择的会话'),
|
||||
h('p', null, '先在对应平台与机器人聊一条消息,再刷新。'))
|
||||
: h('label', { className: 'dim-targetSuggestionField' },
|
||||
h('span', null, '已聊会话'),
|
||||
h('select', {
|
||||
name: 'suggestion',
|
||||
value: '',
|
||||
onChange: (event) => {
|
||||
if (event.target.value === '') return;
|
||||
const suggestion = suggestions[Number(event.target.value)];
|
||||
if (suggestion) onSelect(suggestion);
|
||||
},
|
||||
},
|
||||
h('option', { value: '', disabled: true }, '从会话选择targetID'),
|
||||
h('p', null, '先在对应平台与机器人聊一条消息(私聊或群聊),或先在「访问设置」里出现过联系人/群,再刷新。'))
|
||||
: h('ul', { className: 'dim-targetSuggestionList', role: 'listbox', 'aria-label': '可选投递会话' },
|
||||
suggestions.map((suggestion, index) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
const added = configured.has(identity);
|
||||
return React.createElement('option', {
|
||||
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
|
||||
value: String(index),
|
||||
return h('li', { key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}` },
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: 'dim-targetSuggestionItem',
|
||||
role: 'option',
|
||||
'aria-disabled': added ? 'true' : undefined,
|
||||
disabled: added,
|
||||
}, suggestionOptionLabel(definition, suggestion, added));
|
||||
}))),
|
||||
onClick: () => { if (!added) onSelect(suggestion); },
|
||||
},
|
||||
// Platform display names must stay raw (bypass h() i18n phrase rewrite).
|
||||
React.createElement('strong', null,
|
||||
suggestionDisplayName(suggestion)
|
||||
|| suggestionFallbackName(definition, suggestion)),
|
||||
h('span', null, [
|
||||
localizeText(kindLabel(definition, suggestion.kind)),
|
||||
added ? localizeText('已添加') : null,
|
||||
].filter(Boolean).join(' · ')),
|
||||
React.createElement('code', null, fieldsFor(definition, suggestion.kind)
|
||||
.map((field) => maskRouteValue(suggestion.route?.[field.key]))
|
||||
.filter(Boolean)
|
||||
.join(' · '))));
|
||||
})),
|
||||
h('div', { className: 'dim-targetFormActions' },
|
||||
h(DeliveryButton, { onClick: onCancel }, '取消'),
|
||||
h(DeliveryButton, { onClick: onManual }, '手动填写(高级)')));
|
||||
|
|
|
|||
|
|
@ -903,12 +903,16 @@ const EN = Object.freeze({
|
|||
'已聊会话': 'Conversation',
|
||||
'从会话选择targetID': 'Select a targetID from conversations',
|
||||
'选择后会自动填写目标信息和调用别名,确认后再保存。': 'Selecting a conversation fills the target details and call alias. Review them before saving.',
|
||||
'会列出已聊过的私聊/群聊,以及访问名单里记住的联系人与群(显示为昵称 · 电话 / 群名)。': 'Lists known DMs and groups, plus remembered access-list contacts and groups (shown as nickname · phone / group name).',
|
||||
'可选投递会话': 'Available delivery conversations',
|
||||
'例如 张三 · 13800138000': 'For example, Alice · 13800138000',
|
||||
'正在刷新…': 'Refreshing…',
|
||||
'刷新': 'Refresh',
|
||||
'正在读取已聊会话…': 'Loading known conversations…',
|
||||
'无法读取已聊会话,请稍后重试。': 'Could not load known conversations. Try again later.',
|
||||
'还没有可选择的会话': 'No conversations to choose from',
|
||||
'先在对应平台与机器人聊一条消息,再刷新。': 'Send the bot a message on the platform, then refresh.',
|
||||
'先在对应平台与机器人聊一条消息(私聊或群聊),或先在「访问设置」里出现过联系人/群,再刷新。': 'Chat with the bot first (DM or group), or wait until contacts/groups appear under Access settings, then refresh.',
|
||||
'已添加': 'Added',
|
||||
'手动填写(高级)': 'Enter manually (advanced)',
|
||||
'测试消息已发送,请到目标会话确认。': 'Test message sent. Confirm it in the target conversation.',
|
||||
|
|
|
|||
|
|
@ -425,6 +425,13 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
|
|||
.dim-targetSuggestionField { min-width: 0; display: grid; gap: 5px; margin-top: 12px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
||||
.dim-targetSuggestionField select { width: 100%; min-width: 0; height: 38px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.dim-targetSuggestionField select:focus { outline: 2px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 28%, transparent); border-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-targetSuggestionList { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; max-height: 280px; overflow: auto; }
|
||||
.dim-targetSuggestionItem { width: 100%; box-sizing: border-box; display: grid; gap: 2px; text-align: left; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #fff); color: inherit; font: inherit; cursor: pointer; }
|
||||
.dim-targetSuggestionItem:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-targetSuggestionItem:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.dim-targetSuggestionItem strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; line-height: 20px; font-weight: 600; }
|
||||
.dim-targetSuggestionItem span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
||||
.dim-targetSuggestionItem code { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 16px; word-break: break-all; }
|
||||
.dim-targetForm { margin-top: 14px; padding: 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-targetFormHeading h3 { margin: 0; font-size: 14px; line-height: 21px; }
|
||||
.dim-targetFormHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { deliverySuggestionsFromSessions } from './delivery-suggestions.mjs';
|
||||
import {
|
||||
deliverySuggestionsFromSessions,
|
||||
enrichWhatsappDeliverySuggestions,
|
||||
} from './delivery-suggestions.mjs';
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'weixin',
|
||||
|
|
@ -145,12 +148,25 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|||
channel,
|
||||
ownsBot: (botId) => workspaces.has(botId),
|
||||
listTargets: (botId) => workspaces.listDeliveryTargets(botId),
|
||||
listAllTargets: () => {
|
||||
if (typeof workspaces.listAllDeliveryTargets !== 'function') return [];
|
||||
return workspaces.listAllDeliveryTargets().map((row) => ({
|
||||
...row,
|
||||
channel,
|
||||
}));
|
||||
},
|
||||
async listSuggestions(botId) {
|
||||
const state = await stateFor(botId);
|
||||
if (!state || typeof state.snapshot !== 'function') {
|
||||
throw new TypeError('delivery suggestion state cannot be inspected');
|
||||
}
|
||||
const suggestions = deliverySuggestionsFromSessions(channel, state.snapshot()?.sessions);
|
||||
let suggestions = deliverySuggestionsFromSessions(channel, state.snapshot()?.sessions);
|
||||
if (channel === 'whatsapp' && typeof workspaces.accessGrantFor === 'function') {
|
||||
suggestions = enrichWhatsappDeliverySuggestions(
|
||||
suggestions,
|
||||
workspaces.accessGrantFor(botId),
|
||||
);
|
||||
}
|
||||
return suggestions.map((suggestion) => normalizeDeliveryTarget(
|
||||
channel,
|
||||
suggestion,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,46 @@ export class DeliveryService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List every saved delivery target across registered channel adapters.
|
||||
* @returns {Promise<Array<{ botId: string, targetId: string, name: string, kind: string, channel: string }>>}
|
||||
*/
|
||||
async listCatalog() {
|
||||
const rows = [];
|
||||
const seen = new Set();
|
||||
for (const { adapter } of this.#adapters.values()) {
|
||||
if (typeof adapter.listAllTargets !== 'function') continue;
|
||||
let list;
|
||||
try {
|
||||
list = await adapter.listAllTargets();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const row of list) {
|
||||
const botId = typeof row?.botId === 'string' ? row.botId.trim() : '';
|
||||
const targetId = typeof row?.targetId === 'string' ? row.targetId.trim() : '';
|
||||
if (!botId || !targetId) continue;
|
||||
const key = `${botId}\0${targetId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push({
|
||||
botId,
|
||||
targetId,
|
||||
name: typeof row.name === 'string' && row.name.trim() ? row.name.trim() : targetId,
|
||||
kind: typeof row.kind === 'string' ? row.kind : '',
|
||||
channel: typeof row.channel === 'string' ? row.channel : adapter.channel,
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.sort((left, right) => (
|
||||
left.channel.localeCompare(right.channel)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.targetId.localeCompare(right.targetId)
|
||||
));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listSuggestions(botId) {
|
||||
const id = botIdOf(botId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
|
|
@ -173,7 +213,7 @@ export class DeliveryService {
|
|||
}
|
||||
}
|
||||
|
||||
async send(botId, targetIdOrDraft, text, { signal } = {}) {
|
||||
async send(botId, targetIdOrDraft, text, { signal, mentions } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = typeof targetIdOrDraft === 'string'
|
||||
? targetIdOf(targetIdOrDraft)
|
||||
|
|
@ -195,7 +235,13 @@ export class DeliveryService {
|
|||
if (!target) throw deliveryError('unknown-target', 'Unknown target');
|
||||
}
|
||||
cancellation(signal);
|
||||
await adapter.sendText(id, target, text, { signal });
|
||||
const mentionList = Array.isArray(mentions)
|
||||
? mentions.filter((jid) => typeof jid === 'string' && jid.includes('@'))
|
||||
: [];
|
||||
await adapter.sendText(id, target, text, {
|
||||
signal,
|
||||
...mentionList.length > 0 ? { mentions: mentionList } : {},
|
||||
});
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
||||
|
|
|
|||
|
|
@ -133,3 +133,136 @@ export function deliverySuggestionsFromSessions(channel, sessions) {
|
|||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
function clipName(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
const text = value.trim().replace(/\s+/g, ' ');
|
||||
return text ? text.slice(0, 80) : '';
|
||||
}
|
||||
|
||||
/** Prefer「昵称 · 电话」so ops pickers can tell people apart. */
|
||||
export function whatsappUserDisplayName(pushName, phone) {
|
||||
const nick = clipName(pushName);
|
||||
const number = typeof phone === 'string' ? phone.trim() : '';
|
||||
if (nick && number) return clipName(`${nick} · ${number}`);
|
||||
if (nick) return nick;
|
||||
if (number) return number;
|
||||
return '';
|
||||
}
|
||||
|
||||
function whatsappGroupDisplayName(title, jid) {
|
||||
const name = clipName(title);
|
||||
if (name) return name;
|
||||
const raw = typeof jid === 'string' ? jid.trim() : '';
|
||||
if (!raw) return '';
|
||||
const local = raw.includes('@') ? raw.slice(0, raw.indexOf('@')) : raw;
|
||||
if (local.length <= 10) return clipName(`群 · ${local}`);
|
||||
return clipName(`群 · ${local.slice(0, 6)}…${local.slice(-4)}`);
|
||||
}
|
||||
|
||||
function suggestionKey(suggestion) {
|
||||
return `${suggestion?.kind ?? ''}\0${suggestion?.route?.jid ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge WhatsApp session routes with access-grant contacts/groups so the
|
||||
* delivery picker shows nickname+phone and known groups, not only opaque JIDs.
|
||||
*
|
||||
* @param {Array<{ kind: string, route: { jid: string }, name?: string }>} sessionSuggestions
|
||||
* @param {object|null|undefined} grant - WhatsApp accessGrant document
|
||||
* @returns {Array<{ kind: string, route: { jid: string }, name?: string }>}
|
||||
*/
|
||||
export function enrichWhatsappDeliverySuggestions(sessionSuggestions, grant) {
|
||||
const byKey = new Map();
|
||||
const lidToPhone = new Map();
|
||||
const contacts = Array.isArray(grant?.contacts) ? grant.contacts : [];
|
||||
for (const contact of contacts) {
|
||||
const phone = typeof contact?.phone === 'string' ? contact.phone.trim() : '';
|
||||
if (!phone) continue;
|
||||
for (const lid of Array.isArray(contact.lids) ? contact.lids : []) {
|
||||
const id = typeof lid === 'string' ? lid.trim() : '';
|
||||
if (id) lidToPhone.set(id, phone);
|
||||
}
|
||||
}
|
||||
|
||||
const put = (suggestion) => {
|
||||
if (!suggestion?.kind || !suggestion?.route?.jid) return;
|
||||
const key = suggestionKey(suggestion);
|
||||
const previous = byKey.get(key);
|
||||
if (!previous) {
|
||||
byKey.set(key, suggestion);
|
||||
return;
|
||||
}
|
||||
if (!previous.name && suggestion.name) byKey.set(key, { ...previous, name: suggestion.name });
|
||||
else if (previous.name && suggestion.name && suggestion.name.length > previous.name.length) {
|
||||
byKey.set(key, { ...previous, name: suggestion.name });
|
||||
}
|
||||
};
|
||||
|
||||
for (const raw of Array.isArray(sessionSuggestions) ? sessionSuggestions : []) {
|
||||
if (raw?.kind === 'user' && typeof raw?.route?.jid === 'string') {
|
||||
const jid = raw.route.jid;
|
||||
const lidMatch = /^(\d{5,32})@lid$/.exec(jid);
|
||||
if (lidMatch) {
|
||||
const phone = lidToPhone.get(lidMatch[1]);
|
||||
if (phone) {
|
||||
put({
|
||||
kind: 'user',
|
||||
route: { jid: `${phone}@s.whatsapp.net` },
|
||||
...(raw.name ? { name: clipName(raw.name) } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
put({
|
||||
kind: raw.kind,
|
||||
route: { jid: raw.route.jid },
|
||||
...(raw.name ? { name: clipName(raw.name) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const contact of contacts) {
|
||||
const phone = typeof contact?.phone === 'string' ? contact.phone.trim() : '';
|
||||
const lids = Array.isArray(contact?.lids)
|
||||
? contact.lids.map((lid) => String(lid ?? '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const name = whatsappUserDisplayName(contact?.pushName, phone);
|
||||
if (phone) {
|
||||
put({
|
||||
kind: 'user',
|
||||
route: { jid: `${phone}@s.whatsapp.net` },
|
||||
...(name ? { name } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const lid of lids.slice(0, 1)) {
|
||||
put({
|
||||
kind: 'user',
|
||||
route: { jid: `${lid}@lid` },
|
||||
...(name ? { name } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const groups = grant?.groups && typeof grant.groups === 'object' && !Array.isArray(grant.groups)
|
||||
? grant.groups
|
||||
: {};
|
||||
for (const [groupJid, entry] of Object.entries(groups)) {
|
||||
if (typeof groupJid !== 'string' || !/^\d{5,32}(?:-\d{1,32})?@g\.us$/.test(groupJid)) continue;
|
||||
const name = whatsappGroupDisplayName(entry?.title, groupJid);
|
||||
put({
|
||||
kind: 'group',
|
||||
route: { jid: groupJid },
|
||||
...(name ? { name } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const rows = [...byKey.values()];
|
||||
rows.sort((left, right) => (
|
||||
String(left.kind).localeCompare(String(right.kind))
|
||||
|| String(left.name || '').localeCompare(String(right.name || ''), 'zh')
|
||||
|| String(left.route.jid).localeCompare(String(right.route.jid))
|
||||
));
|
||||
return rows;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ export function createImHostPlugin(internals = {}) {
|
|||
deliveryService.send(botId, targetId, text, options)
|
||||
),
|
||||
listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
|
||||
listDeliveryCatalog: async () => deliveryService.listCatalog(),
|
||||
createTarget: (botId, target) => deliveryService.createTarget(botId, target),
|
||||
/**
|
||||
* Resolve the IM channel peer for a Harness session (botId + conversationKey).
|
||||
* Used by ops schedulers to default proactive delivery back to the caller.
|
||||
|
|
|
|||
|
|
@ -422,6 +422,30 @@ export class BotWorkspaceStore {
|
|||
.sort((left, right) => left.targetId.localeCompare(right.targetId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat catalog of every saved delivery target across bots still present
|
||||
* in this workspace store (used by ops schedulers for picker UIs).
|
||||
*/
|
||||
listAllDeliveryTargets() {
|
||||
const rows = [];
|
||||
for (const botId of Object.keys(this.#deliveryTargets)) {
|
||||
if (!this.has(botId)) continue;
|
||||
for (const target of this.listDeliveryTargets(botId)) {
|
||||
rows.push({
|
||||
botId,
|
||||
targetId: target.targetId,
|
||||
name: target.name || target.targetId,
|
||||
kind: target.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.sort((left, right) => (
|
||||
left.botId.localeCompare(right.botId)
|
||||
|| left.targetId.localeCompare(right.targetId)
|
||||
));
|
||||
return rows;
|
||||
}
|
||||
|
||||
deliveryTargetFor(botId, targetId) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
|
|
@ -1705,6 +1729,7 @@ export function createWorkspaceAwareController(controller, {
|
|||
botId,
|
||||
conversationKey,
|
||||
grant: workspaces.accessGrantFor(botId),
|
||||
botAgentPreset: workspaces.agentPresetFor(botId),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { normalizeAccessPhone, phoneFromWhatsappJid } from './access-grant.mjs';
|
||||
import { normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from './access-grant.mjs';
|
||||
|
||||
/**
|
||||
* Parse a durable conversation binding key into chat identity fields.
|
||||
|
|
@ -102,6 +102,7 @@ export function formatChannelPeerLabel(...parts) {
|
|||
* botId: string,
|
||||
* conversationKey: string,
|
||||
* grant?: object | null,
|
||||
* botAgentPreset?: string | null,
|
||||
* }} input
|
||||
* @returns {object | null}
|
||||
*/
|
||||
|
|
@ -114,12 +115,26 @@ export function resolveChannelPeerFromBinding(input) {
|
|||
const parsed = parseConversationKey(conversationKey);
|
||||
if (!parsed) return null;
|
||||
const grant = input.grant && typeof input.grant === 'object' ? input.grant : null;
|
||||
const botAgentPreset = typeof input.botAgentPreset === 'string' ? input.botAgentPreset.trim() : '';
|
||||
|
||||
const withPreset = (peer) => {
|
||||
const fromGrant = resolveAccessAgentPreset(grant, {
|
||||
kind: peer.kind === 'group' ? 'group' : 'direct',
|
||||
groupJid: peer.kind === 'group' ? peer.conversationId : undefined,
|
||||
conversationId: peer.conversationId,
|
||||
phone: peer.phone,
|
||||
senderId: peer.senderId || peer.phone,
|
||||
});
|
||||
const agentPreset = fromGrant || botAgentPreset || null;
|
||||
return Object.freeze({ ...peer, agentPreset });
|
||||
};
|
||||
|
||||
if (parsed.kind === 'direct') {
|
||||
const { phone, pushName } = resolveContactIdentity(grant, parsed.conversationId);
|
||||
const label = formatChannelPeerLabel(pushName, phone);
|
||||
if (!label) return null;
|
||||
return Object.freeze({
|
||||
// Ops schedulers need a peer even when the contact directory is incomplete;
|
||||
// keep the label human-facing (never raw LID / JID).
|
||||
const label = formatChannelPeerLabel(pushName, phone) || 'WhatsApp 私聊';
|
||||
return withPreset({
|
||||
channel,
|
||||
botId,
|
||||
kind: 'direct',
|
||||
|
|
@ -135,7 +150,7 @@ export function resolveChannelPeerFromBinding(input) {
|
|||
|
||||
const title = groupTitle(grant, parsed.conversationId) || '未命名群';
|
||||
if (!parsed.senderId) {
|
||||
return Object.freeze({
|
||||
return withPreset({
|
||||
channel,
|
||||
botId,
|
||||
kind: 'group',
|
||||
|
|
@ -151,7 +166,7 @@ export function resolveChannelPeerFromBinding(input) {
|
|||
|
||||
const { phone, pushName } = resolveContactIdentity(grant, parsed.senderId);
|
||||
const label = formatChannelPeerLabel(title, pushName, phone) || title;
|
||||
return Object.freeze({
|
||||
return withPreset({
|
||||
channel,
|
||||
botId,
|
||||
kind: 'group',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,56 @@
|
|||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null });
|
||||
const EMPTY_STATE = Object.freeze({
|
||||
version: 1,
|
||||
sessions: {},
|
||||
sessionOrigins: {},
|
||||
seenMessageIds: [],
|
||||
cursor: null,
|
||||
});
|
||||
|
||||
/** Cap unbound+bound provenance so bot state files stay bounded. */
|
||||
const MAX_SESSION_ORIGINS = 2_000;
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
function normalizeOrigins(value) {
|
||||
const origins = Object.create(null);
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return origins;
|
||||
for (const [sessionId, conversationKey] of Object.entries(value)) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) continue;
|
||||
if (typeof conversationKey !== 'string' || !conversationKey) continue;
|
||||
origins[sessionId] = conversationKey;
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed provenance from live bindings so older state files gain reverse maps.
|
||||
* @param {Record<string, string>} sessions
|
||||
* @param {Record<string, string>} origins
|
||||
*/
|
||||
function mergeLiveIntoOrigins(sessions, origins) {
|
||||
for (const [conversationKey, sessionId] of Object.entries(sessions)) {
|
||||
if (typeof conversationKey === 'string' && conversationKey
|
||||
&& typeof sessionId === 'string' && sessionId) {
|
||||
origins[sessionId] = conversationKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop oldest provenance entries when over the cap (insertion order).
|
||||
* @param {Record<string, string>} origins
|
||||
*/
|
||||
function pruneOrigins(origins) {
|
||||
const ids = Object.keys(origins);
|
||||
if (ids.length <= MAX_SESSION_ORIGINS) return;
|
||||
const drop = ids.length - MAX_SESSION_ORIGINS;
|
||||
for (let i = 0; i < drop; i += 1) delete origins[ids[i]];
|
||||
}
|
||||
|
||||
function normalizeState(value) {
|
||||
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
||||
|
|
@ -13,9 +62,13 @@ function normalizeState(value) {
|
|||
}
|
||||
}
|
||||
}
|
||||
const sessionOrigins = normalizeOrigins(value.sessionOrigins);
|
||||
mergeLiveIntoOrigins(sessions, sessionOrigins);
|
||||
pruneOrigins(sessionOrigins);
|
||||
return {
|
||||
version: 1,
|
||||
sessions,
|
||||
sessionOrigins,
|
||||
seenMessageIds: Array.isArray(value.seenMessageIds)
|
||||
? value.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-1_000)
|
||||
: [],
|
||||
|
|
@ -49,6 +102,8 @@ export class ConversationStateStore {
|
|||
|
||||
/**
|
||||
* Reverse-map a Harness session id to its conversation binding key.
|
||||
* Live bindings win; unbound sessions still resolve via sessionOrigins
|
||||
* so DSH can show channel provenance after `/new`.
|
||||
* @param {string} sessionId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
|
|
@ -57,20 +112,40 @@ export class ConversationStateStore {
|
|||
for (const [key, bound] of Object.entries(this.#state.sessions)) {
|
||||
if (bound === sessionId) return key;
|
||||
}
|
||||
return null;
|
||||
const former = this.#state.sessionOrigins?.[sessionId];
|
||||
return typeof former === 'string' && former ? former : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this session id is the live binding for its conversation key.
|
||||
* @param {string} sessionId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLiveSession(sessionId) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) return false;
|
||||
for (const bound of Object.values(this.#state.sessions)) {
|
||||
if (bound === sessionId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async setSession(key, sessionId) {
|
||||
this.#state.sessions[key] = sessionId;
|
||||
if (typeof sessionId === 'string' && sessionId) {
|
||||
this.#state.sessionOrigins[sessionId] = key;
|
||||
pruneOrigins(this.#state.sessionOrigins);
|
||||
}
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
async clearSession(key) {
|
||||
// Keep sessionOrigins so unbound Harness sessions still show channel peer.
|
||||
delete this.#state.sessions[key];
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
async clearSessions() {
|
||||
// Workspace switches drop live chat→session maps only; provenance stays.
|
||||
this.#state.sessions = {};
|
||||
await this.#persist();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -461,9 +461,12 @@ export class WhatsappBotClient {
|
|||
this.#mediaUploadTimeoutMs = mediaUploadTimeoutMs;
|
||||
}
|
||||
|
||||
async sendText(target, text) {
|
||||
async sendText(target, text, { mentions } = {}) {
|
||||
await this.#stopTyping(target.jid);
|
||||
const providerMessageIds = [];
|
||||
const mentionList = Array.isArray(mentions)
|
||||
? mentions.filter((jid) => typeof jid === 'string' && jid.includes('@'))
|
||||
: [];
|
||||
for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
|
||||
const messageId = randomBytes(10).toString('hex').toUpperCase();
|
||||
const options = {
|
||||
|
|
@ -478,9 +481,13 @@ export class WhatsappBotClient {
|
|||
} else {
|
||||
this.#outboundIds.remember(messageId);
|
||||
}
|
||||
const content = {
|
||||
text: chunk,
|
||||
...(index === 0 && mentionList.length > 0 ? { mentions: mentionList } : {}),
|
||||
};
|
||||
const result = await this.#socket.sendMessage(
|
||||
target.jid,
|
||||
{ text: chunk },
|
||||
content,
|
||||
options,
|
||||
);
|
||||
this.#outboundIds.remember(result?.key?.id);
|
||||
|
|
@ -915,7 +922,7 @@ export class WhatsappRuntime {
|
|||
return grant;
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
async sendProactiveText(target, text, { signal, mentions } = {}) {
|
||||
const jid = typeof target?.route?.jid === 'string' ? target.route.jid.trim() : '';
|
||||
const validUser = target?.kind === 'user'
|
||||
&& /^[^@\s]+@(s\.whatsapp\.net|lid)$/.test(jid);
|
||||
|
|
@ -931,7 +938,14 @@ export class WhatsappRuntime {
|
|||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return this.#client.sendText({ jid }, text);
|
||||
const mentionList = Array.isArray(mentions)
|
||||
? mentions.filter((entry) => typeof entry === 'string' && entry.includes('@'))
|
||||
: [];
|
||||
return this.#client.sendText(
|
||||
{ jid },
|
||||
text,
|
||||
mentionList.length > 0 ? { mentions: mentionList } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,55 @@ test('resolveChannelPeerFromBinding builds DM and group labels from access grant
|
|||
assert.equal(groupShared.label, 'Ops Room');
|
||||
});
|
||||
|
||||
test('resolveChannelPeerFromBinding exposes agentPreset from grant or bot default', () => {
|
||||
const grant = {
|
||||
version: 1,
|
||||
globalAdmins: ['8618111111111'],
|
||||
directMembers: [
|
||||
{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'ops-dm' },
|
||||
],
|
||||
groups: {
|
||||
'120363@g.us': {
|
||||
title: 'Ops Room',
|
||||
agentPreset: 'ops-group',
|
||||
admins: [],
|
||||
members: [],
|
||||
},
|
||||
},
|
||||
contacts: [{
|
||||
phone: '8618222222222',
|
||||
lids: ['91010910658657@lid'],
|
||||
pushName: 'Alice',
|
||||
}],
|
||||
};
|
||||
const direct = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
botId: 'bot-1',
|
||||
conversationKey: 'direct:8618222222222@s.whatsapp.net',
|
||||
grant,
|
||||
botAgentPreset: 'bot-default',
|
||||
});
|
||||
assert.equal(direct.agentPreset, 'ops-dm');
|
||||
|
||||
const group = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
botId: 'bot-1',
|
||||
conversationKey: 'group:120363@g.us',
|
||||
grant,
|
||||
botAgentPreset: 'bot-default',
|
||||
});
|
||||
assert.equal(group.agentPreset, 'ops-group');
|
||||
|
||||
const fallback = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
botId: 'bot-1',
|
||||
conversationKey: 'direct:8618999999999@s.whatsapp.net',
|
||||
grant,
|
||||
botAgentPreset: 'bot-default',
|
||||
});
|
||||
assert.equal(fallback.agentPreset, 'bot-default');
|
||||
});
|
||||
|
||||
test('resolveChannelPeerFromBinding never falls back to raw LID when contact is incomplete', () => {
|
||||
const grant = {
|
||||
contacts: [{ lids: ['91010910658657@lid'], pushName: 'Bob' }],
|
||||
|
|
@ -105,7 +154,9 @@ test('resolveChannelPeerFromBinding never falls back to raw LID when contact is
|
|||
conversationKey: 'direct:111222333444@lid',
|
||||
grant,
|
||||
});
|
||||
assert.equal(unknown, null);
|
||||
assert.equal(unknown?.kind, 'direct');
|
||||
assert.equal(unknown?.label, 'WhatsApp 私聊');
|
||||
assert.doesNotMatch(unknown.label, /@lid|111222333444/);
|
||||
|
||||
const groupOnlyLid = resolveChannelPeerFromBinding({
|
||||
channel: 'whatsapp',
|
||||
|
|
@ -143,3 +194,31 @@ test('ConversationStateStore.keyForSession reverses session bindings', async ()
|
|||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ConversationStateStore keeps channel provenance after /new-style clearSession', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-session-origin-'));
|
||||
try {
|
||||
const path = join(root, 'state.json');
|
||||
const store = await new ConversationStateStore(path).load();
|
||||
await store.setSession('direct:8618222222222@s.whatsapp.net', 'session-old');
|
||||
assert.equal(store.isLiveSession('session-old'), true);
|
||||
|
||||
await store.clearSession('direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(store.sessionFor('direct:8618222222222@s.whatsapp.net'), null);
|
||||
assert.equal(store.isLiveSession('session-old'), false);
|
||||
// Unbound Harness session must still resolve for DSH channel chip / cron peer.
|
||||
assert.equal(store.keyForSession('session-old'), 'direct:8618222222222@s.whatsapp.net');
|
||||
|
||||
await store.setSession('direct:8618222222222@s.whatsapp.net', 'session-new');
|
||||
assert.equal(store.keyForSession('session-new'), 'direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(store.keyForSession('session-old'), 'direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(store.isLiveSession('session-new'), true);
|
||||
assert.equal(store.isLiveSession('session-old'), false);
|
||||
|
||||
const reloaded = await new ConversationStateStore(path).load();
|
||||
assert.equal(reloaded.keyForSession('session-old'), 'direct:8618222222222@s.whatsapp.net');
|
||||
assert.equal(reloaded.keyForSession('session-new'), 'direct:8618222222222@s.whatsapp.net');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -632,16 +632,15 @@ test('new target defaults to recent conversations and selection creates an edita
|
|||
payload: { botId: 'bot_feishu_01' },
|
||||
});
|
||||
const picker = renderer.root.findByProps({ 'aria-label': '从已聊过的会话选择' });
|
||||
const suggestionSelect = picker.findByProps({ name: 'suggestion' });
|
||||
const options = suggestionSelect.findAllByType('option');
|
||||
assert.equal(options.length, 4);
|
||||
const existing = options.find((option) => textOf(option).includes('ou_ex'));
|
||||
const suggestionItems = picker.findAllByProps({ className: 'dim-targetSuggestionItem' });
|
||||
assert.equal(suggestionItems.length, 3);
|
||||
const existing = suggestionItems.find((item) => textOf(item).includes('ou_ex'));
|
||||
assert.equal(existing.props.disabled, true);
|
||||
assert.match(textOf(existing), /已添加/);
|
||||
assert.doesNotMatch(textOf(picker), /ou_existing_secret_123456|ou_new_secret_123456|oc_group_secret_123456/);
|
||||
|
||||
const groupIndex = options.find((option) => textOf(option).includes('oc_gr')).props.value;
|
||||
await act(async () => { suggestionSelect.props.onChange({ target: { value: groupIndex } }); });
|
||||
const groupItem = suggestionItems.find((item) => textOf(item).includes('oc_gr') && !item.props.disabled);
|
||||
await act(async () => { groupItem.props.onClick(); });
|
||||
let form = renderer.root.findByType('form');
|
||||
const groupTargetId = form.findByProps({ name: 'targetId' }).props.value;
|
||||
assert.match(groupTargetId, /^tgt_[0-9a-f]{16}$/);
|
||||
|
|
@ -673,10 +672,9 @@ test('new target defaults to recent conversations and selection creates an edita
|
|||
assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/);
|
||||
|
||||
await act(async () => { button(renderer.root, '取消').props.onClick(); });
|
||||
const nextSelect = renderer.root.findByProps({ name: 'suggestion' });
|
||||
const userIndex = nextSelect.findAllByType('option')
|
||||
.find((option) => textOf(option).includes('ou_ne')).props.value;
|
||||
await act(async () => { nextSelect.props.onChange({ target: { value: userIndex } }); });
|
||||
const nextItems = renderer.root.findAllByProps({ className: 'dim-targetSuggestionItem' });
|
||||
const userItem = nextItems.find((item) => textOf(item).includes('ou_ne') && !item.props.disabled);
|
||||
await act(async () => { userItem.props.onClick(); });
|
||||
form = renderer.root.findByType('form');
|
||||
const userTargetId = form.findByProps({ name: 'targetId' }).props.value;
|
||||
assert.match(userTargetId, /^tgt_[0-9a-f]{16}$/);
|
||||
|
|
@ -727,7 +725,7 @@ test('recent conversation picker explains the empty state and refreshes on deman
|
|||
});
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
|
||||
/先在对应平台与机器人聊一条消息,再刷新。/,
|
||||
/先在对应平台与机器人聊一条消息/,
|
||||
);
|
||||
assert.ok(button(renderer.root, '手动填写(高级)'));
|
||||
await act(async () => {
|
||||
|
|
@ -762,11 +760,11 @@ test('recent conversation names remain platform data in the English UI', async (
|
|||
button(renderer.root, 'New target').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
const select = renderer.root.findByProps({ name: 'suggestion' });
|
||||
assert.match(textOf(select), /飞书项目群/);
|
||||
assert.doesNotMatch(textOf(select), /Feishu项目群/);
|
||||
const picker = renderer.root.findByProps({ className: 'dim-targetSuggestions' });
|
||||
assert.match(textOf(picker), /飞书项目群/);
|
||||
assert.doesNotMatch(textOf(picker), /Feishu项目群/);
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
|
||||
textOf(picker),
|
||||
/Choose from conversations/,
|
||||
);
|
||||
const docsLink = renderer.root.findByProps({ className: 'dim-deliveryDocsLink' });
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
createDeliveryAdapter,
|
||||
normalizeDeliveryTarget,
|
||||
} from '../plugin-src/host/delivery-adapter.mjs';
|
||||
import { deliverySuggestionsFromSessions } from '../plugin-src/host/delivery-suggestions.mjs';
|
||||
import { deliverySuggestionsFromSessions, enrichWhatsappDeliverySuggestions } from '../plugin-src/host/delivery-suggestions.mjs';
|
||||
|
||||
const TARGETS = {
|
||||
weixin: { targetId: 'daily', kind: 'user', route: { toUserId: 'wx-user' } },
|
||||
|
|
@ -206,6 +206,57 @@ test('delivery adapter delegates target CRUD to the existing workspace store', a
|
|||
]);
|
||||
});
|
||||
|
||||
test('whatsapp suggestions prefer nickname+phone and include access-grant groups', () => {
|
||||
const sessions = {
|
||||
'direct:910123456789012@lid': 'session-lid-only',
|
||||
'direct:8613800000001@s.whatsapp.net': 'session-phone',
|
||||
'group:120363000000000001@g.us': 'session-group',
|
||||
};
|
||||
const fromSessions = deliverySuggestionsFromSessions('whatsapp', sessions);
|
||||
const enriched = enrichWhatsappDeliverySuggestions(fromSessions, {
|
||||
contacts: [
|
||||
{ phone: '8613800000001', lids: ['910123456789012'], pushName: '张三' },
|
||||
{ phone: '8613800000002', lids: [], pushName: '李四' },
|
||||
{ lids: ['999888777666555'], pushName: '只有LID' },
|
||||
],
|
||||
groups: {
|
||||
'120363000000000001@g.us': { title: '运维告警群', admins: [], members: [] },
|
||||
'120363000000000099@g.us': { title: '未聊过的群', admins: [], members: [] },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(enriched, [
|
||||
{
|
||||
kind: 'group',
|
||||
route: { jid: '120363000000000099@g.us' },
|
||||
name: '未聊过的群',
|
||||
},
|
||||
{
|
||||
kind: 'group',
|
||||
route: { jid: '120363000000000001@g.us' },
|
||||
name: '运维告警群',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
route: { jid: '8613800000002@s.whatsapp.net' },
|
||||
name: '李四 · 8613800000002',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
route: { jid: '8613800000001@s.whatsapp.net' },
|
||||
name: '张三 · 8613800000001',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
route: { jid: '999888777666555@lid' },
|
||||
name: '只有LID',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
enriched.some((row) => row.route.jid.endsWith('@lid') && row.route.jid.startsWith('910')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('suggestion parser strictly filters malformed keys, de-duplicates routes, and never leaks state values', () => {
|
||||
const sessions = {
|
||||
'direct:88': 'secret-session-one',
|
||||
|
|
|
|||
|
|
@ -96,6 +96,22 @@ test('DeliveryService shares target CRUD and sending through one adapter', async
|
|||
await assert.rejects(service.send('bot_one', 'daily-report', 'missing'), { code: 'unknown-target' });
|
||||
});
|
||||
|
||||
test('DeliveryService forwards WhatsApp mentions to the adapter', async () => {
|
||||
const service = createDeliveryService();
|
||||
const adapter = memoryAdapter({ channel: 'whatsapp' });
|
||||
service.registerAdapter(adapter);
|
||||
await service.createTarget('bot_one', {
|
||||
targetId: 'ops-group',
|
||||
name: 'Ops',
|
||||
kind: 'group',
|
||||
route: { jid: '120363@g.us' },
|
||||
});
|
||||
const mentions = ['8613800000000@s.whatsapp.net'];
|
||||
await service.send('bot_one', 'ops-group', '@8613800000000 hello', { mentions });
|
||||
assert.equal(adapter.sends.length, 1);
|
||||
assert.deepEqual(adapter.sends[0][3], { signal: undefined, mentions });
|
||||
});
|
||||
|
||||
test('DeliveryService validates public ids, text, cancellation, and unknown bots', async () => {
|
||||
const service = createDeliveryService();
|
||||
service.registerAdapter(memoryAdapter());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue