mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 09:03:17 +08:00
feat: add stable proactive delivery
This commit is contained in:
parent
410cb822d7
commit
31792d7bc8
88 changed files with 6427 additions and 488 deletions
|
|
@ -2,6 +2,50 @@ import * as React from 'react';
|
|||
|
||||
import { h, isEnglish } from './i18n.js';
|
||||
|
||||
export const BotSettingsContext = React.createContext(Object.freeze({
|
||||
openBotSettings() {},
|
||||
}));
|
||||
|
||||
function SettingsGlyph() {
|
||||
return h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
width: 16,
|
||||
height: 16,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
h('circle', { cx: 12, cy: 12, r: 3 }),
|
||||
h('path', { d: 'M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.55V21h-4v-.08A1.7 1.7 0 0 0 8.97 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.52-1.03H3v-4h.08A1.7 1.7 0 0 0 4.6 8.97a1.7 1.7 0 0 0-.34-1.88l-.06-.06L7.03 4.2l.06.06a1.7 1.7 0 0 0 1.88.34A1.7 1.7 0 0 0 10 3.08V3h4v.08a1.7 1.7 0 0 0 1.03 1.52 1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06a1.7 1.7 0 0 0-.34 1.88A1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15Z' }));
|
||||
}
|
||||
|
||||
export function BotSettingsButton({ channel, botId, botName, connected }) {
|
||||
const { openBotSettings } = React.useContext(BotSettingsContext);
|
||||
const tooltipId = React.useId();
|
||||
return h('span', { className: 'dim-botSettingsAction' },
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: 'dim-botSettingsButton',
|
||||
'data-delivery-channel': channel,
|
||||
'aria-label': '机器人设置',
|
||||
'aria-describedby': tooltipId,
|
||||
onClick: () => openBotSettings?.({
|
||||
channel,
|
||||
botId,
|
||||
botName,
|
||||
connected: Boolean(connected),
|
||||
}),
|
||||
}, h(SettingsGlyph)),
|
||||
h('span', {
|
||||
id: tooltipId,
|
||||
className: 'dim-botSettingsTooltip',
|
||||
role: 'tooltip',
|
||||
}, '机器人设置'));
|
||||
}
|
||||
|
||||
function messageErrorTime(value) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(isEnglish() ? 'en-US' : 'zh-CN', {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -238,14 +239,21 @@ export function AccountCard({
|
|||
h('div', { className: 'dim-botName' },
|
||||
h('h3', { title: account.bot.name }, account.bot.name),
|
||||
h('p', { title: account.bot.clientIdMasked }, account.bot.clientIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'dingtalk',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from "../../agent-preset.js";
|
||||
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -593,15 +594,22 @@ export function BotCard({
|
|||
h("h3", { id: titleId, title: bot.name }, bot.name),
|
||||
h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")),
|
||||
),
|
||||
h(BotStatusMeta, {
|
||||
className: "bxf-healthPill",
|
||||
dotClassName: "bxf-dot",
|
||||
tone,
|
||||
stateLabel: HEALTH_LABELS[stateForDisplay] ?? "状态未知",
|
||||
lastCheckedAt: health.lastCheckedAt,
|
||||
formatCheckedTime,
|
||||
healthState: stateForDisplay,
|
||||
}),
|
||||
h("div", { className: "dim-botCardTools" },
|
||||
h(BotStatusMeta, {
|
||||
className: "bxf-healthPill",
|
||||
dotClassName: "bxf-dot",
|
||||
tone,
|
||||
stateLabel: HEALTH_LABELS[stateForDisplay] ?? "状态未知",
|
||||
lastCheckedAt: health.lastCheckedAt,
|
||||
formatCheckedTime,
|
||||
healthState: stateForDisplay,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: "feishu",
|
||||
botId: connection.botId,
|
||||
botName: bot.name,
|
||||
connected,
|
||||
})),
|
||||
),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: connection.workspace,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -182,14 +183,21 @@ export function AccountCard({
|
|||
h('div', { className: 'ddt-avatar dim-botAvatar dqq-avatar', 'aria-hidden': 'true' }, h(QqLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'qq',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -88,14 +89,21 @@ export function createTokenChannelSettings(definition) {
|
|||
h(LogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, identity))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: channel.toLowerCase(),
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -181,14 +182,21 @@ export function AccountCard({
|
|||
h('div', { className: 'ddt-avatar dim-botAvatar dwecom-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'wecom',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -222,14 +223,21 @@ export function AccountCard({
|
|||
h('div', { className: 'dxw-accountIdentity dim-botIdentity' },
|
||||
h('div', { className: 'dxw-avatar dim-botAvatar', 'aria-hidden': 'true' }, h(WeixinLogoGlyph, { size: 27 })),
|
||||
h('div', { className: 'dim-botName' }, h('h3', null, account.bot.name), h('p', null, account.bot.accountIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'dxw-health',
|
||||
dotClassName: 'dxw-dot',
|
||||
tone,
|
||||
stateLabel: account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪',
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'dxw-health',
|
||||
dotClassName: 'dxw-dot',
|
||||
tone,
|
||||
stateLabel: account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪',
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'weixin',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -318,14 +319,21 @@ export function WhatsappAccountCard({
|
|||
}, h(WhatsappLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.idMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'whatsapp',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
749
plugin-src/client/delivery-settings.js
Normal file
749
plugin-src/client/delivery-settings.js
Normal file
|
|
@ -0,0 +1,749 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { h, isEnglish, localizeText } from './i18n.js';
|
||||
|
||||
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
||||
|
||||
const DELIVERY_DOCS_URL = Object.freeze({
|
||||
zh: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.md',
|
||||
en: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
|
||||
});
|
||||
|
||||
export const DELIVERY_ENDPOINTS = Object.freeze({
|
||||
list: 'target.list',
|
||||
listSuggestions: 'target.suggestion.list',
|
||||
create: 'target.create',
|
||||
update: 'target.update',
|
||||
delete: 'target.delete',
|
||||
test: 'target.test',
|
||||
});
|
||||
|
||||
const CHANNEL_DEFINITIONS = Object.freeze({
|
||||
weixin: {
|
||||
label: '微信',
|
||||
kinds: [{ value: 'user', label: '用户' }],
|
||||
fields: { user: [{ key: 'toUserId', label: '微信用户 ID', placeholder: '填写接收消息的用户 ID' }] },
|
||||
},
|
||||
feishu: {
|
||||
label: '飞书',
|
||||
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'openId', label: 'Open ID', placeholder: 'ou_xxx' }],
|
||||
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: 'oc_xxx' }],
|
||||
},
|
||||
},
|
||||
dingtalk: {
|
||||
label: '钉钉',
|
||||
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'userId', label: '用户 ID', placeholder: '填写钉钉用户 ID' }],
|
||||
group: [{ key: 'openConversationId', label: '群 Open Conversation ID', placeholder: 'cidxxx' }],
|
||||
},
|
||||
},
|
||||
wecom: {
|
||||
label: '企业微信',
|
||||
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'chatId', label: '用户 ID', placeholder: '填写企业微信用户 ID' }],
|
||||
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: '填写群 chatid' }],
|
||||
},
|
||||
},
|
||||
qq: {
|
||||
label: 'QQ',
|
||||
kinds: [{ value: 'user', label: '单聊' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'userOpenId', label: '用户 Open ID', placeholder: '填写 user_openid' }],
|
||||
group: [{ key: 'groupOpenId', label: '群 Open ID', placeholder: '填写 group_openid' }],
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
label: 'Slack',
|
||||
kinds: [{ value: 'conversation', label: '会话' }, { value: 'thread', label: '线程' }],
|
||||
fields: {
|
||||
conversation: [{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' }],
|
||||
thread: [
|
||||
{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' },
|
||||
{ key: 'threadTs', label: 'Thread 时间戳', placeholder: '1712345678.123456' },
|
||||
],
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
label: 'Telegram',
|
||||
kinds: [{ value: 'chat', label: '聊天' }, { value: 'topic', label: '话题' }],
|
||||
fields: {
|
||||
chat: [{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' }],
|
||||
topic: [
|
||||
{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' },
|
||||
{ key: 'messageThreadId', label: 'Topic ID', placeholder: '123', inputMode: 'numeric', integer: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
kinds: [{ value: 'channel', label: '频道或私信' }],
|
||||
fields: { channel: [{ key: 'channelId', label: 'Channel ID', placeholder: '填写可发消息的 Channel ID', inputMode: 'numeric' }] },
|
||||
},
|
||||
whatsapp: {
|
||||
label: 'WhatsApp',
|
||||
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'jid', label: '用户 JID', placeholder: '8613800000000@s.whatsapp.net' }],
|
||||
group: [{ key: 'jid', label: '群 JID', placeholder: '1234567890@g.us' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const DELIVERY_CHANNEL_DEFINITIONS = CHANNEL_DEFINITIONS;
|
||||
|
||||
function presentError(error, fallback) {
|
||||
return error?.message || fallback;
|
||||
}
|
||||
|
||||
function unwrapRpcResult(result) {
|
||||
if (result?.ok === true) return result.value;
|
||||
if (result?.ok === false) {
|
||||
const error = new Error(result.error?.message || '请求失败,请稍后重试。');
|
||||
error.code = result.error?.code;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function targetsFrom(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return Array.isArray(value?.targets) ? value.targets : [];
|
||||
}
|
||||
|
||||
function suggestionsFrom(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return Array.isArray(value?.suggestions) ? value.suggestions : [];
|
||||
}
|
||||
|
||||
function kindLabel(definition, kind) {
|
||||
return definition.kinds.find((entry) => entry.value === kind)?.label ?? kind;
|
||||
}
|
||||
|
||||
function fieldsFor(definition, kind) {
|
||||
return definition.fields[kind] ?? [];
|
||||
}
|
||||
|
||||
function cleanDisplayName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 80) : null;
|
||||
}
|
||||
|
||||
function suggestionDisplayName(suggestion) {
|
||||
return cleanDisplayName(
|
||||
suggestion?.name ?? suggestion?.displayName ?? suggestion?.label,
|
||||
);
|
||||
}
|
||||
|
||||
function routeIdentity(definition, candidate) {
|
||||
const fields = fieldsFor(definition, candidate?.kind);
|
||||
if (fields.length === 0 || !candidate?.route || typeof candidate.route !== 'object') return null;
|
||||
const values = [];
|
||||
for (const field of fields) {
|
||||
const value = candidate.route[field.key];
|
||||
if (value === undefined || value === null || String(value).trim() === '') return null;
|
||||
values.push(String(value).trim());
|
||||
}
|
||||
return JSON.stringify([candidate.kind, ...values]);
|
||||
}
|
||||
|
||||
function validSuggestions(definition, value) {
|
||||
const seen = new Set();
|
||||
return suggestionsFrom(value).filter((suggestion) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
if (!identity || seen.has(identity)) return false;
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function maskRouteValue(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
const at = text.indexOf('@');
|
||||
if (at > 0) {
|
||||
const local = text.slice(0, at);
|
||||
const domain = text.slice(at);
|
||||
const visible = local.length <= 4
|
||||
? `${local.slice(0, 1)}••${local.slice(-1)}`
|
||||
: `${local.slice(0, 3)}…${local.slice(-2)}`;
|
||||
return `${visible}${domain}`;
|
||||
}
|
||||
if (text.length <= 6) return `${text.slice(0, 2)}…${text.slice(-1)}`;
|
||||
if (text.length <= 12) return `${text.slice(0, 3)}…${text.slice(-3)}`;
|
||||
return `${text.slice(0, 5)}…${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
function suggestionFallbackName(definition, suggestion) {
|
||||
const displayName = suggestionDisplayName(suggestion);
|
||||
if (displayName) return displayName;
|
||||
const firstField = fieldsFor(definition, suggestion.kind)[0];
|
||||
const route = firstField ? maskRouteValue(suggestion.route?.[firstField.key]) : '';
|
||||
return `${localizeText(kindLabel(definition, suggestion.kind))}${route ? ` · ${route}` : ''}`;
|
||||
}
|
||||
|
||||
function randomTargetId(targets) {
|
||||
const used = new Set(targets.map((target) => target?.targetId));
|
||||
for (let attempt = 0; attempt < 32; attempt += 1) {
|
||||
const bytes = new Uint8Array(8);
|
||||
const crypto = globalThis.crypto;
|
||||
if (typeof crypto?.getRandomValues === 'function') {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
const suffix = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
||||
const targetId = `tgt_${suffix}`;
|
||||
if (!used.has(targetId)) return targetId;
|
||||
}
|
||||
throw new Error('无法生成未占用的 Target ID,请重试。');
|
||||
}
|
||||
|
||||
function DeliveryButton({ children, kind = 'secondary', className = '', ...props }) {
|
||||
return h('button', {
|
||||
...props,
|
||||
type: props.type ?? 'button',
|
||||
className: `dim-deliveryButton ${className}`.trim(),
|
||||
'data-kind': kind,
|
||||
}, children);
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
const clipboard = globalThis.navigator?.clipboard;
|
||||
if (typeof clipboard?.writeText !== 'function') {
|
||||
throw new Error('当前浏览器不支持自动复制,请手动选择复制。');
|
||||
}
|
||||
await clipboard.writeText(value);
|
||||
}
|
||||
|
||||
function TargetForm({
|
||||
definition,
|
||||
mode,
|
||||
initialValue,
|
||||
source,
|
||||
busy,
|
||||
connected,
|
||||
onCancel,
|
||||
onSave,
|
||||
onTest,
|
||||
}) {
|
||||
const editing = mode === 'edit';
|
||||
const initialKind = initialValue?.kind && definition.fields[initialValue.kind]
|
||||
? initialValue.kind
|
||||
: definition.kinds[0].value;
|
||||
const [targetId, setTargetId] = React.useState(initialValue?.targetId ?? '');
|
||||
const [name, setName] = React.useState(initialValue?.name ?? '');
|
||||
const [kind, setKind] = React.useState(initialKind);
|
||||
const [route, setRoute] = React.useState(initialValue?.route ?? {});
|
||||
const [error, setError] = React.useState(null);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [testState, setTestState] = React.useState(null);
|
||||
|
||||
const currentTarget = () => {
|
||||
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
|
||||
const raw = String(route[field.key] ?? '').trim();
|
||||
return [field.key, field.integer ? Number(raw) : raw];
|
||||
}));
|
||||
return {
|
||||
targetId: targetId.trim(),
|
||||
...(name.trim() ? { name: name.trim() } : {}),
|
||||
kind,
|
||||
route: normalizedRoute,
|
||||
};
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...currentTarget(),
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(presentError(caught, '目标保存失败,请检查后重试。'));
|
||||
}
|
||||
};
|
||||
|
||||
const testTarget = async () => {
|
||||
if (typeof onTest !== 'function') return;
|
||||
setTesting(true);
|
||||
setTestState(null);
|
||||
try {
|
||||
const target = currentTarget();
|
||||
await onTest({ kind: target.kind, route: target.route });
|
||||
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
||||
} catch (caught) {
|
||||
setTestState({ tone: 'error', message: presentError(caught, '测试发送失败,请稍后重试。') });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
const testReady = fieldsFor(definition, kind).every((field) => (
|
||||
String(route[field.key] ?? '').trim()
|
||||
));
|
||||
|
||||
return h('form', { className: 'dim-targetForm', onSubmit: submit },
|
||||
h('div', { className: 'dim-targetFormHeading' },
|
||||
h('h3', null, editing ? '编辑投递目标' : '新建投递目标'),
|
||||
editing
|
||||
? null
|
||||
: h('p', null, source === 'suggestion'
|
||||
? '已从会话自动填入目标信息;确认后再保存。'
|
||||
: '请手动填写从对应平台取得的原生标识。')),
|
||||
h('div', { className: 'dim-targetFormGrid' },
|
||||
h('label', { className: 'dim-targetField' },
|
||||
h('span', null, 'Target ID'),
|
||||
h('input', {
|
||||
name: 'targetId',
|
||||
value: targetId,
|
||||
readOnly: editing,
|
||||
disabled: testing,
|
||||
required: true,
|
||||
pattern: '[A-Za-z0-9._:@-]{1,128}',
|
||||
maxLength: 128,
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
placeholder: '例如 daily-report',
|
||||
onChange: (event) => setTargetId(event.target.value),
|
||||
})),
|
||||
h('label', { className: 'dim-targetField' },
|
||||
h('span', null, '显示名称(可选)'),
|
||||
h('input', {
|
||||
name: 'name',
|
||||
value: name,
|
||||
disabled: testing,
|
||||
maxLength: 80,
|
||||
placeholder: '例如 每日汇报群',
|
||||
onChange: (event) => setName(event.target.value),
|
||||
})),
|
||||
h('label', { className: 'dim-targetField' },
|
||||
h('span', null, '目标类型'),
|
||||
h('select', {
|
||||
name: 'kind',
|
||||
value: kind,
|
||||
disabled: testing,
|
||||
onChange: (event) => {
|
||||
setKind(event.target.value);
|
||||
setRoute({});
|
||||
setTestState(null);
|
||||
},
|
||||
}, definition.kinds.map((entry) => h('option', {
|
||||
key: entry.value,
|
||||
value: entry.value,
|
||||
}, entry.label)))),
|
||||
fieldsFor(definition, kind).map((field) => h('label', {
|
||||
key: field.key,
|
||||
className: 'dim-targetField',
|
||||
},
|
||||
h('span', null, field.label),
|
||||
h('input', {
|
||||
name: field.key,
|
||||
value: route[field.key] ?? '',
|
||||
disabled: testing,
|
||||
required: true,
|
||||
inputMode: field.inputMode,
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
placeholder: field.placeholder,
|
||||
onChange: (event) => {
|
||||
setRoute((current) => ({
|
||||
...current,
|
||||
[field.key]: event.target.value,
|
||||
}));
|
||||
setTestState(null);
|
||||
},
|
||||
})))),
|
||||
error ? h('p', { className: 'dim-targetFormError', role: 'alert' }, error) : null,
|
||||
testState ? h('p', {
|
||||
className: 'dim-targetFeedback',
|
||||
'data-tone': testState.tone,
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, testState.message) : null,
|
||||
h('div', { className: 'dim-targetFormActions' },
|
||||
h(DeliveryButton, { onClick: onCancel, disabled: busy || testing }, '取消'),
|
||||
h(DeliveryButton, {
|
||||
onClick: () => void testTarget(),
|
||||
disabled: busy || testing || !connected || !testReady || typeof onTest !== 'function',
|
||||
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
||||
'aria-label': '测试投递目标',
|
||||
}, testing ? '测试中…' : '测试'),
|
||||
h(DeliveryButton, { type: 'submit', kind: 'primary', disabled: busy || testing },
|
||||
busy ? '正在保存…' : '保存目标')));
|
||||
}
|
||||
|
||||
function suggestionOptionLabel(definition, suggestion, added) {
|
||||
const name = 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(' · ');
|
||||
return [name, type, route, added ? localizeText('已添加') : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function TargetSuggestionPicker({
|
||||
definition,
|
||||
phase,
|
||||
suggestions,
|
||||
error,
|
||||
targets,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
onManual,
|
||||
onCancel,
|
||||
}) {
|
||||
const configured = new Set(targets.map((target) => routeIdentity(definition, target)).filter(Boolean));
|
||||
|
||||
return h('section', { className: 'dim-targetSuggestions', 'aria-label': '从已聊过的会话选择' },
|
||||
h('div', { className: 'dim-targetSuggestionHeading' },
|
||||
h('div', null,
|
||||
h('h3', null, '从已聊过的会话选择'),
|
||||
h('p', null, '选择后会自动填写目标信息和调用别名,确认后再保存。')),
|
||||
h(DeliveryButton, {
|
||||
onClick: () => void onRefresh(),
|
||||
disabled: phase === 'loading',
|
||||
}, phase === 'loading' ? '正在刷新…' : '刷新')),
|
||||
phase === 'loading'
|
||||
? h('div', { className: 'dim-targetSuggestionState', 'aria-busy': 'true' }, '正在读取已聊会话…')
|
||||
: phase === 'error'
|
||||
? h('div', { className: 'dim-targetSuggestionState', role: 'alert' },
|
||||
h('p', null, error),
|
||||
h(DeliveryButton, { onClick: () => void onRefresh() }, '重新读取'))
|
||||
: 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'),
|
||||
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),
|
||||
disabled: added,
|
||||
}, suggestionOptionLabel(definition, suggestion, added));
|
||||
}))),
|
||||
h('div', { className: 'dim-targetFormActions' },
|
||||
h(DeliveryButton, { onClick: onCancel }, '取消'),
|
||||
h(DeliveryButton, { onClick: onManual }, '手动填写(高级)')));
|
||||
}
|
||||
|
||||
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
|
||||
const [action, setAction] = React.useState(null);
|
||||
const [testState, setTestState] = React.useState(null);
|
||||
const [copyState, setCopyState] = React.useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
const testTarget = async () => {
|
||||
setAction('test');
|
||||
setTestState(null);
|
||||
try {
|
||||
await rpcCall(DELIVERY_ENDPOINTS.test, { botId, targetId: target.targetId });
|
||||
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
||||
} catch (error) {
|
||||
setTestState({ tone: 'error', message: presentError(error, '测试发送失败,请稍后重试。') });
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const copyPair = async () => {
|
||||
setCopyState(null);
|
||||
try {
|
||||
await copyText(JSON.stringify({ botId, targetId: target.targetId }));
|
||||
setCopyState('已复制调用参数');
|
||||
} catch (error) {
|
||||
setCopyState(presentError(error, '复制失败。'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTarget = async () => {
|
||||
setAction('delete');
|
||||
try {
|
||||
await rpcCall(DELIVERY_ENDPOINTS.delete, { botId, targetId: target.targetId });
|
||||
await onChanged();
|
||||
} catch (error) {
|
||||
setTestState({ tone: 'error', message: presentError(error, '删除失败,请稍后重试。') });
|
||||
setAction(null);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
return h('li', { className: 'dim-targetRow', 'data-target-id': target.targetId },
|
||||
h('div', { className: 'dim-targetSummary' },
|
||||
h('div', { className: 'dim-targetTitle' },
|
||||
React.createElement('strong', null, target.name || target.targetId),
|
||||
h('span', null, kindLabel(definition, target.kind))),
|
||||
h('code', null, `targetId: ${target.targetId}`)),
|
||||
h('div', { className: 'dim-targetActions' },
|
||||
h(DeliveryButton, { onClick: () => void copyPair() }, '复制调用参数'),
|
||||
h(DeliveryButton, {
|
||||
onClick: () => void testTarget(),
|
||||
disabled: !connected || action === 'test',
|
||||
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
||||
'aria-label': '测试投递目标',
|
||||
}, action === 'test' ? '测试中…' : '测试'),
|
||||
h(DeliveryButton, { onClick: onEdit, disabled: Boolean(action) }, '编辑'),
|
||||
h(DeliveryButton, {
|
||||
kind: 'danger',
|
||||
onClick: () => setConfirmDelete(true),
|
||||
disabled: Boolean(action),
|
||||
}, '删除')),
|
||||
copyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, copyState) : null,
|
||||
testState ? h('p', {
|
||||
className: 'dim-targetFeedback',
|
||||
'data-tone': testState.tone,
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, testState.message) : null,
|
||||
confirmDelete ? h('div', { className: 'dim-targetDeleteConfirm', role: 'alertdialog' },
|
||||
h('p', null,
|
||||
'删除 ', h('code', null, target.targetId),
|
||||
'?使用这个 targetId 的外部调用将返回 unknown-target。'),
|
||||
h('div', { className: 'dim-targetFormActions' },
|
||||
h(DeliveryButton, {
|
||||
onClick: () => setConfirmDelete(false),
|
||||
disabled: action === 'delete',
|
||||
}, '取消'),
|
||||
h(DeliveryButton, {
|
||||
kind: 'danger',
|
||||
onClick: () => void deleteTarget(),
|
||||
disabled: action === 'delete',
|
||||
}, action === 'delete' ? '正在删除…' : '确认删除')))
|
||||
: null);
|
||||
}
|
||||
|
||||
export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }) {
|
||||
const definition = CHANNEL_DEFINITIONS[channel];
|
||||
const [phase, setPhase] = React.useState('loading');
|
||||
const [targets, setTargets] = React.useState([]);
|
||||
const [suggestionPhase, setSuggestionPhase] = React.useState('idle');
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [suggestionError, setSuggestionError] = React.useState(null);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [editor, setEditor] = React.useState(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [botCopyState, setBotCopyState] = React.useState(null);
|
||||
const mounted = React.useRef(true);
|
||||
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
|
||||
const loadTargets = React.useCallback(async ({ signal, silent = false } = {}) => {
|
||||
if (!silent) setPhase('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const value = await invoke(DELIVERY_ENDPOINTS.list, { botId: account.botId }, signal);
|
||||
if (signal?.aborted || !mounted.current) return;
|
||||
setTargets(targetsFrom(value));
|
||||
setPhase('ready');
|
||||
} catch (caught) {
|
||||
if (signal?.aborted || caught?.name === 'AbortError' || !mounted.current) return;
|
||||
setError(presentError(caught, '无法读取投递目标,请稍后重试。'));
|
||||
setPhase('error');
|
||||
}
|
||||
}, [account.botId, invoke]);
|
||||
|
||||
const loadSuggestions = React.useCallback(async () => {
|
||||
setSuggestionPhase('loading');
|
||||
setSuggestionError(null);
|
||||
try {
|
||||
const value = await invoke(DELIVERY_ENDPOINTS.listSuggestions, { botId: account.botId });
|
||||
if (!mounted.current) return;
|
||||
setSuggestions(validSuggestions(definition, value));
|
||||
setSuggestionPhase('ready');
|
||||
} catch (caught) {
|
||||
if (caught?.name === 'AbortError' || !mounted.current) return;
|
||||
setSuggestionError(presentError(caught, '无法读取已聊会话,请稍后重试。'));
|
||||
setSuggestionPhase('error');
|
||||
}
|
||||
}, [account.botId, definition, invoke]);
|
||||
|
||||
React.useEffect(() => {
|
||||
mounted.current = true;
|
||||
const controller = new AbortController();
|
||||
void loadTargets({ signal: controller.signal });
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [loadTargets]);
|
||||
|
||||
if (!definition) {
|
||||
return h('section', { className: 'dim-deliveryPage' },
|
||||
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表'),
|
||||
h('p', { role: 'alert' }, '当前渠道暂不支持投递目标。'));
|
||||
}
|
||||
|
||||
const saveTarget = async (target) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editor?.mode === 'edit') {
|
||||
await invoke(DELIVERY_ENDPOINTS.update, {
|
||||
botId: account.botId,
|
||||
targetId: editor.target.targetId,
|
||||
target: {
|
||||
...(target.name ? { name: target.name } : {}),
|
||||
kind: target.kind,
|
||||
route: target.route,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await invoke(DELIVERY_ENDPOINTS.create, { botId: account.botId, target });
|
||||
}
|
||||
await loadTargets({ silent: true });
|
||||
if (mounted.current) setEditor(null);
|
||||
} finally {
|
||||
if (mounted.current) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openSuggestionPicker = () => {
|
||||
setEditor({ mode: 'suggestions' });
|
||||
void loadSuggestions();
|
||||
};
|
||||
|
||||
const selectSuggestion = (suggestion) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
if (!identity) return;
|
||||
const route = Object.fromEntries(fieldsFor(definition, suggestion.kind).map((field) => [
|
||||
field.key,
|
||||
suggestion.route[field.key],
|
||||
]));
|
||||
setEditor({
|
||||
mode: 'create',
|
||||
source: 'suggestion',
|
||||
draftKey: identity,
|
||||
initialValue: {
|
||||
targetId: randomTargetId(targets),
|
||||
name: suggestionFallbackName(definition, suggestion),
|
||||
kind: suggestion.kind,
|
||||
route,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyBotId = async () => {
|
||||
setBotCopyState(null);
|
||||
try {
|
||||
await copyText(account.botId);
|
||||
setBotCopyState('已复制 Bot ID');
|
||||
} catch (caught) {
|
||||
setBotCopyState(presentError(caught, '复制失败。'));
|
||||
}
|
||||
};
|
||||
|
||||
return h('section', {
|
||||
className: 'dim-deliveryPage',
|
||||
'aria-label': `${account.botName || definition.label}机器人设置`,
|
||||
},
|
||||
h('header', { className: 'dim-deliveryHeader' },
|
||||
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表'),
|
||||
h('div', { className: 'dim-deliveryHeaderMeta' },
|
||||
h('a', {
|
||||
className: 'dim-deliveryDocsLink',
|
||||
href: isEnglish() ? DELIVERY_DOCS_URL.en : DELIVERY_DOCS_URL.zh,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
'aria-label': '打开主动投递使用文档',
|
||||
},
|
||||
h('span', null, '使用文档'),
|
||||
h('span', { 'aria-hidden': 'true' }, '↗')),
|
||||
h('h2', null, account.botName || '机器人设置'))),
|
||||
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-identity-title' },
|
||||
h('div', null,
|
||||
h('h3', { id: 'dim-delivery-identity-title' }, '调用标识')),
|
||||
h('div', { className: 'dim-deliveryBotId' },
|
||||
h('span', null, 'Bot ID'),
|
||||
h('code', { title: account.botId }, account.botId),
|
||||
h(DeliveryButton, { onClick: () => void copyBotId() }, '复制')),
|
||||
botCopyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, botCopyState) : null),
|
||||
h('section', { className: 'dim-deliveryTargets', 'aria-labelledby': 'dim-delivery-targets-title' },
|
||||
h('div', { className: 'dim-deliverySectionHeading' },
|
||||
h('div', null,
|
||||
h('h3', { id: 'dim-delivery-targets-title' }, '投递目标'),
|
||||
account.connected
|
||||
? null
|
||||
: h('p', null, '机器人当前离线;仍可配置目标,恢复连接后再测试。')),
|
||||
h(DeliveryButton, {
|
||||
kind: 'primary',
|
||||
onClick: openSuggestionPicker,
|
||||
disabled: Boolean(editor) || phase !== 'ready',
|
||||
title: phase === 'ready' ? undefined : '请先完成投递目标读取',
|
||||
}, '新建目标')),
|
||||
editor?.mode === 'suggestions'
|
||||
? h(TargetSuggestionPicker, {
|
||||
definition,
|
||||
phase: suggestionPhase,
|
||||
suggestions,
|
||||
error: suggestionError,
|
||||
targets,
|
||||
onRefresh: loadSuggestions,
|
||||
onSelect: selectSuggestion,
|
||||
onManual: () => setEditor({
|
||||
mode: 'create',
|
||||
source: 'manual',
|
||||
draftKey: 'manual',
|
||||
initialValue: { targetId: randomTargetId(targets) },
|
||||
}),
|
||||
onCancel: () => setEditor(null),
|
||||
})
|
||||
: editor ? h(TargetForm, {
|
||||
key: editor.mode === 'edit' ? editor.target.targetId : editor.draftKey,
|
||||
definition,
|
||||
mode: editor.mode,
|
||||
initialValue: editor.mode === 'edit' ? editor.target : editor.initialValue,
|
||||
source: editor.source,
|
||||
busy: saving,
|
||||
connected: account.connected,
|
||||
onCancel: () => setEditor(editor.source === 'suggestion'
|
||||
? { mode: 'suggestions' }
|
||||
: null),
|
||||
onSave: saveTarget,
|
||||
onTest: (target) => invoke(DELIVERY_ENDPOINTS.test, {
|
||||
botId: account.botId,
|
||||
target,
|
||||
}),
|
||||
}) : null,
|
||||
phase === 'loading'
|
||||
? h('div', { className: 'dim-deliveryState', 'aria-busy': 'true' }, '正在读取投递目标…')
|
||||
: phase === 'error'
|
||||
? h('div', { className: 'dim-deliveryState', role: 'alert' },
|
||||
h('p', null, error),
|
||||
h(DeliveryButton, { onClick: () => void loadTargets() }, '重新读取'))
|
||||
: targets.length === 0
|
||||
? h('div', { className: 'dim-deliveryState dim-deliveryEmpty' },
|
||||
h('strong', null, '尚未配置投递目标'),
|
||||
h('p', null, '点击“新建目标”可从已聊过的会话选择,也可手动填写。'))
|
||||
: h('ul', { className: 'dim-targetList' }, targets.map((target) => h(TargetRow, {
|
||||
key: target.targetId,
|
||||
definition,
|
||||
target,
|
||||
botId: account.botId,
|
||||
connected: account.connected,
|
||||
rpcCall: invoke,
|
||||
onChanged: () => loadTargets({ silent: true }),
|
||||
onEdit: () => setEditor({ mode: 'edit', target, source: 'edit' }),
|
||||
})))));
|
||||
}
|
||||
|
|
@ -673,6 +673,88 @@ const EN = Object.freeze({
|
|||
'WhatsApp 服务没有返回有效的扫码任务': 'WhatsApp did not return a valid setup attempt',
|
||||
'WhatsApp 服务没有返回有效的机器人列表': 'WhatsApp did not return a valid account list',
|
||||
'用于关联 WhatsApp 设备的一次性二维码': 'One-time QR code for linking a WhatsApp device',
|
||||
'用户': 'User',
|
||||
'微信用户 ID': 'WeChat user ID',
|
||||
'填写接收消息的用户 ID': 'Enter the recipient user ID',
|
||||
'私聊': 'Direct message',
|
||||
'群聊': 'Group',
|
||||
'群 Chat ID': 'Group Chat ID',
|
||||
'用户 ID': 'User ID',
|
||||
'填写钉钉用户 ID': 'Enter the DingTalk user ID',
|
||||
'群 Open Conversation ID': 'Group Open Conversation ID',
|
||||
'填写企业微信用户 ID': 'Enter the WeCom user ID',
|
||||
'填写群 chatid': 'Enter the group chatid',
|
||||
'单聊': 'Direct message',
|
||||
'用户 Open ID': 'User Open ID',
|
||||
'填写 user_openid': 'Enter the user_openid',
|
||||
'群 Open ID': 'Group Open ID',
|
||||
'填写 group_openid': 'Enter the group_openid',
|
||||
'会话': 'Conversation',
|
||||
'线程': 'Thread',
|
||||
'Thread 时间戳': 'Thread timestamp',
|
||||
'聊天': 'Chat',
|
||||
'话题': 'Topic',
|
||||
'频道或私信': 'Channel or DM',
|
||||
'填写可发消息的 Channel ID': 'Enter a Channel ID the bot can message',
|
||||
'用户 JID': 'User JID',
|
||||
'群 JID': 'Group JID',
|
||||
'请求失败,请稍后重试。': 'Request failed. Try again later.',
|
||||
'当前浏览器不支持自动复制,请手动选择复制。': 'Automatic copy is unavailable. Select and copy the value manually.',
|
||||
'目标保存失败,请检查后重试。': 'Could not save the target. Check it and try again.',
|
||||
'无法生成未占用的 Target ID,请重试。': 'Could not generate an unused Target ID. Try again.',
|
||||
'编辑投递目标': 'Edit delivery target',
|
||||
'新建投递目标': 'New delivery target',
|
||||
'已从会话自动填入目标信息;确认后再保存。': 'Target details were filled from the conversation. Review them before saving.',
|
||||
'请手动填写从对应平台取得的原生标识。': 'Enter the native identifier from the platform manually.',
|
||||
'例如 daily-report': 'For example, daily-report',
|
||||
'使用大小写字母、数字、点、下划线、冒号、@ 或连字符。': 'Use letters, numbers, dots, underscores, colons, @, or hyphens.',
|
||||
'显示名称(可选)': 'Display name (optional)',
|
||||
'例如 每日汇报群': 'For example, Daily reports',
|
||||
'目标类型': 'Target type',
|
||||
'保存目标': 'Save target',
|
||||
'请先完成投递目标读取': 'Wait for delivery targets to finish loading',
|
||||
'从已聊过的会话选择': 'Choose from conversations',
|
||||
'已聊会话': 'Conversation',
|
||||
'从会话选择targetID': 'Select a targetID from conversations',
|
||||
'选择后会自动填写目标信息和调用别名,确认后再保存。': 'Selecting a conversation fills the target details and call alias. Review them before saving.',
|
||||
'正在刷新…': '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.',
|
||||
'已添加': 'Added',
|
||||
'手动填写(高级)': 'Enter manually (advanced)',
|
||||
'测试消息已发送,请到目标会话确认。': 'Test message sent. Confirm it in the target conversation.',
|
||||
'测试发送失败,请稍后重试。': 'Test delivery failed. Try again later.',
|
||||
'已复制调用参数': 'Call parameters copied',
|
||||
'复制失败。': 'Copy failed.',
|
||||
'删除失败,请稍后重试。': 'Could not delete the target. Try again later.',
|
||||
'复制调用参数': 'Copy call parameters',
|
||||
'机器人离线时不可发送测试消息': 'Test delivery is unavailable while the bot is offline',
|
||||
'测试投递目标': 'Test delivery target',
|
||||
'测试': 'Test',
|
||||
'编辑': 'Edit',
|
||||
'删除': 'Delete',
|
||||
'删除 ': 'Delete ',
|
||||
'?使用这个 targetId 的外部调用将返回 unknown-target。': '? External calls using this targetId will return unknown-target.',
|
||||
'正在删除…': 'Deleting…',
|
||||
'确认删除': 'Delete target',
|
||||
'投递目标设置暂不可用。': 'Delivery target settings are unavailable.',
|
||||
'无法读取投递目标,请稍后重试。': 'Could not load delivery targets. Try again later.',
|
||||
'← 返回机器人列表': '← Back to bots',
|
||||
'使用文档': 'User guide',
|
||||
'打开主动投递使用文档': 'Open the proactive delivery guide',
|
||||
'当前渠道暂不支持投递目标。': 'Delivery targets are not supported for this channel.',
|
||||
'已复制 Bot ID': 'Bot ID copied',
|
||||
'调用标识': 'Call identifiers',
|
||||
'复制': 'Copy',
|
||||
'投递目标': 'Delivery targets',
|
||||
'机器人当前离线;仍可配置目标,恢复连接后再测试。': 'The bot is offline. You can still configure targets and test them after reconnection.',
|
||||
'新建目标': 'New target',
|
||||
'正在读取投递目标…': 'Loading delivery targets…',
|
||||
'尚未配置投递目标': 'No delivery targets yet',
|
||||
'点击“新建目标”可从已聊过的会话选择,也可手动填写。': 'Select New target to choose a conversation or enter the details manually.',
|
||||
});
|
||||
|
||||
export const en = EN;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ import { WHATSAPP_RPC_CHANNEL } from './channels/whatsapp/api.js';
|
|||
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 {
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
DeliveryTargetSettingsPage,
|
||||
} from './delivery-settings.js';
|
||||
import {
|
||||
createLoopbackAwareRpcCalls,
|
||||
replacePageLocation,
|
||||
|
|
@ -167,6 +172,7 @@ export function IMSettingsTab({
|
|||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
updateRpcCall,
|
||||
deliveryRpcCall,
|
||||
workspaceDirectoryPicker,
|
||||
browserLocation = globalThis.location,
|
||||
navigateToRecoveryUrl = replacePageLocation,
|
||||
|
|
@ -174,6 +180,7 @@ export function IMSettingsTab({
|
|||
const [selected, setSelected] = React.useState('weixin');
|
||||
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
|
||||
const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
|
||||
const [deliverySettings, setDeliverySettings] = React.useState(null);
|
||||
const githubTooltipId = React.useId();
|
||||
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
||||
const reportLoopbackRecovery = React.useCallback((recovery) => {
|
||||
|
|
@ -194,6 +201,7 @@ export function IMSettingsTab({
|
|||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
updateRpcCall,
|
||||
deliveryRpcCall,
|
||||
}, {
|
||||
location: browserLocation,
|
||||
onRecovery: reportLoopbackRecovery,
|
||||
|
|
@ -201,6 +209,7 @@ export function IMSettingsTab({
|
|||
browserLocation,
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
deliveryRpcCall,
|
||||
feishuRpcCall,
|
||||
officeRpcCall,
|
||||
qqRpcCall,
|
||||
|
|
@ -212,6 +221,9 @@ export function IMSettingsTab({
|
|||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
]);
|
||||
const botSettingsContext = React.useMemo(() => Object.freeze({
|
||||
openBotSettings: setDeliverySettings,
|
||||
}), []);
|
||||
return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
|
||||
h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
|
||||
h('header', { className: 'dim-title' },
|
||||
|
|
@ -253,7 +265,10 @@ export function IMSettingsTab({
|
|||
className: 'dim-channel',
|
||||
'aria-selected': channel.id === active.id,
|
||||
'aria-controls': `dim-panel-${channel.id}`,
|
||||
onClick: () => setSelected(channel.id),
|
||||
onClick: () => {
|
||||
setSelected(channel.id);
|
||||
setDeliverySettings(null);
|
||||
},
|
||||
},
|
||||
h(ChannelLogo, { channel: channel.id }),
|
||||
h('span', { className: 'dim-channelCopy' },
|
||||
|
|
@ -273,25 +288,33 @@ export function IMSettingsTab({
|
|||
onNavigate: navigateToRecoveryUrl,
|
||||
})
|
||||
: null,
|
||||
active.id === 'weixin'
|
||||
? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
|
||||
: active.id === 'feishu'
|
||||
? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
|
||||
: active.id === 'dingtalk'
|
||||
? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
|
||||
: active.id === 'wecom'
|
||||
? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
|
||||
: active.id === 'qq'
|
||||
? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
|
||||
: active.id === 'slack'
|
||||
? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
|
||||
: active.id === 'telegram'
|
||||
? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
|
||||
: active.id === 'discord'
|
||||
? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
|
||||
: active.id === 'whatsapp'
|
||||
? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
|
||||
: h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall })),
|
||||
h(BotSettingsContext.Provider, { value: botSettingsContext },
|
||||
deliverySettings?.channel === active.id
|
||||
? h(DeliveryTargetSettingsPage, {
|
||||
channel: active.id,
|
||||
account: deliverySettings,
|
||||
rpcCall: rpcCalls.deliveryRpcCall,
|
||||
onBack: () => setDeliverySettings(null),
|
||||
})
|
||||
: active.id === 'weixin'
|
||||
? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
|
||||
: active.id === 'feishu'
|
||||
? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
|
||||
: active.id === 'dingtalk'
|
||||
? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
|
||||
: active.id === 'wecom'
|
||||
? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
|
||||
: active.id === 'qq'
|
||||
? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
|
||||
: active.id === 'slack'
|
||||
? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
|
||||
: active.id === 'telegram'
|
||||
? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
|
||||
: active.id === 'discord'
|
||||
? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
|
||||
: active.id === 'whatsapp'
|
||||
? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
|
||||
: h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall }))),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -344,6 +367,8 @@ export function apply(ctx) {
|
|||
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const updateRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(UPDATE_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const deliveryRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(DELIVERY_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const workspaceDirectoryPicker = Object.freeze({
|
||||
listDirectory: (path, signal) =>
|
||||
callWorkspaceDirectoryApi(ctx, 'listDirectory', path, signal),
|
||||
|
|
@ -368,6 +393,7 @@ export function apply(ctx) {
|
|||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
updateRpcCall,
|
||||
deliveryRpcCall,
|
||||
workspaceDirectoryPicker,
|
||||
}),
|
||||
}, IMSettingsTab));
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ const CSS = String.raw`
|
|||
.dim-panel .dim-botName { min-width: 0; }
|
||||
.dim-panel .dim-botName h3 { overflow: hidden; margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; font-weight: 650; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-panel .dim-botName p { overflow: hidden; margin: 4px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-panel .dim-botCardTools { flex: none; display: flex; align-items: flex-start; gap: 8px; }
|
||||
.dim-panel .dim-botHealthGroup { min-width: 0; max-width: 100%; flex: none; display: grid; justify-items: end; gap: 5px; }
|
||||
.dim-panel .dim-botCard .dim-botHealth { flex: none; min-height: 0; display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; border-radius: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; }
|
||||
.dim-panel .dim-lastChecked { display: inline-flex; align-items: baseline; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); font: inherit; font-size: 11px; font-weight: 400; line-height: normal; white-space: nowrap; }
|
||||
|
|
@ -357,6 +358,69 @@ const CSS = String.raw`
|
|||
.dim-panel .dim-botCard .dim-healthDot[data-tone="success"] { background: var(--dsw-alias-state-success-primary, #20a162); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #20a162) 14%, transparent); }
|
||||
.dim-panel .dim-botCard .dim-healthDot[data-tone="warning"] { background: var(--dsw-alias-state-warn-primary, #d97706); }
|
||||
.dim-panel .dim-botCard .dim-healthDot[data-tone="error"] { background: var(--dsw-alias-state-error-primary, #d54941); }
|
||||
.dim-botSettingsAction { position: relative; flex: none; display: inline-flex; }
|
||||
.dim-botSettingsButton { width: 32px; height: 32px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; cursor: pointer; transition: color .15s ease, background .15s ease; }
|
||||
.dim-botSettingsButton:hover { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-botSettingsButton:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
|
||||
.dim-botSettingsTooltip { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; width: max-content; padding: 6px 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 8px 24px rgb(31 35 41 / 14%); font-size: 11px; line-height: 16px; opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
|
||||
.dim-botSettingsAction:hover .dim-botSettingsTooltip, .dim-botSettingsAction:focus-within .dim-botSettingsTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||
.dim-deliveryPage { min-width: 0; display: grid; gap: 14px; }
|
||||
.dim-deliveryHeader { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.dim-deliveryHeaderMeta { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 10px; text-align: right; }
|
||||
.dim-deliveryHeader h2 { min-width: 0; overflow: hidden; margin: 0; font-size: 18px; line-height: 24px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-deliveryDocsLink { min-height: 30px; flex: none; display: inline-flex; align-items: center; gap: 4px; padding: 5px 8px; border-radius: 7px; color: var(--dsw-alias-state-business-primary, #3370ff); font-size: 12px; line-height: 18px; text-decoration: none; white-space: nowrap; }
|
||||
.dim-deliveryDocsLink:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 8%, transparent); }
|
||||
.dim-deliveryDocsLink:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
|
||||
.dim-deliveryButton { min-height: 30px; display: inline-flex; align-items: center; justify-content: center; padding: 5px 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; line-height: 18px; cursor: pointer; }
|
||||
.dim-deliveryButton:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-deliveryButton:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
|
||||
.dim-deliveryButton:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.dim-deliveryButton[data-kind="primary"] { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-deliveryButton[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); }
|
||||
.dim-deliveryBack { flex: none; border-color: transparent; background: transparent; }
|
||||
.dim-deliveryIdentity, .dim-deliveryTargets { min-width: 0; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-bg-layer-3, #fff); }
|
||||
.dim-deliveryIdentity > div:first-child h3, .dim-deliverySectionHeading h3 { margin: 0; font-size: 15px; line-height: 22px; font-weight: 650; }
|
||||
.dim-deliveryIdentity > div:first-child p, .dim-deliverySectionHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
||||
.dim-deliveryBotId { min-width: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 10px; margin-top: 12px; padding: 10px 12px; border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-deliveryBotId > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
|
||||
.dim-deliveryBotId code { min-width: 0; overflow: hidden; font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-deliverySectionHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.dim-deliveryState { margin-top: 14px; padding: 24px 16px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; text-align: center; }
|
||||
.dim-deliveryState p { margin: 5px 0; }
|
||||
.dim-deliveryEmpty strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; }
|
||||
.dim-targetList { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||
.dim-targetRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px 14px; padding: 13px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff); }
|
||||
.dim-targetSummary { min-width: 0; }
|
||||
.dim-targetTitle { min-width: 0; display: flex; align-items: center; gap: 7px; }
|
||||
.dim-targetTitle strong { overflow: hidden; font-size: 13px; line-height: 20px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-targetTitle span { flex: none; padding: 1px 6px; border-radius: 5px; color: var(--dsw-alias-state-business-primary, #3370ff); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 9%, transparent); font-size: 10px; line-height: 16px; }
|
||||
.dim-targetSummary code { display: block; overflow: hidden; margin-top: 3px; color: var(--dsw-alias-label-secondary, #646a73); font: 11px/17px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-targetActions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
|
||||
.dim-targetFeedback { grid-column: 1 / -1; margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
|
||||
.dim-targetFeedback[data-tone="success"] { color: var(--dsw-alias-state-success-primary, #20a162); }
|
||||
.dim-targetFeedback[data-tone="error"], .dim-targetFormError { color: var(--dsw-alias-state-error-primary, #d54941); }
|
||||
.dim-targetDeleteConfirm { grid-column: 1 / -1; padding: 10px 12px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.dim-targetDeleteConfirm p { margin: 0 0 8px; font-size: 12px; line-height: 18px; }
|
||||
.dim-targetSuggestions { 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-targetSuggestionHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.dim-targetSuggestionHeading h3 { margin: 0; font-size: 14px; line-height: 21px; }
|
||||
.dim-targetSuggestionHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
|
||||
.dim-targetSuggestionState { margin-top: 12px; padding: 18px 12px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; text-align: center; }
|
||||
.dim-targetSuggestionState p { margin: 4px 0; }
|
||||
.dim-targetSuggestionState strong { color: var(--dsw-alias-label-primary, #1f2329); }
|
||||
.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-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; }
|
||||
.dim-targetFormGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px 12px; margin-top: 12px; }
|
||||
.dim-targetField { min-width: 0; display: grid; align-content: start; gap: 5px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
||||
.dim-targetField input, .dim-targetField select { width: 100%; min-width: 0; height: 34px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; }
|
||||
.dim-targetField input:focus, .dim-targetField 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-targetField input[readonly] { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-targetFormError { margin: 10px 0 0; font-size: 12px; line-height: 18px; }
|
||||
.dim-targetFormActions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
|
||||
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
|
||||
.dim-panel .ddt-headingCopy { display: none; }
|
||||
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
|
||||
|
|
@ -376,6 +440,10 @@ const CSS = String.raw`
|
|||
.dim-panel .ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; }
|
||||
.dim-panel .ddt-qrColumn { width: 100%; min-width: 0; }
|
||||
.dim-panel .ddt-qrCopy { width: 100%; min-width: 0; overflow-wrap: anywhere; }
|
||||
.dim-targetRow { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-targetActions { justify-content: flex-start; }
|
||||
.dim-targetFormGrid { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-targetSuggestionHeading { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
@media (max-width: 840px) {
|
||||
.dim-title { align-items: flex-start; }
|
||||
|
|
@ -399,6 +467,11 @@ const CSS = String.raw`
|
|||
.dim-rail { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
|
||||
.dim-loopbackRecoveryAction { width: 100%; }
|
||||
.dim-deliveryHeader, .dim-deliverySectionHeading { align-items: stretch; flex-direction: column; }
|
||||
.dim-deliveryHeaderMeta { justify-content: space-between; text-align: left; }
|
||||
.dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
|
||||
.dim-deliveryBotId > span { grid-column: 1 / -1; }
|
||||
.dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
|
||||
.dim-directoryPickerBackdrop { padding: 10px; }
|
||||
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
|
||||
.dim-directoryPickerHeader { padding: 18px 17px 14px; }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export async function apply(ctx, config = {}) {
|
|||
}
|
||||
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installDingtalkRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
|
|
@ -17,6 +19,7 @@ export async function apply(ctx, config = {}) {
|
|||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-dingtalk: close bot connections');
|
||||
return disposeRpc;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -149,6 +150,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'dingtalk', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ export async function apply(ctx, config = {}) {
|
|||
return installDiscordRpc(ctx, config.controller, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installDiscordRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Discord bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Discord bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export async function apply(ctx, config = {}) {
|
|||
}
|
||||
|
||||
const production = await createProductionController(ctx, config);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installFeishuRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
|
|
@ -36,6 +38,7 @@ export async function apply(ctx, config = {}) {
|
|||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-feishu: close controller and live connection');
|
||||
return disposeRpc;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
|
||||
function webSocketProxyUrl(env) {
|
||||
for (const key of ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY']) {
|
||||
|
|
@ -204,6 +205,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'feishu', workspaces, coreController, stateFor: stateForBotId,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ export async function apply(ctx, config = {}) {
|
|||
return installQqRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installQqRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close QQ bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close QQ bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -139,6 +140,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({ channel: 'qq', workspaces, coreController, stateFor }),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import {
|
||||
createDeliveryAdapter,
|
||||
supportsDeliveryChannel,
|
||||
} from '../../delivery-adapter.mjs';
|
||||
|
||||
export function pluginPaths(config, channel) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
|
|
@ -142,6 +146,9 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
...(supportsDeliveryChannel(channel) ? {
|
||||
deliveryAdapter: createDeliveryAdapter({ channel, workspaces, coreController, stateFor }),
|
||||
} : {}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ export const inject = ['connection', 'credentials', 'typertGateway'];
|
|||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installSlackRpc(ctx, config.controller, config.rpcAuthority);
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installSlackRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Slack bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Slack bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { pluginPaths } from '../shared/production.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
|
@ -129,6 +130,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'slack', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ export async function apply(ctx, config = {}) {
|
|||
return installTelegramRpc(ctx, config.controller, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installTelegramRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Telegram bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Telegram bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ export async function apply(ctx, config = {}) {
|
|||
return installWecomRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installWecomRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Enterprise WeChat bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Enterprise WeChat bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -143,6 +144,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'wecom', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export async function apply(ctx, config = {}) {
|
|||
}
|
||||
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installWeixinRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
|
|
@ -17,6 +19,7 @@ export async function apply(ctx, config = {}) {
|
|||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-weixin: close account connections');
|
||||
return disposeRpc;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -146,6 +147,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'weixin', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ export async function apply(ctx, config = {}) {
|
|||
return installWhatsappRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installWhatsappRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close WhatsApp Web connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close WhatsApp Web connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -151,6 +152,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'whatsapp', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
179
plugin-src/host/delivery-adapter.mjs
Normal file
179
plugin-src/host/delivery-adapter.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { deliverySuggestionsFromSessions } from './delivery-suggestions.mjs';
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'weixin',
|
||||
'feishu',
|
||||
'dingtalk',
|
||||
'wecom',
|
||||
'qq',
|
||||
'slack',
|
||||
'telegram',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
]);
|
||||
|
||||
export function supportsDeliveryChannel(channel) {
|
||||
return CHANNELS.has(channel);
|
||||
}
|
||||
|
||||
function invalidTarget(message) {
|
||||
const error = new Error(message);
|
||||
error.code = 'invalid-target';
|
||||
return error;
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value, allowed, label) {
|
||||
if (!isRecord(value)) throw invalidTarget(`${label} must be an object`);
|
||||
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
||||
if (extra !== undefined) throw invalidTarget(`${label} contains an unknown field`);
|
||||
}
|
||||
|
||||
function requiredString(value, label) {
|
||||
if (typeof value !== 'string' || !value || value.trim() !== value) {
|
||||
throw invalidTarget(`${label} must be a non-empty string without surrounding whitespace`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetName(value) {
|
||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > 80) {
|
||||
throw invalidTarget('target.name must contain 1 to 80 characters');
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function routeWithStrings(route, fields) {
|
||||
exactKeys(route, fields, 'route');
|
||||
const normalized = {};
|
||||
for (const field of fields) normalized[field] = requiredString(route[field], `route.${field}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function oneOf(value, choices) {
|
||||
if (!choices.includes(value)) throw invalidTarget('target kind is not supported by this channel');
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeRoute(channel, kind, route) {
|
||||
switch (channel) {
|
||||
case 'weixin':
|
||||
oneOf(kind, ['user']);
|
||||
return routeWithStrings(route, ['toUserId']);
|
||||
case 'feishu':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, kind === 'user' ? ['openId'] : ['chatId']);
|
||||
case 'dingtalk':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, kind === 'user' ? ['userId'] : ['openConversationId']);
|
||||
case 'wecom':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, ['chatId']);
|
||||
case 'qq':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, kind === 'user' ? ['userOpenId'] : ['groupOpenId']);
|
||||
case 'slack': {
|
||||
oneOf(kind, ['conversation', 'thread']);
|
||||
const normalized = routeWithStrings(
|
||||
route,
|
||||
kind === 'conversation' ? ['channelId'] : ['channelId', 'threadTs'],
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
case 'telegram': {
|
||||
oneOf(kind, ['chat', 'topic']);
|
||||
exactKeys(route, kind === 'chat' ? ['chatId'] : ['chatId', 'messageThreadId'], 'route');
|
||||
const chatId = requiredString(route.chatId, 'route.chatId');
|
||||
if (!/^-?\d+$/.test(chatId)) throw invalidTarget('route.chatId must be a decimal string');
|
||||
if (kind === 'chat') return { chatId };
|
||||
if (!Number.isSafeInteger(route.messageThreadId) || route.messageThreadId <= 0) {
|
||||
throw invalidTarget('route.messageThreadId must be a positive integer');
|
||||
}
|
||||
return { chatId, messageThreadId: route.messageThreadId };
|
||||
}
|
||||
case 'discord':
|
||||
oneOf(kind, ['channel']);
|
||||
return routeWithStrings(route, ['channelId']);
|
||||
case 'whatsapp': {
|
||||
oneOf(kind, ['user', 'group']);
|
||||
const normalized = routeWithStrings(route, ['jid']);
|
||||
const valid = kind === 'user'
|
||||
? /^\d{5,32}@(s\.whatsapp\.net|lid)$/.test(normalized.jid)
|
||||
: /^\d{5,32}(?:-\d{1,32})?@g\.us$/.test(normalized.jid);
|
||||
if (!valid) {
|
||||
throw invalidTarget(`route.jid must be a ${kind} WhatsApp JID`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
default:
|
||||
throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strictly validate and copy one channel delivery target. */
|
||||
export function normalizeDeliveryTarget(channel, value, { targetIdRequired = true } = {}) {
|
||||
if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
||||
exactKeys(
|
||||
value,
|
||||
targetIdRequired ? ['targetId', 'name', 'kind', 'route'] : ['name', 'kind', 'route'],
|
||||
'target',
|
||||
);
|
||||
const normalized = {};
|
||||
if (targetIdRequired) normalized.targetId = requiredString(value.targetId, 'target.targetId');
|
||||
if (value.name !== undefined) normalized.name = targetName(value.name);
|
||||
normalized.kind = requiredString(value.kind, 'target.kind');
|
||||
normalized.route = normalizeRoute(channel, normalized.kind, value.route);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Bind one channel's existing workspace store and unwrapped controller to DeliveryService. */
|
||||
export function createDeliveryAdapter({ channel, workspaces, coreController, stateFor }) {
|
||||
if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
||||
if (!workspaces || typeof workspaces !== 'object') {
|
||||
throw new TypeError('delivery adapter requires a workspace store');
|
||||
}
|
||||
if (!coreController || typeof coreController !== 'object') {
|
||||
throw new TypeError('delivery adapter requires a proactive text controller');
|
||||
}
|
||||
if (typeof stateFor !== 'function') {
|
||||
throw new TypeError('delivery adapter requires a bot state getter');
|
||||
}
|
||||
return Object.freeze({
|
||||
channel,
|
||||
ownsBot: (botId) => workspaces.has(botId),
|
||||
listTargets: (botId) => workspaces.listDeliveryTargets(botId),
|
||||
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);
|
||||
return suggestions.map((suggestion) => normalizeDeliveryTarget(
|
||||
channel,
|
||||
suggestion,
|
||||
{ targetIdRequired: false },
|
||||
));
|
||||
},
|
||||
createTarget: (botId, target) => workspaces.createDeliveryTarget(
|
||||
botId,
|
||||
normalizeDeliveryTarget(channel, target),
|
||||
),
|
||||
updateTarget: (botId, targetId, replacement) => workspaces.updateDeliveryTarget(
|
||||
botId,
|
||||
targetId,
|
||||
normalizeDeliveryTarget(channel, replacement, { targetIdRequired: false }),
|
||||
),
|
||||
deleteTarget: (botId, targetId) => workspaces.deleteDeliveryTarget(botId, targetId),
|
||||
async sendText(botId, target, text, options = {}) {
|
||||
const normalized = normalizeDeliveryTarget(channel, target);
|
||||
if (typeof coreController.sendProactiveText !== 'function') {
|
||||
throw new TypeError('delivery controller cannot send proactive text');
|
||||
}
|
||||
await coreController.sendProactiveText(botId, normalized, text, options);
|
||||
return { sent: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
158
plugin-src/host/delivery-rpc.mjs
Normal file
158
plugin-src/host/delivery-rpc.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { resolveRpcAuthority } from './rpc-authority.mjs';
|
||||
|
||||
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
||||
export const DELIVERY_TEST_MESSAGE = 'DSH-IM 主动投递测试成功。';
|
||||
export const DELIVERY_ENDPOINTS = Object.freeze({
|
||||
send: 'message.send',
|
||||
listTargets: 'target.list',
|
||||
listSuggestions: 'target.suggestion.list',
|
||||
createTarget: 'target.create',
|
||||
updateTarget: 'target.update',
|
||||
deleteTarget: 'target.delete',
|
||||
testTarget: 'target.test',
|
||||
});
|
||||
|
||||
const ENDPOINTS = new Set(Object.values(DELIVERY_ENDPOINTS));
|
||||
const PUBLIC_ERRORS = new Set([
|
||||
'bad-request',
|
||||
'unknown-bot',
|
||||
'unknown-target',
|
||||
'target-conflict',
|
||||
'invalid-target',
|
||||
'bot-not-connected',
|
||||
'target-rejected',
|
||||
'delivery-failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value, keys) {
|
||||
return isRecord(value)
|
||||
&& Object.keys(value).length === keys.length
|
||||
&& Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
|
||||
function validBotId(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validTargetId(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9._:@-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validTarget(value, { targetId }) {
|
||||
const keys = targetId ? ['targetId', 'name', 'kind', 'route'] : ['name', 'kind', 'route'];
|
||||
if (!isRecord(value) || Object.keys(value).some((key) => !keys.includes(key))) return false;
|
||||
if (targetId && !validTargetId(value.targetId)) return false;
|
||||
if (value.name !== undefined
|
||||
&& (typeof value.name !== 'string' || !value.name.trim() || value.name.trim().length > 80)) return false;
|
||||
return typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
||||
&& isRecord(value.route);
|
||||
}
|
||||
|
||||
function validDraftTarget(value) {
|
||||
return exactKeys(value, ['kind', 'route'])
|
||||
&& typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
||||
&& isRecord(value.route);
|
||||
}
|
||||
|
||||
function validPayload(endpoint, payload) {
|
||||
if (!ENDPOINTS.has(endpoint) || !isRecord(payload)) return false;
|
||||
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
||||
return exactKeys(payload, ['botId', 'targetId', 'text'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
||||
&& typeof payload.text === 'string' && Boolean(payload.text.trim());
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.listTargets
|
||||
|| endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
return exactKeys(payload, ['botId']) && validBotId(payload.botId);
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
||||
return exactKeys(payload, ['botId', 'target'])
|
||||
&& validBotId(payload.botId) && validTarget(payload.target, { targetId: true });
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
||||
return exactKeys(payload, ['botId', 'targetId', 'target'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
||||
&& validTarget(payload.target, { targetId: false });
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.testTarget) {
|
||||
return (exactKeys(payload, ['botId', 'targetId'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId))
|
||||
|| (exactKeys(payload, ['botId', 'target'])
|
||||
&& validBotId(payload.botId) && validDraftTarget(payload.target));
|
||||
}
|
||||
return exactKeys(payload, ['botId', 'targetId'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId);
|
||||
}
|
||||
|
||||
function publicError(error) {
|
||||
const code = PUBLIC_ERRORS.has(error?.code) ? error.code : 'delivery-failed';
|
||||
return { code, message: code, details: {} };
|
||||
}
|
||||
|
||||
export function createDeliveryRpcHandler(service) {
|
||||
for (const method of [
|
||||
'send',
|
||||
'listTargets',
|
||||
'listSuggestions',
|
||||
'createTarget',
|
||||
'updateTarget',
|
||||
'deleteTarget',
|
||||
]) {
|
||||
if (typeof service?.[method] !== 'function') {
|
||||
throw new TypeError(`A complete delivery service is required (${method})`);
|
||||
}
|
||||
}
|
||||
return async (endpoint, payload, signal) => {
|
||||
if (!validPayload(endpoint, payload)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'bad-request', message: 'Invalid delivery request.', details: {} },
|
||||
};
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } };
|
||||
}
|
||||
try {
|
||||
let value;
|
||||
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
||||
value = await service.send(payload.botId, payload.targetId, payload.text, { signal });
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.listTargets) {
|
||||
value = await service.listTargets(payload.botId);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
value = await service.listSuggestions(payload.botId);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
||||
value = await service.createTarget(payload.botId, payload.target);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
||||
value = await service.updateTarget(payload.botId, payload.targetId, payload.target);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.deleteTarget) {
|
||||
value = await service.deleteTarget(payload.botId, payload.targetId);
|
||||
} else {
|
||||
value = await service.send(
|
||||
payload.botId,
|
||||
Object.hasOwn(payload, 'target') ? payload.target : payload.targetId,
|
||||
DELIVERY_TEST_MESSAGE,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return { ok: false, error: publicError(error) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function installDeliveryRpc(ctx, service, { authority } = {}) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
createDeliveryRpcHandler(service),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
224
plugin-src/host/delivery-service.mjs
Normal file
224
plugin-src/host/delivery-service.mjs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
|
||||
|
||||
const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
|
||||
const CHANNEL_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const DRAFT_TARGET_ID = '__test__';
|
||||
|
||||
const ADAPTER_METHODS = Object.freeze([
|
||||
'ownsBot',
|
||||
'listTargets',
|
||||
'listSuggestions',
|
||||
'createTarget',
|
||||
'updateTarget',
|
||||
'deleteTarget',
|
||||
'sendText',
|
||||
]);
|
||||
|
||||
const DELIVERY_ERROR_CODES = new Set([
|
||||
'bad-request',
|
||||
'unknown-bot',
|
||||
'unknown-target',
|
||||
'target-conflict',
|
||||
'invalid-target',
|
||||
'bot-not-connected',
|
||||
'target-rejected',
|
||||
'delivery-failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
function deliveryError(code, message = code, options) {
|
||||
const error = new Error(message, options);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function botIdOf(value) {
|
||||
if (typeof value !== 'string' || !BOT_ID_PATTERN.test(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid bot id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetIdOf(value) {
|
||||
if (typeof value !== 'string' || !TARGET_ID_PATTERN.test(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid target id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetObject(value, { includesTargetId } = {}) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid target');
|
||||
}
|
||||
if (includesTargetId) targetIdOf(value.targetId);
|
||||
else if (Object.hasOwn(value, 'targetId')) {
|
||||
throw deliveryError('bad-request', 'A target id cannot be changed');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function draftTargetObject(value) {
|
||||
targetObject(value);
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 2 || !keys.includes('kind') || !keys.includes('route')) {
|
||||
throw deliveryError('bad-request', 'Invalid draft target');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cancellation(signal) {
|
||||
if (signal?.aborted) throw deliveryError('cancelled', 'Request cancelled');
|
||||
}
|
||||
|
||||
function publicOperationError(error, fallback = 'delivery-failed') {
|
||||
if (error?.code === 'workspace-bot-not-found') {
|
||||
return deliveryError('unknown-bot', 'Unknown bot', { cause: error });
|
||||
}
|
||||
if (DELIVERY_ERROR_CODES.has(error?.code)) return error;
|
||||
return deliveryError(fallback, fallback, { cause: error });
|
||||
}
|
||||
|
||||
function validateAdapter(adapter) {
|
||||
if (!adapter || typeof adapter !== 'object'
|
||||
|| typeof adapter.channel !== 'string' || !CHANNEL_PATTERN.test(adapter.channel)) {
|
||||
throw new TypeError('A delivery adapter with a valid channel is required');
|
||||
}
|
||||
for (const method of ADAPTER_METHODS) {
|
||||
if (typeof adapter[method] !== 'function') {
|
||||
throw new TypeError(`A complete delivery adapter is required (${method})`);
|
||||
}
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export class DeliveryService {
|
||||
#adapters = new Map();
|
||||
|
||||
registerAdapter(value) {
|
||||
const adapter = validateAdapter(value);
|
||||
const registration = Object.freeze({ adapter });
|
||||
this.#adapters.set(adapter.channel, registration);
|
||||
return () => {
|
||||
if (this.#adapters.get(adapter.channel) !== registration) return false;
|
||||
this.#adapters.delete(adapter.channel);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
async listTargets(botId) {
|
||||
const id = botIdOf(botId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
const targets = await adapter.listTargets(id);
|
||||
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
||||
return { botId: id, channel: adapter.channel, targets };
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listSuggestions(botId) {
|
||||
const id = botIdOf(botId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
const suggestions = await adapter.listSuggestions(id);
|
||||
if (!Array.isArray(suggestions)) throw new TypeError('Adapter returned invalid suggestions');
|
||||
return {
|
||||
botId: id,
|
||||
channel: adapter.channel,
|
||||
suggestions: suggestions.map((suggestion) => normalizeDeliveryTarget(
|
||||
adapter.channel,
|
||||
suggestion,
|
||||
{ targetIdRequired: false },
|
||||
)),
|
||||
};
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createTarget(botId, target) {
|
||||
const id = botIdOf(botId);
|
||||
targetObject(target, { includesTargetId: true });
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
return await adapter.createTarget(id, target);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTarget(botId, targetId, replacement) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
targetObject(replacement);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
return await adapter.updateTarget(id, targetKey, replacement);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTarget(botId, targetId) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
await adapter.deleteTarget(id, targetKey);
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async send(botId, targetIdOrDraft, text, { signal } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = typeof targetIdOrDraft === 'string'
|
||||
? targetIdOf(targetIdOrDraft)
|
||||
: null;
|
||||
const draft = targetKey === null ? draftTargetObject(targetIdOrDraft) : null;
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw deliveryError('bad-request', 'Message text is required');
|
||||
}
|
||||
cancellation(signal);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
let target;
|
||||
if (draft) {
|
||||
target = { targetId: DRAFT_TARGET_ID, ...draft };
|
||||
} else {
|
||||
const targets = await adapter.listTargets(id);
|
||||
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
||||
target = targets.find((candidate) => candidate?.targetId === targetKey);
|
||||
if (!target) throw deliveryError('unknown-target', 'Unknown target');
|
||||
}
|
||||
cancellation(signal);
|
||||
await adapter.sendText(id, target, text, { signal });
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
||||
throw deliveryError('cancelled', 'Request cancelled', { cause: error });
|
||||
}
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #adapterFor(botId) {
|
||||
for (const { adapter } of this.#adapters.values()) {
|
||||
let ownsBot;
|
||||
try {
|
||||
ownsBot = await adapter.ownsBot(botId);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
if (ownsBot) return adapter;
|
||||
}
|
||||
throw deliveryError('unknown-bot', 'Unknown bot');
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeliveryService() {
|
||||
return new DeliveryService();
|
||||
}
|
||||
135
plugin-src/host/delivery-suggestions.mjs
Normal file
135
plugin-src/host/delivery-suggestions.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
const CHANNELS = new Set([
|
||||
'weixin',
|
||||
'feishu',
|
||||
'dingtalk',
|
||||
'wecom',
|
||||
'qq',
|
||||
'slack',
|
||||
'telegram',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function opaqueId(value) {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& value.length <= 512
|
||||
&& value.trim() === value
|
||||
&& !/[\s:\u0000-\u001f\u007f]/u.test(value);
|
||||
}
|
||||
|
||||
function afterPrefix(key, prefix) {
|
||||
const marker = `${prefix}:`;
|
||||
if (!key.startsWith(marker)) return null;
|
||||
const value = key.slice(marker.length);
|
||||
return opaqueId(value) ? value : null;
|
||||
}
|
||||
|
||||
function simpleSuggestion(key, definitions) {
|
||||
for (const [prefix, kind, field] of definitions) {
|
||||
const value = afterPrefix(key, prefix);
|
||||
if (value) return { kind, route: { [field]: value } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function feishuSuggestion(key) {
|
||||
const openId = afterPrefix(key, 'p2p');
|
||||
if (openId?.startsWith('ou_')) {
|
||||
return { kind: 'user', route: { openId } };
|
||||
}
|
||||
return simpleSuggestion(key, [['group', 'group', 'chatId']]);
|
||||
}
|
||||
|
||||
function slackSuggestion(key) {
|
||||
const directChannel = afterPrefix(key, 'direct');
|
||||
if (directChannel && /^[A-Za-z0-9_-]{1,128}$/.test(directChannel)) {
|
||||
return { kind: 'conversation', route: { channelId: directChannel } };
|
||||
}
|
||||
const match = /^group:([^:]+):(\d{1,20}(?:\.\d{1,20})?)$/.exec(key);
|
||||
if (!match || !/^[A-Za-z0-9_-]{1,128}$/.test(match[1])) return null;
|
||||
return { kind: 'thread', route: { channelId: match[1], threadTs: match[2] } };
|
||||
}
|
||||
|
||||
function telegramSuggestion(key) {
|
||||
const match = /^(direct|group):(-?\d+)(?::([1-9]\d*))?$/.exec(key);
|
||||
if (!match) return null;
|
||||
const chatId = Number(match[2]);
|
||||
if (!Number.isSafeInteger(chatId)) return null;
|
||||
if (match[1] === 'direct' && match[3] !== undefined) return null;
|
||||
if (match[3] === undefined) return { kind: 'chat', route: { chatId: match[2] } };
|
||||
const messageThreadId = Number(match[3]);
|
||||
if (!Number.isSafeInteger(messageThreadId) || messageThreadId <= 0) return null;
|
||||
return { kind: 'topic', route: { chatId: match[2], messageThreadId } };
|
||||
}
|
||||
|
||||
function discordSuggestion(key) {
|
||||
const match = /^(?:direct|group):(\d{1,32})$/.exec(key);
|
||||
return match ? { kind: 'channel', route: { channelId: match[1] } } : null;
|
||||
}
|
||||
|
||||
function whatsappSuggestion(key) {
|
||||
const direct = /^direct:(\d{5,32}@(s\.whatsapp\.net|lid))$/.exec(key);
|
||||
if (direct) return { kind: 'user', route: { jid: direct[1] } };
|
||||
const group = /^group:(\d{5,32}(?:-\d{1,32})?@g\.us)$/.exec(key);
|
||||
return group ? { kind: 'group', route: { jid: group[1] } } : null;
|
||||
}
|
||||
|
||||
/** Convert one persisted conversation key into a stable proactive-delivery route. */
|
||||
export function deliverySuggestionFromConversationKey(channel, key) {
|
||||
if (!CHANNELS.has(channel) || typeof key !== 'string') return null;
|
||||
switch (channel) {
|
||||
case 'weixin':
|
||||
return simpleSuggestion(key, [['p2p', 'user', 'toUserId']]);
|
||||
case 'feishu':
|
||||
return feishuSuggestion(key);
|
||||
case 'dingtalk':
|
||||
return simpleSuggestion(key, [
|
||||
['p2p', 'user', 'userId'],
|
||||
['group', 'group', 'openConversationId'],
|
||||
]);
|
||||
case 'wecom':
|
||||
return simpleSuggestion(key, [
|
||||
['direct', 'user', 'chatId'],
|
||||
['group', 'group', 'chatId'],
|
||||
]);
|
||||
case 'qq':
|
||||
return simpleSuggestion(key, [
|
||||
['c2c', 'user', 'userOpenId'],
|
||||
['group', 'group', 'groupOpenId'],
|
||||
]);
|
||||
case 'slack':
|
||||
return slackSuggestion(key);
|
||||
case 'telegram':
|
||||
return telegramSuggestion(key);
|
||||
case 'discord':
|
||||
return discordSuggestion(key);
|
||||
case 'whatsapp':
|
||||
return whatsappSuggestion(key);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and de-duplicate stable delivery routes from a persisted sessions map.
|
||||
* Session ids and every other state field are intentionally ignored.
|
||||
*/
|
||||
export function deliverySuggestionsFromSessions(channel, sessions) {
|
||||
if (!CHANNELS.has(channel) || !isRecord(sessions)) return [];
|
||||
const suggestions = [];
|
||||
const seen = new Set();
|
||||
for (const key of Object.keys(sessions)) {
|
||||
const suggestion = deliverySuggestionFromConversationKey(channel, key);
|
||||
if (!suggestion) continue;
|
||||
const identity = JSON.stringify(suggestion);
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
suggestions.push(suggestion);
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
|||
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
|
||||
import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
|
||||
import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
|
||||
import { installDeliveryRpc } from './delivery-rpc.mjs';
|
||||
import { createDeliveryService } from './delivery-service.mjs';
|
||||
import { installUpdateRpc } from './update-rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
|
|
@ -19,15 +21,18 @@ export const inject = [
|
|||
'typertGateway',
|
||||
];
|
||||
|
||||
function channelConfig(config, name) {
|
||||
function channelConfig(config, name, deliveryService) {
|
||||
const channel = config[name] ?? {};
|
||||
return config.rpcAuthority === undefined
|
||||
const withAuthority = config.rpcAuthority === undefined
|
||||
? channel
|
||||
: { ...channel, rpcAuthority: config.rpcAuthority };
|
||||
return name === 'office' ? withAuthority : { ...withAuthority, deliveryService };
|
||||
}
|
||||
|
||||
export function createImHostPlugin(internals = {}) {
|
||||
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
|
||||
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
|
||||
const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
|
||||
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
||||
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
||||
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
|
||||
|
|
@ -54,8 +59,17 @@ export function createImHostPlugin(internals = {}) {
|
|||
name,
|
||||
inject,
|
||||
async apply(ctx, config = {}) {
|
||||
const deliveryService = makeDeliveryService();
|
||||
if (typeof ctx?.provide === 'function') {
|
||||
ctx.provide('dshIm', Object.freeze({
|
||||
send: (botId, targetId, text, options) => (
|
||||
deliveryService.send(botId, targetId, text, options)
|
||||
),
|
||||
listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
|
||||
}));
|
||||
}
|
||||
const activate = async (readyCtx) => {
|
||||
await activateChannels(readyCtx, config);
|
||||
await activateChannels(readyCtx, config, deliveryService);
|
||||
};
|
||||
if (typeof ctx?.inject === 'function') {
|
||||
const modern = typeof ctx?.typertGateway?.stream === 'function';
|
||||
|
|
@ -69,7 +83,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
},
|
||||
});
|
||||
|
||||
async function activateChannels(ctx, config) {
|
||||
async function activateChannels(ctx, config, deliveryService) {
|
||||
setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
|
||||
if (typeof ctx?.inject === 'function') {
|
||||
ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
|
||||
|
|
@ -87,11 +101,16 @@ export function createImHostPlugin(internals = {}) {
|
|||
} catch (error) {
|
||||
logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
|
||||
}
|
||||
try {
|
||||
startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
|
||||
} catch (error) {
|
||||
logger.error?.('[dsh-im] failed to activate delivery management; continuing with channels', error);
|
||||
}
|
||||
}
|
||||
const failures = [];
|
||||
for (const [channel, start] of channels) {
|
||||
try {
|
||||
await start(ctx, channelConfig(config, channel));
|
||||
await start(ctx, channelConfig(config, channel, deliveryService));
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue