mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 22:00:50 +08:00
feat(settings): move IM bot to top-level menu
Remove the legacy Plugins-tab registrations, keep the channel-logo navigation, and harden upgrade/package verification.
This commit is contained in:
parent
892e18db77
commit
1ce8126edb
30 changed files with 815 additions and 842 deletions
|
|
@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed / 变更
|
||||
|
||||
- 「IM机器人」设置页已从插件页签迁移到一级设置菜单,并以 `order: 21` 尽量排在「Agent 预设」之后;新版不再注册旧二级入口。升级后重启 `dsh web` 并刷新浏览器即可使用,已有机器人配置和页面内的渠道专属 Logo 保持不变。
|
||||
The **IM Bot** settings page has moved from a Plugins tab to the top-level settings menu and uses `order: 21` to follow **Agent Presets**. The new release no longer registers the legacy nested entry. Restart `dsh web` and refresh the browser after upgrading; existing bot configuration and channel-specific logos inside the page are preserved.
|
||||
|
||||
## [2.6.0] - 2026-08-25
|
||||
|
||||
### Added / 新增
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
|||
|
||||
Other IM platforms can be added through the same channel-adapter structure.
|
||||
|
||||
All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent as image files, with optional captions to Harness. Each image is limited to 5 MB, and images in one message are limited to 20 MB in total. Downloading images or files from Feishu user messages requires the `im:message:readonly` tenant scope, shown on the confirmation page as **Read direct and group messages**; Feishu currently offers no narrower image-only scope for that download endpoint. Apps created through the built-in QR flow request it by default; for existing or manually connected apps, click **Complete permissions** in the plugin's Feishu settings and scan the QR code to incrementally add that scope, `im:resource` for uploading bot-sent images or files, and the card callback.
|
||||
All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent as image files, with optional captions to Harness. Each image is limited to 5 MB, and images in one message are limited to 20 MB in total. Downloading images or files from Feishu user messages requires the `im:message:readonly` tenant scope, shown on the confirmation page as **Read direct and group messages**; Feishu currently offers no narrower image-only scope for that download endpoint. Apps created through the built-in QR flow request it by default; for existing or manually connected apps, click **Complete permissions** on the IM Bot settings page and scan the QR code to incrementally add that scope, `im:resource` for uploading bot-sent images or files, and the card callback.
|
||||
|
||||
### Result-file and image delivery
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ Install the published stable release from npm (recommended):
|
|||
dsh plugin --profile web add -w @xmanrui/dsh-im
|
||||
```
|
||||
|
||||
Restart `dsh web`, then open **Settings → Plugins → IM Bot**.
|
||||
Restart `dsh web`, refresh the browser, then open **Settings → IM Bot**. The top-level IM Bot entry uses `order: 21` to follow **Agent Presets**, and the Plugins page no longer retains the old entry. Upgrading preserves existing bots, credentials, workspaces, Agent Presets, and Session bindings.
|
||||
|
||||
To try the latest code before it is published to npm, use the GitHub-source installer instead:
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ If the Slack desktop app has no native Slash Command registered with the same na
|
|||
|
||||
## Design
|
||||
|
||||
- Registers one **IM Bot** settings page containing nine IM channels and one AI Office Connector.
|
||||
- Registers one top-level **IM Bot** settings page containing nine IM channels and one AI Office Connector.
|
||||
- Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins.
|
||||
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the Host's `language` config (Chinese by default; `en` switches them to English), with Chinese always as the fallback so untranslated text is sent verbatim.
|
||||
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
|||
|
||||
其他 IM 平台可继续按同一渠道适配器结构接入。
|
||||
|
||||
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。飞书下载用户消息中的图片或文件需要租户权限 `im:message:readonly`,确认页将其显示为“获取单聊、群组消息”;飞书目前没有为该下载接口提供仅限图片的更窄权限。扫码新建的应用会默认申请;已有或手动绑定的应用可私聊机器人执行 `/repair`,或在插件页面点击“补全权限”,扫码增量补全该权限、上传机器人图片或文件所需的 `im:resource`,以及卡片回调。
|
||||
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。飞书下载用户消息中的图片或文件需要租户权限 `im:message:readonly`,确认页将其显示为“获取单聊、群组消息”;飞书目前没有为该下载接口提供仅限图片的更窄权限。扫码新建的应用会默认申请;已有或手动绑定的应用可私聊机器人执行 `/repair`,或在「IM机器人」设置页点击“补全权限”,扫码增量补全该权限、上传机器人图片或文件所需的 `im:resource`,以及卡片回调。
|
||||
|
||||
### 结果文件与图片回传
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ Heartbeat 成功响应必须是 JSON:`{"ok":true,"protocolVersion":"office-har
|
|||
dsh plugin --profile web add -w @xmanrui/dsh-im
|
||||
```
|
||||
|
||||
重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。
|
||||
重启 `dsh web`、刷新浏览器,然后打开「设置 → IM机器人」。IM机器人使用 `order: 21`,尽量排在一级设置菜单的「Agent 预设」之后;插件页面不再保留旧入口。从旧版升级不会改变已有机器人、凭据、工作区、Agent Preset 或会话绑定。
|
||||
|
||||
如需试用尚未发布到 npm 的最新代码,可以改用 GitHub 源安装器:
|
||||
|
||||
|
|
@ -217,7 +217,7 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
|
|||
|
||||
## 设计
|
||||
|
||||
- Harness 中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
|
||||
- Harness 一级设置菜单中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
|
||||
- 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
|
||||
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息跟随 Host 的 `language` 配置(默认中文;设为 `en` 即为英文),中文始终为兜底,未收录的文案原样输出;
|
||||
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ try {
|
|||
for (const packageName of legacy) {
|
||||
runDsh(['plugin', '--profile', profile, 'remove', packageName]);
|
||||
}
|
||||
console.log('\nIM 机器人插件已安装。请重启 dsh web,然后打开「设置 → 插件 → IM机器人」。');
|
||||
console.log('\nIM 机器人插件已安装。请重启 dsh web、刷新浏览器,然后打开「设置 → IM机器人」。');
|
||||
if (legacy.size > 0) {
|
||||
console.log('已用 dsh-im 替换独立飞书/微信/钉钉插件;原有凭据和扫码绑定保持不变。');
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 446 KiB |
1392
lib/client.js
1392
lib/client.js
File diff suppressed because it is too large
Load diff
14
lib/index.js
14
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -83,7 +83,7 @@
|
|||
"scripts": {
|
||||
"build": "node plugin-src/client/build.mjs && node plugin-src/host/build.mjs",
|
||||
"test": "node --test test/*.test.mjs test/channels/*/*.test.mjs",
|
||||
"check": "npm test && npm run build && node scripts/verify-package.mjs"
|
||||
"check": "npm run build && npm test && node scripts/verify-package.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
} from '../../channel-card-meta.js';
|
||||
import {
|
||||
DINGTALK_ENDPOINTS,
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
connectionTestFeedback,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
|
|
@ -29,9 +28,6 @@ import { installDingtalkStyles } from './styles.js';
|
|||
|
||||
const ACTIVE_PROVISION_STATES = new Set(['pending', 'scanned', 'authorizing', 'creating', 'connecting']);
|
||||
|
||||
export const name = 'dingtalk-settings';
|
||||
export const inject = ['slots', 'connection'];
|
||||
|
||||
function DingtalkIcon({ size = 28 }) {
|
||||
return h('svg', {
|
||||
width: size,
|
||||
|
|
@ -864,16 +860,3 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
})
|
||||
: null)));
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => installDingtalkStyles(), 'dingtalk-settings: install client styles');
|
||||
const rpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal);
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'dingtalk',
|
||||
order: 40,
|
||||
label: '钉钉',
|
||||
inject: () => ({ rpcCall }),
|
||||
}, DingtalkSettingsTab));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const DINGTALK_STYLE_ID = 'xmanrui-dsh-dingtalk-settings';
|
||||
export const DINGTALK_STYLE_ID = 'xmanrui-dsh-im-dingtalk-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.ddt-page {
|
||||
|
|
@ -126,7 +126,7 @@ export function installDingtalkStyles() {
|
|||
const existing = document.querySelector(`style[data-plugin-css="${DINGTALK_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-dingtalk';
|
||||
style.dataset.plugin = '@xmanrui/dsh-im';
|
||||
style.dataset.pluginCss = DINGTALK_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { h } from "../../i18n.js";
|
|||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
FEISHU_REGISTRATION_OPERATIONS,
|
||||
FEISHU_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeBotsSnapshot,
|
||||
normalizeGroupResponseMode,
|
||||
|
|
@ -28,11 +27,6 @@ import {
|
|||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from "../../channel-card-meta.js";
|
||||
import { installFeishuStyles } from "./styles.js";
|
||||
|
||||
export const name = "feishu-settings";
|
||||
export const inject = ["slots", "connection"];
|
||||
|
||||
const CALLBACK_REPAIR_OPERATION = FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
|
||||
const GROUP_MESSAGE_PERMISSION_OPERATION = FEISHU_REGISTRATION_OPERATIONS.GROUP_MESSAGE_PERMISSION;
|
||||
|
||||
|
|
@ -1540,26 +1534,3 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
),
|
||||
));
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(
|
||||
() => installFeishuStyles(),
|
||||
"feishu-settings: install client styles",
|
||||
);
|
||||
|
||||
const rpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
|
||||
|
||||
ctx.slots.inject("settings.plugins.tab", () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: "settings.plugins.tab",
|
||||
id: "feishu",
|
||||
order: 20,
|
||||
label: "飞书",
|
||||
inject: () => ({ rpcCall }),
|
||||
},
|
||||
FeishuSettingsTab,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const FEISHU_STYLE_ID = "beihuixinghe-dsh-feishu-settings";
|
||||
export const FEISHU_STYLE_ID = "xmanrui-dsh-im-feishu-settings";
|
||||
|
||||
const CSS = String.raw`
|
||||
.bxf-page {
|
||||
|
|
@ -562,7 +562,7 @@ export function installFeishuStyles() {
|
|||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.dataset.plugin = "@xmanrui/dsh-feishu";
|
||||
style.dataset.plugin = "@xmanrui/dsh-im";
|
||||
style.dataset.pluginCss = FEISHU_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
QQ_ENDPOINTS,
|
||||
QQ_RPC_CHANNEL,
|
||||
connectionTestFeedback,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
|
|
@ -514,11 +513,3 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
|
||||
botList)));
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => installQqStyles(), 'qq-settings: install client styles');
|
||||
const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal);
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab', id: 'qq', order: 50, label: 'QQ', inject: () => ({ rpcCall }),
|
||||
}, QqSettingsTab));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
WECOM_ENDPOINTS,
|
||||
WECOM_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
|
|
@ -546,11 +545,3 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
|
||||
botList)));
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => installWecomStyles(), 'wecom-settings: install client styles');
|
||||
const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal);
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab', id: 'wecom', order: 45, label: '企业微信', inject: () => ({ rpcCall }),
|
||||
}, WecomSettingsTab));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { QrActionIcon } from '../../credential-binding.js';
|
|||
import { h } from '../../i18n.js';
|
||||
import {
|
||||
WEIXIN_ENDPOINTS,
|
||||
WEIXIN_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
|
|
@ -27,11 +26,6 @@ import {
|
|||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
} from '../../channel-card-meta.js';
|
||||
import { installWeixinStyles } from './styles.js';
|
||||
|
||||
export const name = 'weixin-settings';
|
||||
export const inject = ['slots', 'connection'];
|
||||
|
||||
const Button = React.forwardRef(function Button(
|
||||
{ children, kind = 'secondary', className = '', ...props },
|
||||
ref,
|
||||
|
|
@ -717,16 +711,3 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
: null),
|
||||
));
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => installWeixinStyles(), 'weixin-settings: install client styles');
|
||||
const rpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal);
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'weixin',
|
||||
order: 30,
|
||||
label: '微信',
|
||||
inject: () => ({ rpcCall }),
|
||||
}, WeixinSettingsTab));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const WEIXIN_STYLE_ID = 'xmanrui-dsh-weixin-settings';
|
||||
export const WEIXIN_STYLE_ID = 'xmanrui-dsh-im-weixin-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.dxw-page {
|
||||
|
|
@ -107,7 +107,7 @@ export function installWeixinStyles() {
|
|||
const existing = document.querySelector(`style[data-plugin-css="${WEIXIN_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-weixin';
|
||||
style.dataset.plugin = '@xmanrui/dsh-im';
|
||||
style.dataset.pluginCss = WEIXIN_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
|
|
|
|||
|
|
@ -333,10 +333,10 @@ export function apply(ctx) {
|
|||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
});
|
||||
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'im',
|
||||
order: 20,
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'xmanrui-dsh-im',
|
||||
order: 21,
|
||||
label: () => t('IM机器人'),
|
||||
locale: IM_LOCALE_NAMESPACE,
|
||||
inject: () => ({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import { access, readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
async function readSourceTree(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const chunks = [];
|
||||
for (const entry of entries) {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
chunks.push(await readSourceTree(path));
|
||||
} else if (entry.isFile() && /\.m?js$/u.test(entry.name)) {
|
||||
chunks.push(await readFile(path, 'utf8'));
|
||||
}
|
||||
}
|
||||
return chunks.join('\n');
|
||||
}
|
||||
|
||||
const required = [
|
||||
'lib/index.js',
|
||||
'lib/client.js',
|
||||
|
|
@ -35,7 +50,17 @@ const required = [
|
|||
];
|
||||
await Promise.all(required.map((path) => access(resolve(root, path))));
|
||||
|
||||
const [client, host, patch, manifestText, lockText, hostSource, clientSource, executable] = await Promise.all([
|
||||
const [
|
||||
client,
|
||||
host,
|
||||
patch,
|
||||
manifestText,
|
||||
lockText,
|
||||
hostSource,
|
||||
clientEntrySource,
|
||||
clientSources,
|
||||
executable,
|
||||
] = await Promise.all([
|
||||
readFile(resolve(root, 'lib/client.js'), 'utf8'),
|
||||
readFile(resolve(root, 'lib/index.js'), 'utf8'),
|
||||
readFile(resolve(root, 'cordis.patch.yml'), 'utf8'),
|
||||
|
|
@ -43,6 +68,7 @@ const [client, host, patch, manifestText, lockText, hostSource, clientSource, ex
|
|||
readFile(resolve(root, 'package-lock.json'), 'utf8'),
|
||||
readFile(resolve(root, 'plugin-src/host/index.mjs'), 'utf8'),
|
||||
readFile(resolve(root, 'plugin-src/client/index.js'), 'utf8'),
|
||||
readSourceTree(resolve(root, 'plugin-src/client')),
|
||||
stat(resolve(root, 'bin/dsh-im.mjs')),
|
||||
]);
|
||||
const manifest = JSON.parse(manifestText);
|
||||
|
|
@ -82,17 +108,28 @@ if (forbiddenDshLockPaths.length > 0) {
|
|||
);
|
||||
}
|
||||
|
||||
if (!client.includes('id: "@xmanrui/dsh-im"')) {
|
||||
if (!/\bid\s*:\s*["']@xmanrui\/dsh-im["']/u.test(client)) {
|
||||
throw new Error('client bundle does not register the dsh-im loader id');
|
||||
}
|
||||
if (!client.includes('id: "im"')
|
||||
|| !client.includes('label: () => t("IM\\u673A\\u5668\\u4EBA")')
|
||||
|| !client.includes('locale: IM_LOCALE_NAMESPACE')
|
||||
|| !client.includes('IM_LOCALE_NAMESPACE = "dsh-im"')) {
|
||||
throw new Error('client bundle does not register the localized IM settings tab');
|
||||
const sourceSectionMarkers = [
|
||||
/ctx\.slots\.inject\(\s*["']settings\.section["']/u,
|
||||
/name\s*:\s*["']settings\.section["']/u,
|
||||
/id\s*:\s*["']xmanrui-dsh-im["']/u,
|
||||
/order\s*:\s*21\b/u,
|
||||
/label\s*:\s*\(\)\s*=>\s*t\(\s*["']IM机器人["']\s*\)/u,
|
||||
/locale\s*:\s*IM_LOCALE_NAMESPACE\b/u,
|
||||
];
|
||||
const bundleSectionPattern = /name\s*:\s*["']settings\.section["']\s*,\s*id\s*:\s*["']xmanrui-dsh-im["']\s*,\s*order\s*:\s*21\s*,\s*label\s*:\s*\(\)\s*=>\s*[$A-Z_a-z][$\w]*\(\s*["']IM(?:机器人|\\u673A\\u5668\\u4EBA)["']\s*\)\s*,\s*locale\s*:\s*(?:[$A-Z_a-z][$\w]*|["']dsh-im["'])/u;
|
||||
if (sourceSectionMarkers.some((pattern) => !pattern.test(clientEntrySource))
|
||||
|| !/IM_LOCALE_NAMESPACE\s*=\s*["']dsh-im["']/u.test(clientSources)
|
||||
|| !bundleSectionPattern.test(client)) {
|
||||
throw new Error('client bundle does not register the localized top-level IM settings section');
|
||||
}
|
||||
if ((client.match(/ctx\.slots\.inject\("settings\.plugins\.tab"/g) ?? []).length !== 1) {
|
||||
throw new Error('client bundle must register exactly one settings tab');
|
||||
if ((client.match(/\.slots\.inject\(\s*["']settings\.section["']/gu) ?? []).length !== 1) {
|
||||
throw new Error('client bundle must register exactly one top-level settings section');
|
||||
}
|
||||
if (client.includes('settings.plugins.tab') || clientSources.includes('settings.plugins.tab')) {
|
||||
throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
|
||||
}
|
||||
if (/role:\s*["']switch|type:\s*["']checkbox/.test(client)) {
|
||||
throw new Error('client bundle contains a channel enable switch');
|
||||
|
|
@ -115,7 +152,7 @@ if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(host)) {
|
|||
throw new Error('host bundle still imports an external channel plugin');
|
||||
}
|
||||
if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(
|
||||
manifestText + lockText + hostSource + clientSource,
|
||||
manifestText + lockText + hostSource + clientSources,
|
||||
)) {
|
||||
throw new Error('source or package metadata still depends on an external channel plugin');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ function artifactFailureText(fileName, error) {
|
|||
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
|
||||
switch (error?.code) {
|
||||
case 'artifact-permission-required':
|
||||
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { name });
|
||||
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { name });
|
||||
case 'artifact-too-large':
|
||||
return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
|
||||
case 'artifact-empty':
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|||
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
||||
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
||||
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
||||
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
||||
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
||||
|
||||
export function conversationKey(event) {
|
||||
const chatType = event?.message?.chat_type;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function connectionTestTarget(state) {
|
|||
|
||||
export function connectionTestMessage(botName, channelLabel = t('机器人')) {
|
||||
const name = cleanText(botName) ?? channelLabel;
|
||||
return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('这条消息由插件页面中的“{name}”机器人卡片发出。', { name })}`;
|
||||
return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('这条消息由「IM机器人」设置页中的“{name}”机器人卡片发出。', { name })}`;
|
||||
}
|
||||
|
||||
export function connectionTestTargetUnavailable(channelLabel = t('机器人')) {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ export default {
|
|||
'机器人正在移除或已重新接入,无法操作原会话的工作区。':
|
||||
'The bot is being removed or has been reconnected; the original session’s workspace cannot be changed.',
|
||||
'操作失败,请稍后重试。': 'The operation failed. Please try again later.',
|
||||
'结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。':
|
||||
'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the plugin page and scan the QR code. Complete any publishing approval Feishu requires, then try again.',
|
||||
'结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。':
|
||||
'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Complete any publishing approval Feishu requires, then try again.',
|
||||
'结果文件「{name}」超过飞书 30 MB 上限,未发送。':
|
||||
'The result file "{name}" exceeds the Feishu 30 MB limit and was not sent.',
|
||||
'结果文件「{name}」为空,飞书不允许发送空文件。':
|
||||
|
|
@ -28,8 +28,8 @@ export default {
|
|||
'The result file "{name}" was temporarily rate-limited by Feishu and could not be sent. Please try again later.',
|
||||
'结果文件「{name}」已生成,但暂时未能发送,请稍后重试。':
|
||||
'The result file "{name}" was generated but could not be sent right now. Please try again later.',
|
||||
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。':
|
||||
'Message processing failed. Please try again later. If the problem persists, check the connection status on the Feishu plugin page in DeepSeek Harness.',
|
||||
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的「IM机器人」设置页检查飞书连接状态。':
|
||||
'Message processing failed. Please try again later. If the problem persists, check the Feishu connection status on the IM Bot settings page in DeepSeek Harness.',
|
||||
'已开启全新 Harness 会话。': 'A brand-new Harness session has started.',
|
||||
'飞书机器人与 DeepSeek Harness 连接正常。':
|
||||
'The Feishu bot is connected to DeepSeek Harness and working normally.',
|
||||
|
|
@ -338,8 +338,8 @@ export default {
|
|||
'飞书机器人': 'Feishu bot',
|
||||
|
||||
// feishu/message-utils.mjs
|
||||
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。':
|
||||
'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the plugin page and scan the QR code. Publish a new version and complete any approval requested by Feishu, then resend the image.',
|
||||
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。':
|
||||
'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Publish a new version and complete any approval requested by Feishu, then resend the image.',
|
||||
|
||||
// feishu/feishu-runtime.mjs — callback probe notices
|
||||
'✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。':
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ export default {
|
|||
// connection-test.mjs
|
||||
'✅ DeepSeek Harness 连接测试成功':
|
||||
'✅ DeepSeek Harness connection test succeeded',
|
||||
'这条消息由插件页面中的“{name}”机器人卡片发出。':
|
||||
'This message was sent from the "{name}" bot card on the plugin page.',
|
||||
'这条消息由「IM机器人」设置页中的“{name}”机器人卡片发出。':
|
||||
'This message was sent from the "{name}" bot card on the IM Bot settings page.',
|
||||
'{channelLabel}尚未收到可用于测试的私聊消息。':
|
||||
'The {channelLabel} has not received a direct message that can be used for testing yet.',
|
||||
'机器人': 'bot',
|
||||
|
|
|
|||
|
|
@ -5,15 +5,11 @@ import test from 'node:test';
|
|||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/dingtalk/index.js', import.meta.url);
|
||||
const STYLES_URL = new URL('../../../plugin-src/client/channels/dingtalk/styles.js', import.meta.url);
|
||||
|
||||
test('standalone client exports a reusable settings component and registration', async () => {
|
||||
test('channel client exports a reusable settings component without a standalone settings registration', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /export const name = 'dingtalk-settings'/);
|
||||
assert.match(source, /export const inject = \['slots', 'connection'\]/);
|
||||
assert.match(source, /export function DingtalkSettingsTab\(\{ rpcCall \}\)/);
|
||||
assert.match(source, /export function apply\(ctx\)/);
|
||||
assert.match(source, /ctx\.connection\.rpc\.call\(DINGTALK_RPC_CHANNEL/);
|
||||
assert.match(source, /id: 'dingtalk'/);
|
||||
assert.match(source, /label: '钉钉'/);
|
||||
assert.doesNotMatch(source, /export function apply\(ctx\)/);
|
||||
assert.doesNotMatch(source, /settings\.plugins\.tab/);
|
||||
});
|
||||
|
||||
test('QR guidance describes the complete official DingTalk authorization flow', async () => {
|
||||
|
|
|
|||
|
|
@ -993,7 +993,7 @@ test('bridge tells users to grant im:message:readonly when Feishu rejects image
|
|||
assert.match(sent[0], /im:message:readonly/);
|
||||
assert.match(sent[0], /\/repair/);
|
||||
assert.match(sent[0], /发布新版本/);
|
||||
assert.match(sent[0], /插件页面/);
|
||||
assert.match(sent[0], /「IM机器人」设置页/);
|
||||
assert.match(sent[0], /补全权限/);
|
||||
assert.doesNotMatch(sent[0], /99991672|HTTP 400|secret-shaped|private\/path/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ test('Feishu image loading maps the missing message scope to an actionable error
|
|||
assert.equal(error.code, 'feishu-image-permission-required');
|
||||
assert.match(error.userMessage, /im:message:readonly/);
|
||||
assert.match(error.userMessage, /\/repair/);
|
||||
assert.match(error.userMessage, /插件页面/);
|
||||
assert.match(error.userMessage, /「IM机器人」设置页/);
|
||||
assert.match(error.userMessage, /补全权限/);
|
||||
assert.match(error.userMessage, /发布新版本/);
|
||||
assert.equal(error.cause, providerError);
|
||||
|
|
|
|||
|
|
@ -791,7 +791,7 @@ test('connection test uses the selected bot runtime and shared message copy', as
|
|||
await fx.controller.initialize();
|
||||
assert.deepEqual(await fx.controller.sendConnectionTest(healthy.id), { sent: true });
|
||||
assert.deepEqual(fx.runtimes.get(healthy.id)[0].sentTests, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
|
||||
]);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1378,7 +1378,7 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
|
|||
await controller.initialize();
|
||||
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
|
||||
assert.deepEqual(sent, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -664,7 +664,21 @@ test('every shipped Chinese client string has an English projection', async () =
|
|||
}
|
||||
});
|
||||
|
||||
test('client registers a live bilingual locale seat and directory picker for the IM settings tab', async () => {
|
||||
test('client source contains no legacy Plugins-tab settings registrations', async () => {
|
||||
const paths = (await readdir(CLIENT_SOURCE_DIRECTORY_URL, { recursive: true }))
|
||||
.filter((path) => path.endsWith('.js'));
|
||||
const sources = await Promise.all(paths.map(async (path) => ({
|
||||
path,
|
||||
source: await readFile(new URL(path, CLIENT_SOURCE_DIRECTORY_URL), 'utf8'),
|
||||
})));
|
||||
const legacy = sources
|
||||
.filter(({ source }) => source.includes('settings.plugins.tab'))
|
||||
.map(({ path }) => path);
|
||||
|
||||
assert.deepEqual(legacy, []);
|
||||
});
|
||||
|
||||
test('client registers one top-level bilingual IM settings section with a directory picker', async () => {
|
||||
const effects = [];
|
||||
const registrations = [];
|
||||
const dictionaries = [];
|
||||
|
|
@ -697,7 +711,7 @@ test('client registers a live bilingual locale seat and directory picker for the
|
|||
},
|
||||
slots: {
|
||||
inject(name, install) {
|
||||
assert.equal(name, 'settings.plugins.tab');
|
||||
assert.equal(name, 'settings.section');
|
||||
install();
|
||||
},
|
||||
register(options, component) {
|
||||
|
|
@ -717,8 +731,12 @@ test('client registers a live bilingual locale seat and directory picker for the
|
|||
assert.equal(dictionaries[0].namespace, IM_LOCALE_NAMESPACE);
|
||||
assert.deepEqual(Object.keys(dictionaries[0].value.en).sort(), Object.keys(dictionaries[0].value.zh).sort());
|
||||
assert.equal(registrations.length, 1);
|
||||
assert.equal(registrations[0].options.name, 'settings.section');
|
||||
assert.equal(registrations[0].options.id, 'xmanrui-dsh-im');
|
||||
assert.equal(registrations[0].options.order, 21);
|
||||
assert.equal(registrations[0].options.locale, IM_LOCALE_NAMESPACE);
|
||||
assert.equal(registrations[0].options.label(), 'IM bots');
|
||||
assert.equal(registrations[0].component, IMSettingsTab);
|
||||
|
||||
const injected = registrations[0].options.inject();
|
||||
const signal = new AbortController().signal;
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ const WEIXIN_FORBIDDEN = new Error(
|
|||
|
||||
test('creates a localhost recovery target for the known loopback transport 403', () => {
|
||||
const recovery = createLoopbackRecovery(WEIXIN_FORBIDDEN, {
|
||||
href: 'http://127.0.0.1:3080/settings?tab=plugins#im',
|
||||
href: 'http://127.0.0.1:3080/settings',
|
||||
});
|
||||
|
||||
assert.deepEqual(recovery, {
|
||||
url: 'http://localhost:3080/settings?tab=plugins#im',
|
||||
url: 'http://localhost:3080/settings',
|
||||
origin: 'http://localhost:3080',
|
||||
});
|
||||
assert.deepEqual(createLoopbackRecovery(WEIXIN_FORBIDDEN, {
|
||||
|
|
@ -103,7 +103,7 @@ test('RPC wrappers preserve successful results, unrelated errors, and missing op
|
|||
|
||||
test('recovery notice exposes one localized navigation action', () => {
|
||||
const recovery = {
|
||||
url: 'http://localhost:3080/settings?tab=plugins#im',
|
||||
url: 'http://localhost:3080/settings',
|
||||
origin: 'http://localhost:3080',
|
||||
};
|
||||
const element = LoopbackRecoveryNotice({ recovery, onNavigate() {} });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue