@xmanrui/dsh-im 4.34.0 → 4.34.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/lib/client.js +323 -204
- package/lib/index.js +297 -297
- package/package.json +1 -1
- package/plugin-src/client/access-policy-settings.js +18 -3
- package/plugin-src/client/bot-settings-tabs.js +114 -0
- package/plugin-src/client/delivery-settings.js +2 -16
- package/plugin-src/client/i18n.js +7 -0
- package/plugin-src/client/styles.js +8 -3
- package/plugin-src/host/channels/feishu/production.mjs +13 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -1
- package/plugin-src/host/channels/shared/access-policy-production.mjs +14 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
- package/plugin-src/host/modern-harness-api.mjs +2 -1
- package/src/channels/discord/config-store.mjs +21 -1
- package/src/channels/discord/discord-api.mjs +32 -0
- package/src/channels/discord/discord-controller.mjs +22 -1
- package/src/channels/feishu/bridge.mjs +40 -14
- package/src/channels/feishu/feishu-channel.mjs +11 -3
- package/src/channels/feishu/feishu-runtime.mjs +10 -2
- package/src/channels/shared/bot-workspace-store.mjs +4 -0
- package/src/channels/shared/connection-error.mjs +10 -4
- package/src/channels/shared/i18n-en/feishu.mjs +2 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +4 -0
- package/src/channels/shared/message-failure.mjs +26 -4
- package/src/channels/shared/text-harness-bridge.mjs +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.34.
|
|
3
|
+
"version": "4.34.2",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
5
|
"description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
|
|
6
6
|
"keywords": [
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { h, localizeText } from './i18n.js';
|
|
9
9
|
|
|
10
10
|
export const ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set';
|
|
11
|
+
const CONFIRMED_OWNER_CHANNELS = new Set(['weixin', 'feishu', 'qq', 'whatsapp', 'discord']);
|
|
11
12
|
|
|
12
13
|
export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
|
|
13
14
|
weixin: Object.freeze({
|
|
@@ -17,9 +18,9 @@ export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
|
|
|
17
18
|
}),
|
|
18
19
|
feishu: Object.freeze({
|
|
19
20
|
directUserLabel: '飞书 Open ID',
|
|
20
|
-
directPlaceholder: 'ou_xxx',
|
|
21
|
+
directPlaceholder: '填写使用者的 Open ID(ou_xxx),不是机器人 ID',
|
|
21
22
|
groupUserLabel: '群成员 Open ID',
|
|
22
|
-
groupPlaceholder: 'ou_xxx',
|
|
23
|
+
groupPlaceholder: '填写使用者的 Open ID(ou_xxx),不是机器人 ID',
|
|
23
24
|
}),
|
|
24
25
|
dingtalk: Object.freeze({
|
|
25
26
|
directUserLabel: '钉钉用户 ID',
|
|
@@ -125,6 +126,8 @@ function ScenePolicyEditor({
|
|
|
125
126
|
placeholder,
|
|
126
127
|
disabled = false,
|
|
127
128
|
unsupported = false,
|
|
129
|
+
ownerHelp,
|
|
130
|
+
identityHelp,
|
|
128
131
|
onChange,
|
|
129
132
|
}) {
|
|
130
133
|
const ownerHelpId = React.useId();
|
|
@@ -165,7 +168,7 @@ function ScenePolicyEditor({
|
|
|
165
168
|
id: ownerHelpId,
|
|
166
169
|
className: 'dim-channelTooltip dim-accessHelpTooltip',
|
|
167
170
|
role: 'tooltip',
|
|
168
|
-
}, '
|
|
171
|
+
}, ownerHelp, identityHelp ? h('br') : null, identityHelp)))),
|
|
169
172
|
unsupported
|
|
170
173
|
? h('div', { className: 'dim-accessUnsupported', role: 'note' },
|
|
171
174
|
h('strong', null, '当前渠道不支持群聊'),
|
|
@@ -271,6 +274,12 @@ function ScenePolicyEditor({
|
|
|
271
274
|
|
|
272
275
|
export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved }) {
|
|
273
276
|
const definition = ACCESS_CHANNEL_DEFINITIONS[channel];
|
|
277
|
+
const ownerHelp = CONFIRMED_OWNER_CHANNELS.has(channel)
|
|
278
|
+
? '已由接入流程确认的所有者可以访问并执行命令,不受本页限制。未识别到所有者身份时,按本页访问设置执行。'
|
|
279
|
+
: '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。';
|
|
280
|
+
const identityHelp = channel === 'feishu'
|
|
281
|
+
? '白名单请填写使用者的 Open ID,不是机器人的 Open ID。仅保存通配符的接入配置不包含所有者身份。'
|
|
282
|
+
: null;
|
|
274
283
|
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
|
|
275
284
|
const initialKey = JSON.stringify(initialPolicy);
|
|
276
285
|
const [draft, setDraft] = React.useState(() => clonePolicy(
|
|
@@ -333,6 +342,8 @@ export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved })
|
|
|
333
342
|
policy: draft.direct,
|
|
334
343
|
userLabel: definition.directUserLabel,
|
|
335
344
|
placeholder: definition.directPlaceholder,
|
|
345
|
+
ownerHelp,
|
|
346
|
+
identityHelp,
|
|
336
347
|
disabled: saving,
|
|
337
348
|
onChange: (direct) => { setDraft((current) => ({ ...current, direct })); setFeedback(null); },
|
|
338
349
|
}),
|
|
@@ -342,6 +353,10 @@ export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved })
|
|
|
342
353
|
policy: draft.group,
|
|
343
354
|
userLabel: definition.groupUserLabel ?? definition.directUserLabel,
|
|
344
355
|
placeholder: definition.groupPlaceholder ?? definition.directPlaceholder,
|
|
356
|
+
ownerHelp,
|
|
357
|
+
identityHelp: channel === 'qq'
|
|
358
|
+
? '扫码获得的是私聊身份,群聊只有匹配已确认的所有者身份时才豁免;其他成员按群聊白名单设置执行。'
|
|
359
|
+
: identityHelp,
|
|
345
360
|
disabled: saving || definition.groupSupported === false,
|
|
346
361
|
unsupported: definition.groupSupported === false,
|
|
347
362
|
onChange: (group) => { setDraft((current) => ({ ...current, group })); setFeedback(null); },
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { h } from './i18n.js';
|
|
4
|
+
|
|
5
|
+
// Scroll only the tab strip: scrollIntoView can also move the host settings page.
|
|
6
|
+
function revealTab(strip, tab) {
|
|
7
|
+
if (!strip || !tab) return;
|
|
8
|
+
const viewport = strip.getBoundingClientRect();
|
|
9
|
+
const bounds = tab.getBoundingClientRect();
|
|
10
|
+
if (bounds.left < viewport.left) strip.scrollLeft += bounds.left - viewport.left;
|
|
11
|
+
else if (bounds.right > viewport.right) strip.scrollLeft += bounds.right - viewport.right;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function BotSettingsTabs({ tabs, activeTabId, onChange }) {
|
|
15
|
+
const barRef = React.useRef(null);
|
|
16
|
+
const stripRef = React.useRef(null);
|
|
17
|
+
const listRef = React.useRef(null);
|
|
18
|
+
const [scroll, setScroll] = React.useState({ overflow: false, left: false, right: false });
|
|
19
|
+
|
|
20
|
+
const measure = React.useCallback(() => {
|
|
21
|
+
const bar = barRef.current;
|
|
22
|
+
const strip = stripRef.current;
|
|
23
|
+
const list = listRef.current;
|
|
24
|
+
if (!bar || !strip || !list) return;
|
|
25
|
+
const next = {
|
|
26
|
+
// Compare against the whole bar, so arrows disappear as soon as all tabs fit.
|
|
27
|
+
overflow: list.scrollWidth > bar.clientWidth + 1,
|
|
28
|
+
left: strip.scrollLeft > 1,
|
|
29
|
+
right: strip.scrollWidth - strip.clientWidth - strip.scrollLeft > 1,
|
|
30
|
+
};
|
|
31
|
+
setScroll((previous) => Object.keys(next).every((key) => next[key] === previous[key])
|
|
32
|
+
? previous : next);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
const strip = stripRef.current;
|
|
37
|
+
if (!strip) return undefined;
|
|
38
|
+
const refresh = () => {
|
|
39
|
+
revealTab(strip, strip.querySelector('[aria-selected="true"]'));
|
|
40
|
+
measure();
|
|
41
|
+
};
|
|
42
|
+
refresh();
|
|
43
|
+
const view = strip.ownerDocument.defaultView;
|
|
44
|
+
const observer = view.ResizeObserver ? new view.ResizeObserver(refresh) : null;
|
|
45
|
+
for (const node of [barRef.current, strip, listRef.current]) observer?.observe(node);
|
|
46
|
+
view.addEventListener('resize', refresh);
|
|
47
|
+
return () => {
|
|
48
|
+
observer?.disconnect();
|
|
49
|
+
view.removeEventListener('resize', refresh);
|
|
50
|
+
};
|
|
51
|
+
}, [tabs, activeTabId, scroll.overflow, measure]);
|
|
52
|
+
|
|
53
|
+
const scrollTabs = (direction) => {
|
|
54
|
+
const strip = stripRef.current;
|
|
55
|
+
if (!strip) return;
|
|
56
|
+
strip.scrollLeft += direction * Math.max(80, strip.clientWidth * 0.75);
|
|
57
|
+
measure();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleKeyDown = (event, index) => {
|
|
61
|
+
let next;
|
|
62
|
+
if (event.key === 'Home') next = 0;
|
|
63
|
+
else if (event.key === 'End') next = tabs.length - 1;
|
|
64
|
+
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
65
|
+
next = (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
|
66
|
+
}
|
|
67
|
+
if (next === undefined) return;
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
onChange(tabs[next].id);
|
|
70
|
+
const target = stripRef.current?.querySelectorAll('[role="tab"]')[next];
|
|
71
|
+
target?.focus({ preventScroll: true });
|
|
72
|
+
revealTab(stripRef.current, target);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const arrow = (direction, label, enabled) => scroll.overflow ? h('button', {
|
|
76
|
+
type: 'button',
|
|
77
|
+
className: 'dim-botSettingsScroll',
|
|
78
|
+
'aria-label': label,
|
|
79
|
+
title: label,
|
|
80
|
+
disabled: !enabled,
|
|
81
|
+
onClick: () => scrollTabs(direction),
|
|
82
|
+
}, h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' },
|
|
83
|
+
h('path', {
|
|
84
|
+
d: direction < 0 ? 'M10 3 5 8l5 5' : 'm6 3 5 5-5 5',
|
|
85
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
|
|
86
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
87
|
+
}))) : null;
|
|
88
|
+
|
|
89
|
+
return h('div', { ref: barRef, className: 'dim-botSettingsTabsBar' },
|
|
90
|
+
arrow(-1, '向左滚动设置页签', scroll.left),
|
|
91
|
+
h('nav', {
|
|
92
|
+
ref: stripRef,
|
|
93
|
+
className: 'dim-botSettingsTabs',
|
|
94
|
+
role: 'tablist',
|
|
95
|
+
'aria-label': '机器人设置页签',
|
|
96
|
+
onScroll: measure,
|
|
97
|
+
}, h('div', { ref: listRef, className: 'dim-botSettingsTabsList', role: 'presentation' },
|
|
98
|
+
tabs.map((tab, index) => h('button', {
|
|
99
|
+
key: tab.id,
|
|
100
|
+
id: `dim-bot-settings-${tab.id}-tab`,
|
|
101
|
+
type: 'button',
|
|
102
|
+
role: 'tab',
|
|
103
|
+
className: 'dim-botSettingsTab',
|
|
104
|
+
'aria-selected': tab.id === activeTabId,
|
|
105
|
+
'aria-controls': `dim-bot-settings-${tab.id}-panel`,
|
|
106
|
+
tabIndex: tab.id === activeTabId ? 0 : -1,
|
|
107
|
+
onClick: (event) => {
|
|
108
|
+
onChange(tab.id);
|
|
109
|
+
revealTab(stripRef.current, event?.currentTarget);
|
|
110
|
+
},
|
|
111
|
+
onKeyDown: (event) => handleKeyDown(event, index),
|
|
112
|
+
}, tab.label)))),
|
|
113
|
+
arrow(1, '向右滚动设置页签', scroll.right));
|
|
114
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
3
|
import { AccessPolicySettingsPage } from './access-policy-settings.js';
|
|
4
|
+
import { BotSettingsTabs } from './bot-settings-tabs.js';
|
|
4
5
|
import { FeishuGroupSettingsPage } from './channels/feishu/group-settings.js';
|
|
5
6
|
import { FeishuVoiceSettingsPage } from './channels/feishu/voice-settings.js';
|
|
6
7
|
import { FeishuSlashPanelSettingsPage } from './channels/feishu/slash-panel-settings.js';
|
|
@@ -770,22 +771,7 @@ export function DeliveryTargetSettingsPage({
|
|
|
770
771
|
},
|
|
771
772
|
h('header', { className: 'dim-deliveryHeader' },
|
|
772
773
|
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表')),
|
|
773
|
-
h(
|
|
774
|
-
h('nav', {
|
|
775
|
-
className: 'dim-botSettingsTabs',
|
|
776
|
-
role: 'tablist',
|
|
777
|
-
'aria-label': '机器人设置页签',
|
|
778
|
-
}, settingsTabs.map((tab) => h('button', {
|
|
779
|
-
key: tab.id,
|
|
780
|
-
id: `dim-bot-settings-${tab.id}-tab`,
|
|
781
|
-
type: 'button',
|
|
782
|
-
role: 'tab',
|
|
783
|
-
className: 'dim-botSettingsTab',
|
|
784
|
-
'aria-selected': tab.id === activeTab.id,
|
|
785
|
-
'aria-controls': `dim-bot-settings-${tab.id}-panel`,
|
|
786
|
-
tabIndex: tab.id === activeTab.id ? 0 : -1,
|
|
787
|
-
onClick: () => setActiveTabId(tab.id),
|
|
788
|
-
}, tab.label)))),
|
|
774
|
+
h(BotSettingsTabs, { tabs: settingsTabs, activeTabId: activeTab.id, onChange: setActiveTabId }),
|
|
789
775
|
h('div', {
|
|
790
776
|
id: activePanelId,
|
|
791
777
|
className: 'dim-botSettingsTabPanel',
|
|
@@ -48,6 +48,8 @@ const EN = Object.freeze({
|
|
|
48
48
|
'IM 面板加载失败': 'Could not load the IM panel',
|
|
49
49
|
'更多机器人设置': 'More bot settings',
|
|
50
50
|
'更多设置': 'More settings',
|
|
51
|
+
'向左滚动设置页签': 'Scroll settings tabs left',
|
|
52
|
+
'向右滚动设置页签': 'Scroll settings tabs right',
|
|
51
53
|
'常用配置': 'Common settings',
|
|
52
54
|
'机器人设置': 'Bot settings',
|
|
53
55
|
'机器人设置页签': 'Bot settings tabs',
|
|
@@ -68,6 +70,11 @@ const EN = Object.freeze({
|
|
|
68
70
|
'尚未添加用户': 'No users added',
|
|
69
71
|
'当前渠道不支持群聊': 'Group chat is not supported by this channel',
|
|
70
72
|
'原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。': 'The original owner or QR-code operator can always access the bot and run commands; the settings below apply only to other users.',
|
|
73
|
+
'已由接入流程确认的所有者可以访问并执行命令,不受本页限制。未识别到所有者身份时,按本页访问设置执行。': 'Owners confirmed during connection setup can access the bot and run commands regardless of these settings. When no owner has been identified, these access settings apply.',
|
|
74
|
+
'白名单请填写使用者的 Open ID,不是机器人的 Open ID。仅保存通配符的接入配置不包含所有者身份。': 'Enter the user’s Open ID in the allowlist, not the bot’s Open ID. A connection configured with only a wildcard has no identified owner.',
|
|
75
|
+
'扫码获得的是私聊身份,群聊只有匹配已确认的所有者身份时才豁免;其他成员按群聊白名单设置执行。': 'QR setup identifies the owner for direct messages. In groups, only a matching confirmed owner identity is exempt; other members follow the group allowlist settings.',
|
|
76
|
+
'填写使用者的 Open ID(ou_xxx),不是机器人 ID': 'Enter the user’s Open ID (ou_xxx), not the bot ID',
|
|
77
|
+
'这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。': 'Enter a user Open ID here, not the bot’s own Open ID.',
|
|
71
78
|
'此区域无需配置,保存私聊设置时会保留现有群聊策略。': 'No setup is needed here. Saving direct-message settings keeps the existing group policy.',
|
|
72
79
|
'访问设置已保存。': 'Access settings saved.',
|
|
73
80
|
'访问设置暂不可用。': 'Access settings are currently unavailable.',
|
|
@@ -467,9 +467,14 @@ const CSS = String.raw`
|
|
|
467
467
|
.dim-moreSettingsChevron { width: 6px; height: 6px; margin-right: 2px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); }
|
|
468
468
|
.dim-deliveryPage { min-width: 0; display: grid; }
|
|
469
469
|
.dim-deliveryHeader { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
470
|
-
.dim-botSettingsTabsBar { min-width: 0; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
|
|
471
|
-
.dim-botSettingsTabs { min-width: 0;
|
|
470
|
+
.dim-botSettingsTabsBar { min-width: 0; display: flex; align-items: center; gap: 4px; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
|
|
471
|
+
.dim-botSettingsTabs { min-width: 0; flex: 1; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
|
|
472
472
|
.dim-botSettingsTabs::-webkit-scrollbar { display: none; }
|
|
473
|
+
.dim-botSettingsTabsList { width: max-content; min-width: 100%; display: flex; align-items: flex-end; gap: 24px; }
|
|
474
|
+
.dim-botSettingsScroll { flex: 0 0 30px; width: 30px; height: 32px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #646a73); cursor: pointer; }
|
|
475
|
+
.dim-botSettingsScroll:hover:not(:disabled) { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
|
476
|
+
.dim-botSettingsScroll:disabled { opacity: .3; cursor: default; }
|
|
477
|
+
.dim-botSettingsScroll:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: -2px; }
|
|
473
478
|
.dim-botSettingsTab { position: relative; min-height: 38px; flex: none; display: inline-flex; align-items: center; justify-content: center; padding: 6px 2px 9px; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; font-weight: 560; white-space: nowrap; cursor: pointer; transition: color .15s ease; }
|
|
474
479
|
.dim-botSettingsTab::after { content: ''; position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; border-radius: 2px 2px 0 0; background: transparent; transform: scaleX(.45); transition: background .15s ease, transform .15s ease; }
|
|
475
480
|
.dim-botSettingsTab:hover:not([aria-selected="true"]) { color: var(--dsw-alias-label-primary, #1f2329); }
|
|
@@ -735,7 +740,7 @@ const CSS = String.raw`
|
|
|
735
740
|
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
|
|
736
741
|
.dim-loopbackRecoveryAction { width: 100%; }
|
|
737
742
|
.dim-deliverySectionHeading { align-items: stretch; flex-direction: column; }
|
|
738
|
-
.dim-
|
|
743
|
+
.dim-botSettingsTabsList { gap: 18px; }
|
|
739
744
|
.dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
|
|
740
745
|
.dim-deliveryBotId > span { grid-column: 1 / -1; }
|
|
741
746
|
.dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getImageInputSettingsStore } from '../../../../src/channels/shared/image-input-settings-store.mjs';
|
|
2
|
+
import { t } from '../../../../src/channels/shared/i18n.mjs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join, resolve } from 'node:path';
|
|
4
5
|
import { unlink } from 'node:fs/promises';
|
|
@@ -34,6 +35,7 @@ import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
|
34
35
|
import {
|
|
35
36
|
accessPolicyProvider,
|
|
36
37
|
initialAccessPolicyFor,
|
|
38
|
+
newBotAccessPolicyFor,
|
|
37
39
|
} from '../shared/access-policy-production.mjs';
|
|
38
40
|
|
|
39
41
|
// The WebSocket agent built here is only used for the Feishu long connection,
|
|
@@ -229,7 +231,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
229
231
|
const id = botId ?? botConfig.id ?? botConfig.appId;
|
|
230
232
|
await workspaces.ensure(id, {
|
|
231
233
|
defaultAgentPreset: config.agentPreset,
|
|
232
|
-
initialAccessPolicy:
|
|
234
|
+
initialAccessPolicy: newBotAccessPolicyFor('feishu', botConfig),
|
|
233
235
|
});
|
|
234
236
|
const workspaceScope = createBotWorkspaceScope(harness, {
|
|
235
237
|
botId: id, workspaces, state, agentPresetCatalog,
|
|
@@ -291,6 +293,16 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
291
293
|
stateFor: stateForBotId,
|
|
292
294
|
agentPresetCatalog,
|
|
293
295
|
modelCatalog,
|
|
296
|
+
validateAccessPolicyForBot(botId, policy) {
|
|
297
|
+
const botOpenId = configStore.getBot(botId)?.botOpenId;
|
|
298
|
+
if (!botOpenId) return;
|
|
299
|
+
if ([policy.direct, policy.group].some((scope) => scope.mode === 'allowlist'
|
|
300
|
+
&& scope.allowlist.users.some((user) => user.id === botOpenId))) {
|
|
301
|
+
const error = new TypeError(t('这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。'));
|
|
302
|
+
error.code = 'access-policy-invalid';
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
},
|
|
294
306
|
});
|
|
295
307
|
|
|
296
308
|
const supervisor = createSupervisor({
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
accessPolicyProvider,
|
|
32
32
|
initialAccessPolicyFor,
|
|
33
|
+
newBotAccessPolicyFor,
|
|
33
34
|
} from '../shared/access-policy-production.mjs';
|
|
34
35
|
|
|
35
36
|
function pluginPaths(config) {
|
|
@@ -117,7 +118,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
117
118
|
const state = await stateFor(botId);
|
|
118
119
|
await workspaces.ensure(botId, {
|
|
119
120
|
defaultAgentPreset: config.agentPreset,
|
|
120
|
-
initialAccessPolicy:
|
|
121
|
+
initialAccessPolicy: newBotAccessPolicyFor('qq', botConfig),
|
|
121
122
|
});
|
|
122
123
|
const workspaceScope = createBotWorkspaceScope(harness, {
|
|
123
124
|
botId, workspaces, state, agentPresetCatalog,
|
|
@@ -97,6 +97,19 @@ export function initialAccessPolicyFor(channel, config = {}) {
|
|
|
97
97
|
throw new TypeError(`Unsupported access-policy channel: ${channel}`);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Seed newly connected bots after startup has migrated existing bots with
|
|
102
|
+
* initialAccessPolicyFor(). Open Feishu/QQ messages and commands in both chat
|
|
103
|
+
* types; workspace ensure() preserves any policy already saved.
|
|
104
|
+
*/
|
|
105
|
+
export function newBotAccessPolicyFor(channel, config = {}) {
|
|
106
|
+
const key = String(channel ?? '').trim().toLowerCase();
|
|
107
|
+
if (key === 'feishu' || key === 'qq') {
|
|
108
|
+
return createAccessPolicy({ direct: openScope(), group: openScope() });
|
|
109
|
+
}
|
|
110
|
+
return initialAccessPolicyFor(channel, config);
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
export function privilegedSenderIdsFor(channel, config = {}) {
|
|
101
114
|
const key = String(channel ?? '').trim().toLowerCase();
|
|
102
115
|
if (key === 'weixin') return cleanIds(config.ownerUserId);
|
|
@@ -109,6 +122,7 @@ export function privilegedSenderIdsFor(channel, config = {}) {
|
|
|
109
122
|
}
|
|
110
123
|
if (key === 'qq') return cleanIds(config.ownerUserOpenid).filter((id) => id !== '*');
|
|
111
124
|
if (key === 'whatsapp') return cleanIds(config.accountJid);
|
|
125
|
+
if (key === 'discord') return cleanIds(config.ownerUserId);
|
|
112
126
|
return [];
|
|
113
127
|
}
|
|
114
128
|
|
|
@@ -26,6 +26,7 @@ export function publicWorkspaceError(error) {
|
|
|
26
26
|
'model-selection-unavailable',
|
|
27
27
|
'model-reasoning-unavailable',
|
|
28
28
|
'context-enhancement-invalid',
|
|
29
|
+
'access-policy-invalid',
|
|
29
30
|
].includes(error?.code)) return null;
|
|
30
31
|
return { code: error.code, message: error.message };
|
|
31
32
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
|
|
4
|
+
import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
|
|
4
5
|
|
|
5
6
|
const modernApis = new WeakMap();
|
|
6
7
|
|
|
@@ -25,7 +26,7 @@ function failureOf(error) {
|
|
|
25
26
|
return {
|
|
26
27
|
code: 'internal',
|
|
27
28
|
message: error instanceof Error ? error.message : String(error),
|
|
28
|
-
details:
|
|
29
|
+
details: extractConnectionEvidence(error).details,
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -20,6 +20,26 @@ export function maskDiscordBotId(platformId) {
|
|
|
20
20
|
|
|
21
21
|
export class DiscordConfigStore extends TokenBotConfigStore {
|
|
22
22
|
constructor(path) {
|
|
23
|
-
super(path, {
|
|
23
|
+
super(path, {
|
|
24
|
+
channel: 'Discord',
|
|
25
|
+
...IDENTITY_OPTIONS,
|
|
26
|
+
normalizeBotExtension(value) {
|
|
27
|
+
if (value.ownerUserId == null) return { ownerUserId: null };
|
|
28
|
+
const ownerUserId = typeof value.ownerUserId === 'string' ? value.ownerUserId.trim() : '';
|
|
29
|
+
if (!/^\d{5,30}$/.test(ownerUserId) || ownerUserId === value.platformId?.trim()) return null;
|
|
30
|
+
return { ownerUserId };
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
save(value) {
|
|
36
|
+
// Shared credential binding rebuilds the base config. Preserve only this
|
|
37
|
+
// bot's confirmed identity when omitted; explicit null still clears it.
|
|
38
|
+
return super.save({
|
|
39
|
+
...value,
|
|
40
|
+
ownerUserId: value?.ownerUserId === undefined
|
|
41
|
+
? this.get(value?.botId)?.ownerUserId ?? null
|
|
42
|
+
: value.ownerUserId,
|
|
43
|
+
});
|
|
24
44
|
}
|
|
25
45
|
}
|
|
@@ -123,6 +123,10 @@ export class DiscordApi {
|
|
|
123
123
|
return this.#request('users/@me', { ...options, method: 'GET' });
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
getCurrentApplication(options = {}) {
|
|
127
|
+
return this.#request('applications/@me', { ...options, method: 'GET' });
|
|
128
|
+
}
|
|
129
|
+
|
|
126
130
|
getGatewayBot(options = {}) {
|
|
127
131
|
return this.#request('gateway/bot', { ...options, method: 'GET' });
|
|
128
132
|
}
|
|
@@ -345,3 +349,31 @@ export async function inspectDiscordToken(token, options = {}) {
|
|
|
345
349
|
username: cleanString(bot.username),
|
|
346
350
|
};
|
|
347
351
|
}
|
|
352
|
+
|
|
353
|
+
/** Owner lookup is optional metadata, bounded separately from Gateway startup. */
|
|
354
|
+
export async function inspectDiscordOwner(token, {
|
|
355
|
+
platformId,
|
|
356
|
+
timeoutMs = 5_000,
|
|
357
|
+
signal,
|
|
358
|
+
...options
|
|
359
|
+
} = {}) {
|
|
360
|
+
const api = new DiscordApi({ token, ...options });
|
|
361
|
+
const lookupSignal = requestSignal(signal, positiveTimeout(timeoutMs, 'owner lookup timeout'));
|
|
362
|
+
const request = { signal: lookupSignal, retry: false };
|
|
363
|
+
const application = await api.getCurrentApplication(request);
|
|
364
|
+
const bot = application?.bot ?? await api.getCurrentUser(request);
|
|
365
|
+
if ((!application?.bot && bot?.bot !== true) || bot?.bot === false
|
|
366
|
+
|| snowflake(bot?.id, 'bot id') !== platformId) {
|
|
367
|
+
throw new Error('Discord application does not belong to the configured bot');
|
|
368
|
+
}
|
|
369
|
+
// A team application belongs to its team owner, not every developer or the
|
|
370
|
+
// synthetic application owner that Discord can return for team applications.
|
|
371
|
+
const ownerUserId = snowflake(application?.team != null
|
|
372
|
+
? application.team.owner_user_id
|
|
373
|
+
: application?.owner?.id, 'owner id');
|
|
374
|
+
if (ownerUserId === platformId || (application?.owner?.bot === true && application?.team == null)) {
|
|
375
|
+
throw new Error('Discord application owner is not a human identity');
|
|
376
|
+
}
|
|
377
|
+
lookupSignal.throwIfAborted();
|
|
378
|
+
return ownerUserId;
|
|
379
|
+
}
|
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
import { TokenBotController } from '../shared/token-bot-controller.mjs';
|
|
2
2
|
import { deriveDiscordBotIdentity, maskDiscordBotId } from './config-store.mjs';
|
|
3
|
-
import { inspectDiscordToken } from './discord-api.mjs';
|
|
3
|
+
import { inspectDiscordOwner, inspectDiscordToken } from './discord-api.mjs';
|
|
4
4
|
import { DISCORD_DESCRIPTOR } from './discord-bridge.mjs';
|
|
5
5
|
|
|
6
6
|
export class DiscordController extends TokenBotController {
|
|
7
7
|
constructor(options) {
|
|
8
|
+
if (typeof options.createRuntime !== 'function') throw new TypeError('Discord runtime factory must be a function');
|
|
9
|
+
const inspectOwner = options.inspectOwner ?? inspectDiscordOwner;
|
|
10
|
+
if (typeof inspectOwner !== 'function') throw new TypeError('Discord owner inspector must be a function');
|
|
8
11
|
super({
|
|
9
12
|
...options,
|
|
10
13
|
descriptor: DISCORD_DESCRIPTOR,
|
|
11
14
|
inspectToken: options.inspectToken ?? inspectDiscordToken,
|
|
12
15
|
deriveIdentity: deriveDiscordBotIdentity,
|
|
13
16
|
maskPlatformId: maskDiscordBotId,
|
|
17
|
+
createRuntime: async (request) => {
|
|
18
|
+
// Run inside the parent's existing per-bot transition. The production
|
|
19
|
+
// callback must receive the committed owner before building its policy.
|
|
20
|
+
let config = options.configStore.get(request.botId) ?? request.config;
|
|
21
|
+
try {
|
|
22
|
+
const ownerUserId = await inspectOwner(request.token, { platformId: config.platformId });
|
|
23
|
+
if (ownerUserId != null && ownerUserId !== config.ownerUserId) {
|
|
24
|
+
config = await options.configStore.save({ ...config, ownerUserId });
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// Neither provider errors nor optional metadata writes should break
|
|
28
|
+
// an otherwise usable bot. Do not log provider responses or user IDs.
|
|
29
|
+
(options.logger ?? console).warn?.('[dsh-im:discord] owner lookup unavailable; using saved identity', {
|
|
30
|
+
botId: request.botId,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return options.createRuntime({ ...request, config });
|
|
34
|
+
},
|
|
14
35
|
});
|
|
15
36
|
}
|
|
16
37
|
}
|