feat: send test messages during bot connection checks

This commit is contained in:
xmanrui 2026-08-19 03:52:52 +08:00
parent 94da24538f
commit fb61c4235f
78 changed files with 3460 additions and 210 deletions

View file

@ -85,6 +85,16 @@ function normalizeError(value, fallbackCode, fallbackMessage) {
};
}
function normalizeTestMessage(value) {
if (!isRecord(value)) return null;
if (value.sent === true) return { sent: true };
if (value.sent !== false) return null;
const code = value.code === 'test-target-unavailable'
? 'test-target-unavailable'
: 'test-message-failed';
return { sent: false, code };
}
export function unwrapRpcResult(result) {
if (!isRecord(result) || typeof result.ok !== 'boolean') {
throw new Error('钉钉服务返回了无法识别的响应');
@ -189,9 +199,18 @@ export function normalizeSnapshot(value) {
connected: bots.filter((bot) => bot.connected).length,
},
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
testMessage: normalizeTestMessage(source.testMessage),
};
}
export function connectionTestFeedback(result) {
if (result?.sent === true) return '钉钉连接检查完成,测试消息已发送。';
if (result?.code === 'test-target-unavailable') {
return '钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。';
}
return result ? '钉钉连接检查完成,但测试消息发送失败。' : null;
}
export function presentError(error) {
return {
code: safeErrorCode(error?.code, 'DINGTALK_ERROR'),

View file

@ -7,6 +7,7 @@ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import {
DINGTALK_ENDPOINTS,
DINGTALK_RPC_CHANNEL,
connectionTestFeedback,
formatRemaining,
normalizeProvisioning,
normalizeSnapshot,
@ -208,6 +209,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
export function AccountCard({
account,
busy,
feedback,
removing,
onReconnect,
onWorkspaceSave,
@ -241,6 +243,10 @@ export function AccountCard({
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
feedback ? h('div', {
className: 'ddt-summary dim-cardSummary',
role: 'status',
}, feedback) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
@ -262,6 +268,7 @@ function AccountList(props) {
h(AccountCard, {
account,
busy: props.busyByBot[account.botId],
feedback: props.feedbackByBot[account.botId]?.message,
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
@ -280,6 +287,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [feedbackByBot, setFeedbackByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const [credentialOpen, setCredentialOpen] = React.useState(false);
const [credentialError, setCredentialError] = React.useState(null);
@ -325,6 +333,22 @@ export function DingtalkSettingsTab({ rpcCall }) {
}
}, []);
const discardStaleFeedback = React.useCallback((snapshot) => {
const botsById = new Map(snapshot.bots.map((bot) => [bot.botId, bot]));
setFeedbackByBot((current) => {
let changed = false;
const next = { ...current };
for (const [botId, feedback] of Object.entries(next)) {
const bot = botsById.get(botId);
if (!bot || (feedback.clearWhenDisconnected && (!bot.connected || bot.error))) {
delete next[botId];
changed = true;
}
}
return changed ? next : current;
});
}, []);
const focusAddButton = React.useCallback(() => {
if (!mountedRef.current) return;
if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current);
@ -366,6 +390,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
revision: snapshot.revision,
error: null,
});
discardStaleFeedback(snapshot);
if (restoreProvisioning && snapshot.provisioning) {
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
? {
@ -386,7 +411,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
}));
return undefined;
}
}, [invoke, workspaceFence]);
}, [discardStaleFeedback, invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -480,6 +505,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
revision: snapshot.revision,
error: null,
});
discardStaleFeedback(snapshot);
}
setCredentialOpen(false);
announce('钉钉机器人凭据已绑定。');
@ -490,7 +516,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBusy(false);
}
}, [announce, invoke, loadStatus, workspaceFence]);
}, [announce, discardStaleFeedback, invoke, loadStatus, workspaceFence]);
const cancelProvisioning = React.useCallback(async () => {
if (!mountedRef.current) return;
@ -595,6 +621,13 @@ export function DingtalkSettingsTab({ rpcCall }) {
if (!mountedRef.current) return undefined;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, operation);
if (operation === 'reconnect') {
setFeedbackByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
}
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (!mountedRef.current) return undefined;
@ -606,28 +639,50 @@ export function DingtalkSettingsTab({ rpcCall }) {
revision: snapshot.revision,
error: null,
});
discardStaleFeedback(snapshot);
}
announce(typeof success === 'function' ? success(snapshot) : success);
const successMessage = typeof success === 'function' ? success(snapshot) : success;
if (operation === 'reconnect') {
setFeedbackByBot((current) => ({
...current,
[account.botId]: {
message: successMessage,
clearWhenDisconnected: snapshot.testMessage?.sent === true,
},
}));
}
announce(successMessage);
return snapshot;
} catch (error) {
if (!mountedRef.current) return undefined;
announce(`操作失败:${presentError(error).message}`);
const failureMessage = operation === 'reconnect'
? '连接检查失败,请稍后重试。'
: `操作失败:${presentError(error).message}`;
if (operation === 'reconnect') {
setFeedbackByBot((current) => ({
...current,
[account.botId]: { message: failureMessage, clearWhenDisconnected: false },
}));
}
announce(failureMessage);
return undefined;
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true, restoreProvisioning: false });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
}, [announce, discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
const reconnect = React.useCallback((account) => runBotAction({
account,
operation: 'reconnect',
endpoint: DINGTALK_ENDPOINTS.reconnectBot,
payload: { botId: account.botId },
success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected
? '钉钉连接检查完成。'
: '钉钉仍未连接,插件会继续自动重试。',
payload: { botId: account.botId, sendTest: true },
success: (snapshot) => {
const refreshed = snapshot?.bots.find((bot) => bot.botId === account.botId);
if (!refreshed?.connected) return '钉钉仍未连接,插件会继续自动重试。';
return connectionTestFeedback(snapshot.testMessage) ?? '钉钉连接检查完成。';
},
}), [runBotAction]);
const saveWorkspace = React.useCallback(async (account, workspace) => {
@ -646,13 +701,14 @@ export function DingtalkSettingsTab({ rpcCall }) {
revision: snapshot.revision,
error: null,
});
discardStaleFeedback(snapshot);
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
}, [discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
const remove = React.useCallback(async (account) => {
const snapshot = await runBotAction({
@ -738,6 +794,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
? h(AccountList, {
bots: model.bots,
busyByBot,
feedbackByBot,
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,

View file

@ -330,6 +330,16 @@ function formatCheckedTime(timestamp) {
}
}
function connectionTestNotice(value) {
if (value?.testMessage?.sent === true) {
return '测试消息已发送,请到飞书会话中确认。';
}
if (value?.testMessage?.code === 'test-target-unavailable') {
return '连接检查完成,但当前没有可用的测试消息接收人。';
}
return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
}
function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
const cancelRef = React.useRef(null);
const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
@ -364,6 +374,7 @@ export function BotCard({
connection,
busy,
actionError,
testNotice,
removing,
onReconnect,
onWorkspaceSave,
@ -420,6 +431,10 @@ export function BotCard({
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
summary) : null,
testNotice ? h("div", {
className: "bxf-healthSummary dim-cardSummary",
role: "status",
}, testNotice) : null,
h("div", { className: "bxf-actions bxf-botActions dim-cardActions" },
h(Button, {
className: "dim-cardAction", onClick: onReconnect,
@ -454,6 +469,7 @@ function BotList(props) {
connection: bot,
busy: props.busyByBot[bot.botId],
actionError: props.errorsByBot[bot.botId],
testNotice: props.testNoticesByBot[bot.botId],
removing: props.removeTargetId === bot.botId,
onReconnect: () => props.onReconnect(bot),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
@ -527,6 +543,7 @@ export function FeishuSettingsTab({ rpcCall }) {
const [credentialError, setCredentialError] = React.useState(null);
const [busyByBot, setBusyByBot] = React.useState({});
const [errorsByBot, setErrorsByBot] = React.useState({});
const [testNoticesByBot, setTestNoticesByBot] = React.useState({});
const [removeTargetId, setRemoveTargetId] = React.useState(null);
const [announcement, setAnnouncement] = React.useState("");
const [now, setNow] = React.useState(() => Date.now());
@ -826,8 +843,14 @@ export function FeishuSettingsTab({ rpcCall }) {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(botId, "reconnect");
setBotError(botId, null);
setTestNoticesByBot((current) => {
const next = { ...current };
delete next[botId];
return next;
});
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
const value = await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId, sendTest: true });
const snapshot = normalizeBotsSnapshot(value);
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
@ -839,12 +862,18 @@ export function FeishuSettingsTab({ rpcCall }) {
error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
throw error;
}
announce(connection.connected
const testNotice = connectionTestNotice(value);
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setTestNoticesByBot((current) => ({ ...current, [botId]: testNotice }));
}
announce(testNotice ?? (connection.connected
? `${bot.name}连接检查完成。`
: `${bot.name}已重新连接。`);
: `${bot.name}已重新连接。`));
} catch (error) {
setBotError(botId, error);
announce(`${bot.name}操作失败,请查看机器人状态。`);
const failure = new Error("连接检查失败,请稍后重试。");
failure.code = error?.code;
setBotError(botId, failure);
announce(failure.message);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@ -995,6 +1024,7 @@ export function FeishuSettingsTab({ rpcCall }) {
bots: model.bots,
busyByBot,
errorsByBot,
testNoticesByBot,
removeTargetId,
onReconnect: (bot) => void reconnectOneBot(bot),
onWorkspaceSave: saveWorkspace,

View file

@ -13,6 +13,7 @@ export const QQ_ENDPOINTS = Object.freeze({
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
const TEST_MESSAGE_CODES = new Set(['test-target-unavailable', 'test-message-failed']);
const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
function isRecord(value) {
@ -96,18 +97,38 @@ function normalizeBot(value) {
};
}
function normalizeTestMessage(value) {
if (!isRecord(value) || typeof value.sent !== 'boolean') return undefined;
if (value.sent) return { sent: true };
const code = text(value.code, 'test-message-failed', 80);
return {
sent: false,
code: TEST_MESSAGE_CODES.has(code) ? code : 'test-message-failed',
};
}
export function normalizeSnapshot(value) {
const source = isRecord(value?.snapshot) ? value.snapshot : value;
if (!isRecord(source) || !Array.isArray(source.bots)) throw new Error('QQ 服务没有返回有效的机器人列表');
const bots = source.bots.map(normalizeBot).filter(Boolean);
const testMessage = normalizeTestMessage(source.testMessage);
return {
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
...(testMessage ? { testMessage } : {}),
};
}
export function connectionTestFeedback(result) {
if (result?.sent === true) return '测试消息已发送,请到对应机器人会话中确认。';
if (result?.code === 'test-target-unavailable') {
return '连接检查完成。请先私聊机器人发送 /status,再重新检查。';
}
return result ? '连接检查完成,但测试消息发送失败。' : null;
}
export function presentError(error) {
return {
code: text(error?.code, 'QQ_ERROR', 80),

View file

@ -9,6 +9,7 @@ import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
QQ_ENDPOINTS,
QQ_RPC_CHANNEL,
connectionTestFeedback,
formatRemaining,
normalizeProvisioning,
normalizeSnapshot,
@ -151,6 +152,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
export function AccountCard({
account,
busy,
feedback,
removing,
onReconnect,
onWorkspaceSave,
@ -180,6 +182,11 @@ export function AccountCard({
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
feedback ? h('div', {
className: 'ddt-summary dim-cardSummary',
role: 'status',
'aria-live': 'polite',
}, feedback) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
@ -193,6 +200,7 @@ export function QqSettingsTab({ rpcCall }) {
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [feedbackByBot, setFeedbackByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const [credentialOpen, setCredentialOpen] = React.useState(false);
const [credentialError, setCredentialError] = React.useState(null);
@ -361,6 +369,35 @@ export function QqSettingsTab({ rpcCall }) {
}
}, [invoke, loadStatus, workspaceFence]);
const reconnect = React.useCallback(async (account) => {
setFeedbackByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
try {
const snapshot = await botAction(
account,
'reconnect',
QQ_ENDPOINTS.reconnectBot,
{ botId: account.botId, sendTest: true },
);
if (mounted.current) {
setFeedbackByBot((current) => ({
...current,
[account.botId]: connectionTestFeedback(snapshot?.testMessage),
}));
}
} catch {
if (mounted.current) {
setFeedbackByBot((current) => ({
...current,
[account.botId]: '连接检查失败,请稍后重试。',
}));
}
}
}, [botAction]);
let provisionView = null;
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请 QQ 二维码…');
else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
@ -378,8 +415,9 @@ export function QqSettingsTab({ rpcCall }) {
h('li', { key: account.botId }, h(AccountCard, {
account,
busy: busyByBot[account.botId],
feedback: feedbackByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(account, 'reconnect', QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
onReconnect: () => void reconnect(account),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',

View file

@ -30,6 +30,14 @@ function checkedTime(value) {
}
}
function connectionTestNotice(value) {
if (value?.testMessage?.sent === true) return '测试消息已发送,请到对应机器人会话中确认。';
if (value?.testMessage?.code === 'test-target-unavailable') {
return '连接检查完成。请先私聊机器人发送 /status,再重新检查。';
}
return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
}
export function createTokenChannelSettings(definition) {
const {
channel,
@ -53,7 +61,7 @@ export function createTokenChannelSettings(definition) {
emptyActionLabel = '填写 Bot Token',
} = definition;
function AccountCard({ account, busy, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
@ -82,6 +90,7 @@ export function createTokenChannelSettings(definition) {
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
testNotice ? h('div', { className: 'ddt-summary dim-cardSummary', role: 'status' }, testNotice) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
h(Button, {
className: 'dim-cardAction',
@ -111,6 +120,7 @@ export function createTokenChannelSettings(definition) {
const [credentialError, setCredentialError] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [testNoticeByBot, setTestNoticeByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
@ -198,10 +208,25 @@ export function createTokenChannelSettings(definition) {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload));
const value = await invoke(endpoint, payload);
const snapshot = api.normalizeSnapshot(value);
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
if (mounted.current && operation === 'reconnect') {
setTestNoticeByBot((current) => ({
...current,
[account.botId]: connectionTestNotice(value),
}));
}
} catch (error) {
if (operation !== 'reconnect') throw error;
if (mounted.current) {
setTestNoticeByBot((current) => ({
...current,
[account.botId]: '连接检查失败,请稍后重试。',
}));
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
@ -221,12 +246,13 @@ export function createTokenChannelSettings(definition) {
h('li', { key: account.botId }, h(AccountCard, {
account,
busy: busyByBot[account.botId],
testNotice: testNoticeByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(
account,
'reconnect',
endpoints.reconnectBot,
{ botId: account.botId },
{ botId: account.botId, sendTest: true },
),
onWorkspaceSave: (workspace) => botAction(
account,

View file

@ -34,6 +34,16 @@ function timestamp(value) {
return Number.isNaN(parsed) ? undefined : parsed;
}
function normalizeTestMessage(value) {
if (!isRecord(value)) return null;
if (value.sent === true) return { sent: true };
if (value.sent !== false) return null;
const code = value.code === 'test-target-unavailable'
? 'test-target-unavailable'
: 'test-message-failed';
return { sent: false, code };
}
export function unwrapRpcResult(result) {
if (!isRecord(result) || typeof result.ok !== 'boolean') throw new Error('企业微信服务返回了无法识别的响应');
if (!result.ok) {
@ -105,6 +115,7 @@ export function normalizeSnapshot(value) {
bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
testMessage: normalizeTestMessage(source.testMessage),
};
}

View file

@ -151,6 +151,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
export function AccountCard({
account,
busy,
feedback,
removing,
onReconnect,
onWorkspaceSave,
@ -180,6 +181,11 @@ export function AccountCard({
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
feedback ? h('div', {
className: 'ddt-summary dim-cardSummary',
role: 'status',
'aria-live': 'polite',
}, feedback) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
@ -193,13 +199,31 @@ export function WecomSettingsTab({ rpcCall }) {
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [feedbackByBot, setFeedbackByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const [credentialOpen, setCredentialOpen] = React.useState(false);
const [credentialError, setCredentialError] = React.useState(null);
const [notice, setNotice] = React.useState('');
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React.useRef(null);
const noticeFrameRef = React.useRef(null);
const announce = React.useCallback((message) => {
if (!mounted.current) return;
if (noticeFrameRef.current !== null) {
window.cancelAnimationFrame(noticeFrameRef.current);
noticeFrameRef.current = null;
}
setNotice('');
if (message) {
noticeFrameRef.current = window.requestAnimationFrame(() => {
noticeFrameRef.current = null;
if (mounted.current) setNotice(message);
});
}
}, []);
React.useEffect(() => {
const disposeDingtalk = installDingtalkStyles();
@ -207,6 +231,10 @@ export function WecomSettingsTab({ rpcCall }) {
mounted.current = true;
return () => {
mounted.current = false;
if (noticeFrameRef.current !== null) {
window.cancelAnimationFrame(noticeFrameRef.current);
noticeFrameRef.current = null;
}
disposeWecom();
disposeDingtalk();
};
@ -361,6 +389,46 @@ export function WecomSettingsTab({ rpcCall }) {
}
}, [invoke, loadStatus, workspaceFence]);
const reconnect = React.useCallback(async (account) => {
setFeedbackByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
try {
const snapshot = await botAction(
account,
'reconnect',
WECOM_ENDPOINTS.reconnectBot,
{ botId: account.botId, sendTest: true },
);
if (!snapshot) return;
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
let feedback;
if (!refreshed?.connected) {
feedback = '企业微信仍未连接,插件会继续自动重试。';
} else if (snapshot.testMessage?.sent) {
feedback = '企业微信连接检查完成,测试消息已发送。';
} else if (snapshot.testMessage?.code === 'test-target-unavailable') {
feedback = '企业微信连接检查完成。请先私聊机器人发送 /status,再重新检查。';
} else if (snapshot.testMessage) {
feedback = '企业微信连接检查完成,但测试消息发送失败。';
} else {
feedback = '企业微信连接检查完成。';
}
if (mounted.current) {
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
}
announce(feedback);
} catch {
const feedback = '连接检查失败,请稍后重试。';
if (mounted.current) {
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
}
announce(feedback);
}
}, [announce, botAction]);
let provisionView = null;
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…');
else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
@ -378,8 +446,9 @@ export function WecomSettingsTab({ rpcCall }) {
h('li', { key: account.botId }, h(AccountCard, {
account,
busy: busyByBot[account.botId],
feedback: feedbackByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
onReconnect: () => void reconnect(account),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
@ -419,6 +488,7 @@ export function WecomSettingsTab({ rpcCall }) {
credentialOpen,
addButtonRef,
}),
h('div', { className: 'ddt-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
model.phase === 'loading' ? h(LoadingView)
: model.phase === 'error'
? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取企业微信机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))

View file

@ -35,6 +35,16 @@ function timestamp(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function normalizeTestMessage(value) {
if (!isRecord(value)) return null;
if (value.sent === true) return { sent: true };
if (value.sent !== false) return null;
const code = value.code === 'test-target-unavailable'
? 'test-target-unavailable'
: 'test-message-failed';
return { sent: false, code };
}
export function unwrapRpcResult(result) {
if (!isRecord(result) || typeof result.ok !== 'boolean') {
throw new Error('微信服务返回了无法识别的响应');
@ -142,6 +152,7 @@ export function normalizeSnapshot(value) {
connected: bots.filter((bot) => bot.connected).length,
},
provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
testMessage: normalizeTestMessage(value.testMessage),
};
}

View file

@ -198,6 +198,7 @@ function checkedTime(timestamp) {
export function AccountCard({
account,
busy,
feedback,
removing,
onReconnect,
onWorkspaceSave,
@ -229,6 +230,11 @@ export function AccountCard({
}),
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
feedback ? h('div', {
className: 'dxw-summary dim-cardSummary',
role: 'status',
'aria-live': 'polite',
}, feedback) : null,
h('div', { className: 'dxw-actions dim-cardActions' },
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
@ -250,6 +256,7 @@ function AccountList(props) {
h(AccountCard, {
account,
busy: props.busyByBot[account.botId],
feedback: props.feedbackByBot[account.botId],
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
@ -282,6 +289,7 @@ export function WeixinSettingsTab({ rpcCall }) {
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [feedbackByBot, setFeedbackByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const [notice, setNotice] = React.useState('');
const [now, setNow] = React.useState(() => Date.now());
@ -496,15 +504,42 @@ export function WeixinSettingsTab({ rpcCall }) {
const reconnect = React.useCallback(async (account) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'reconnect');
setFeedbackByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
const snapshot = normalizeSnapshot(await invoke(
WEIXIN_ENDPOINTS.reconnectBot,
{ botId: account.botId, sendTest: true },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
}
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
announce(refreshed?.connected ? '微信连接检查完成。' : '微信仍未连接,插件会继续自动重试。');
} catch (error) {
announce(`连接检查失败:${presentError(error).message}`);
let feedback;
if (!refreshed?.connected) {
feedback = '微信仍未连接,插件会继续自动重试。';
} else if (snapshot.testMessage?.sent) {
feedback = '微信连接检查完成,测试消息已发送。';
} else if (snapshot.testMessage?.code === 'test-target-unavailable') {
feedback = '微信连接检查完成。请先私聊机器人发送 /status,再重新检查。';
} else if (snapshot.testMessage) {
feedback = '微信连接检查完成,但测试消息发送失败。';
} else {
feedback = '微信连接检查完成。';
}
if (mountedRef.current) {
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
}
announce(feedback);
} catch {
const feedback = '连接检查失败,请稍后重试。';
if (mountedRef.current) {
setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
}
announce(feedback);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@ -611,6 +646,7 @@ export function WeixinSettingsTab({ rpcCall }) {
? h(AccountList, {
bots: model.bots,
busyByBot,
feedbackByBot,
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,

View file

@ -43,6 +43,16 @@ function checkedTime(value) {
}
}
function connectionTestNotice(value) {
if (value?.testMessage?.sent === true) {
return '测试消息已发送,请到 WhatsApp 自聊会话中确认。';
}
if (value?.testMessage?.code === 'test-target-unavailable') {
return '连接检查完成,但当前没有可用的 WhatsApp 自聊目标。';
}
return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
}
function Heading({ totals, busy, onAdd, addButtonRef }) {
return h('div', { className: 'ddt-heading' },
h('div', { className: 'ddt-tools' },
@ -159,6 +169,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
export function WhatsappAccountCard({
account,
busy,
testNotice,
removing,
onReconnect,
onWorkspaceSave,
@ -196,6 +207,10 @@ export function WhatsappAccountCard({
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
testNotice ? h('div', {
className: 'ddt-summary dim-cardSummary',
role: 'status',
}, testNotice) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
h(Button, {
className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy),
@ -218,6 +233,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [testNoticeByBot, setTestNoticeByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
@ -365,10 +381,32 @@ export function WhatsappSettingsTab({ rpcCall }) {
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
if (operation === 'reconnect') {
setTestNoticeByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
}
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
const value = await invoke(endpoint, payload);
const snapshot = normalizeSnapshot(value);
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (operation === 'reconnect') {
setTestNoticeByBot((current) => ({
...current,
[account.botId]: connectionTestNotice(value),
}));
}
}
} catch (error) {
if (operation !== 'reconnect') throw error;
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setTestNoticeByBot((current) => ({
...current,
[account.botId]: '连接检查失败,请稍后重试。',
}));
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
@ -389,12 +427,13 @@ export function WhatsappSettingsTab({ rpcCall }) {
h('li', { key: account.botId }, h(WhatsappAccountCard, {
account,
busy: busyByBot[account.botId],
testNotice: testNoticeByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(
account,
'reconnect',
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId: account.botId },
{ botId: account.botId, sendTest: true },
),
onWorkspaceSave: (workspace) => botAction(
account,

View file

@ -75,6 +75,23 @@ const EN = Object.freeze({
'刚刚': 'Just now',
'检查连接': 'Check connection',
'检查中…': 'Checking…',
'连接检查失败,请稍后重试。': 'Connection check failed. Try again later.',
'测试消息已发送,请到对应机器人会话中确认。': 'Test message sent. Check the matching bot conversation.',
'连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'Connection check completed. Send /status to the bot in a direct message, then check again.',
'连接检查完成,但测试消息发送失败。': 'Connection check completed, but the test message could not be sent.',
'测试消息已发送,请到飞书会话中确认。': 'Test message sent. Check the Feishu conversation.',
'连接检查完成,但当前没有可用的测试消息接收人。': 'Connection check completed, but no test-message recipient is available.',
'测试消息已发送,请到 WhatsApp 自聊会话中确认。': 'Test message sent. Check the WhatsApp self-chat.',
'连接检查完成,但当前没有可用的 WhatsApp 自聊目标。': 'Connection check completed, but no WhatsApp self-chat target is available.',
'钉钉连接检查完成,测试消息已发送。': 'DingTalk connection check completed and the test message was sent.',
'钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'DingTalk connection check completed. Send /status to the bot in a direct message, then check again.',
'钉钉连接检查完成,但测试消息发送失败。': 'DingTalk connection check completed, but the test message could not be sent.',
'微信连接检查完成,测试消息已发送。': 'WeChat connection check completed and the test message was sent.',
'微信连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'WeChat connection check completed. Send /status to the bot in a direct message, then check again.',
'微信连接检查完成,但测试消息发送失败。': 'WeChat connection check completed, but the test message could not be sent.',
'企业微信连接检查完成,测试消息已发送。': 'WeCom connection check completed and the test message was sent.',
'企业微信连接检查完成。请先私聊机器人发送 /status,再重新检查。': 'WeCom connection check completed. Send /status to the bot in a direct message, then check again.',
'企业微信连接检查完成,但测试消息发送失败。': 'WeCom connection check completed, but the test message could not be sent.',
'重试连接': 'Reconnect',
'重试中…': 'Retrying…',
'移除接入': 'Remove connection',

View file

@ -1,6 +1,10 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import {
connectionTestTargetUnavailable,
publicConnectionTestResult,
} from '../../../../src/channels/shared/connection-test.mjs';
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
export const DINGTALK_ENDPOINTS = Object.freeze({
@ -69,9 +73,11 @@ function payloadFailure(endpoint, payload) {
: 'bot.bind-credentials requires Client ID and Client Secret.';
}
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId)
return exactKeys(payload, ['botId', 'sendTest'])
&& validId(payload.botId)
&& (payload.sendTest === undefined || payload.sendTest === true)
? null
: 'bot.reconnect requires a botId.';
: 'bot.reconnect requires a botId and optional sendTest=true.';
}
if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
@ -210,7 +216,27 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
} else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
const snapshot = await controller.reconnectBot(payload.botId);
if (signal?.aborted) return cancelled();
let testMessage;
if (payload.sendTest === true) {
const connected = snapshot?.bots?.some(
(bot) => bot?.botId === payload.botId && bot?.connected === true,
);
if (!connected || typeof controller.sendConnectionTest !== 'function') {
testMessage = publicConnectionTestResult(
connectionTestTargetUnavailable('钉钉机器人'),
);
} else {
try {
await controller.sendConnectionTest(payload.botId);
testMessage = publicConnectionTestResult();
} catch (error) {
testMessage = publicConnectionTestResult(error);
}
}
}
value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
} else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
} else if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {

View file

@ -1,4 +1,5 @@
import QRCode from 'qrcode';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import {
@ -258,8 +259,14 @@ function validPayload(endpoint, payload) {
? null
: 'Disconnect requires removeCredentials=true.';
}
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot
|| endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
return hasOnlyKeys(payload, new Set(['botId', 'sendTest']))
&& safeOpaqueId(payload.botId)
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
? null
: 'A valid botId and optional sendTest flag are required.';
}
if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
return hasOnlyKeys(payload, new Set(['botId'])) && safeOpaqueId(payload.botId)
? null
: 'A single valid botId is required.';
@ -431,7 +438,31 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
value = await toPublicFeishuStatus(await controller.disconnect(), { encodeQr: cachedEncodeQr });
} else if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
if (typeof controller.reconnectBot !== 'function') throw new Error('Multi-bot reconnect is unavailable');
value = await toPublicFeishuStatus(await controller.reconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
const checked = await controller.reconnectBot(payload.botId);
if (signal?.aborted) return cancelled();
value = await toPublicFeishuStatus(checked, { encodeQr: cachedEncodeQr });
if (payload.sendTest === true) {
let testError = null;
const connected = checked?.bots?.some(
(bot) => bot?.botId === payload.botId && bot.connected === true,
) === true;
if (!connected) {
testError = new Error('Feishu bot is not connected');
testError.code = 'test-target-unavailable';
} else {
try {
if (typeof controller.sendConnectionTest !== 'function') {
const unavailable = new Error('Connection test is unavailable');
unavailable.code = 'test-target-unavailable';
throw unavailable;
}
await controller.sendConnectionTest(payload.botId);
} catch (error) {
testError = error;
}
}
value = { ...value, testMessage: publicConnectionTestResult(testError) };
}
} else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
if (typeof controller.disconnectBot !== 'function') throw new Error('Multi-bot disconnect is unavailable');
value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr });

View file

@ -1,4 +1,8 @@
import QRCode from 'qrcode';
import {
connectionTestTargetUnavailable,
publicConnectionTestResult,
} from '../../../../src/channels/shared/connection-test.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
@ -53,7 +57,10 @@ function payloadFailure(endpoint, payload) {
? null : 'bot.bind-credentials requires AppID and AppSecret.';
}
if (endpoint === QQ_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
return exactKeys(payload, ['botId', 'sendTest'])
&& validId(payload.botId)
&& (payload.sendTest === undefined || payload.sendTest === true)
? null : 'bot.reconnect requires a botId and accepts only sendTest=true.';
}
if (endpoint === QQ_ENDPOINTS.deleteBot) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
@ -125,7 +132,33 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
} else if (endpoint === QQ_ENDPOINTS.bindCredentials) {
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
const snapshot = await controller.reconnectBot(payload.botId);
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
let testMessage;
if (payload.sendTest === true) {
const connected = snapshot?.bots?.some(
(bot) => bot?.botId === payload.botId && bot?.connected === true,
) === true;
if (!connected || typeof controller.sendConnectionTest !== 'function') {
testMessage = publicConnectionTestResult(
connectionTestTargetUnavailable('QQ机器人'),
);
} else {
let testError = null;
try {
await controller.sendConnectionTest(payload.botId);
} catch (error) {
testError = error;
}
testMessage = publicConnectionTestResult(testError);
}
}
value = await publicStatus({
...snapshot,
...(testMessage ? { testMessage } : {}),
}, cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(

View file

@ -1,4 +1,5 @@
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import {
publicWorkspaceError,
SET_WORKSPACE_ENDPOINT,
@ -44,7 +45,8 @@ function payloadFailure(endpoint, payload) {
? null : 'bot.bind-credentials requires a Bot Token.';
}
if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId)
return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
? null : 'bot.reconnect requires a botId.';
}
if (endpoint === TOKEN_BOT_ENDPOINTS.deleteBot) {
@ -105,6 +107,28 @@ export function createTokenBotRpcHandler(controller, { channel }) {
value = await controller.bindCredentials(payload);
} else if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
value = await controller.reconnectBot(payload.botId);
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
if (payload.sendTest === true) {
let testError = null;
try {
if (value?.bots?.find((bot) => bot?.botId === payload.botId)?.connected !== true) {
const unavailable = new Error('Bot is not connected');
unavailable.code = 'test-target-unavailable';
throw unavailable;
}
if (typeof controller.sendConnectionTest !== 'function') {
const unavailable = new Error('Connection test is unavailable');
unavailable.code = 'test-target-unavailable';
throw unavailable;
}
await controller.sendConnectionTest(payload.botId);
} catch (error) {
testError = error;
}
value = { ...value, testMessage: publicConnectionTestResult(testError) };
}
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await controller.updateWorkspace(payload.botId, payload.workspace);

View file

@ -1,4 +1,5 @@
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import {
publicWorkspaceError,
SET_WORKSPACE_ENDPOINT,
@ -53,7 +54,8 @@ function payloadFailure(endpoint, payload) {
? null : 'bot.bind-credentials requires xoxb Bot Token and xapp App Token.';
}
if (endpoint === SLACK_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId)
return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
? null : 'bot.reconnect requires a botId.';
}
if (endpoint === SLACK_ENDPOINTS.deleteBot) {
@ -114,7 +116,31 @@ export function createSlackRpcHandler(controller) {
let value;
if (endpoint === SLACK_ENDPOINTS.status) value = await controller.status();
else if (endpoint === SLACK_ENDPOINTS.bindCredentials) value = await controller.bindCredentials(payload);
else if (endpoint === SLACK_ENDPOINTS.reconnectBot) value = await controller.reconnectBot(payload.botId);
else if (endpoint === SLACK_ENDPOINTS.reconnectBot) {
value = await controller.reconnectBot(payload.botId);
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
if (payload.sendTest === true) {
let testError = null;
try {
if (value?.bots?.find((bot) => bot?.botId === payload.botId)?.connected !== true) {
const unavailable = new Error('Bot is not connected');
unavailable.code = 'test-target-unavailable';
throw unavailable;
}
if (typeof controller.sendConnectionTest !== 'function') {
const unavailable = new Error('Connection test is unavailable');
unavailable.code = 'test-target-unavailable';
throw unavailable;
}
await controller.sendConnectionTest(payload.botId);
} catch (error) {
testError = error;
}
value = { ...value, testMessage: publicConnectionTestResult(testError) };
}
}
else if (endpoint === SLACK_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await controller.updateWorkspace(payload.botId, payload.workspace);

View file

@ -1,6 +1,10 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import {
connectionTestTargetUnavailable,
publicConnectionTestResult,
} from '../../../../src/channels/shared/connection-test.mjs';
export const WECOM_RPC_CHANNEL = '/wecom';
export const WECOM_ENDPOINTS = Object.freeze({
@ -53,7 +57,10 @@ function payloadFailure(endpoint, payload) {
? null : 'bot.bind-credentials requires Bot ID and Secret.';
}
if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
return exactKeys(payload, ['botId', 'sendTest'])
&& validId(payload.botId)
&& (payload.sendTest === undefined || payload.sendTest === true)
? null : 'bot.reconnect requires a botId and optional sendTest=true.';
}
if (endpoint === WECOM_ENDPOINTS.deleteBot) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
@ -130,7 +137,29 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
} else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
const snapshot = await controller.reconnectBot(payload.botId);
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
let testMessage;
if (payload.sendTest === true) {
const connected = snapshot?.bots?.some(
(bot) => bot?.botId === payload.botId && bot?.connected === true,
);
if (!connected || typeof controller.sendConnectionTest !== 'function') {
testMessage = publicConnectionTestResult(
connectionTestTargetUnavailable('企业微信机器人'),
);
} else {
try {
await controller.sendConnectionTest(payload.botId);
testMessage = publicConnectionTestResult();
} catch (error) {
testMessage = publicConnectionTestResult(error);
}
}
}
value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(

View file

@ -5,6 +5,10 @@ import {
SET_WORKSPACE_ENDPOINT,
validWorkspacePayload,
} from '../shared/workspace-rpc.mjs';
import {
connectionTestTargetUnavailable,
publicConnectionTestResult,
} from '../../../../src/channels/shared/connection-test.mjs';
export const WEIXIN_RPC_CHANNEL = '/weixin';
export const WEIXIN_ENDPOINTS = Object.freeze({
@ -55,9 +59,11 @@ function payloadFailure(endpoint, payload) {
: 'provision.verify requires an attemptId and a 4-to-8-digit code.';
}
if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId)
return exactKeys(payload, ['botId', 'sendTest'])
&& validId(payload.botId)
&& (payload.sendTest === undefined || payload.sendTest === true)
? null
: 'bot.reconnect requires a botId.';
: 'bot.reconnect requires a botId and optional sendTest=true.';
}
if (endpoint === WEIXIN_ENDPOINTS.deleteBot) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
@ -165,7 +171,27 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
value = await controller.cancelProvisioning(payload.attemptId);
if (!value) return badRequest('The provisioning attempt no longer exists.');
} else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
const snapshot = await controller.reconnectBot(payload.botId);
if (signal?.aborted) return cancelled();
let testMessage;
if (payload.sendTest === true) {
const connected = snapshot?.bots?.some(
(bot) => bot?.botId === payload.botId && bot?.connected === true,
);
if (!connected || typeof controller.sendConnectionTest !== 'function') {
testMessage = publicConnectionTestResult(
connectionTestTargetUnavailable('微信机器人'),
);
} else {
try {
await controller.sendConnectionTest(payload.botId);
testMessage = publicConnectionTestResult();
} catch (error) {
testMessage = publicConnectionTestResult(error);
}
}
}
value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
} else if (endpoint === WEIXIN_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(

View file

@ -1,5 +1,6 @@
import QRCode from 'qrcode';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
@ -39,8 +40,9 @@ function payloadFailure(endpoint, payload) {
? null : `${endpoint} requires an attemptId.`;
}
if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId)
? null : 'bot.reconnect requires a botId.';
return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
&& (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
? null : 'bot.reconnect requires a botId and optional sendTest flag.';
}
if (endpoint === WHATSAPP_ENDPOINTS.deleteBot) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
@ -119,7 +121,33 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
} else if (endpoint === WHATSAPP_ENDPOINTS.cancelProvisioning) {
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
} else if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
const checked = await controller.reconnectBot(payload.botId);
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
value = await publicStatus(checked, cachedEncode);
if (payload.sendTest === true) {
let testError = null;
const connected = checked?.bots?.some(
(bot) => bot?.botId === payload.botId && bot.connected === true,
) === true;
if (!connected) {
testError = new Error('WhatsApp bot is not connected');
testError.code = 'test-target-unavailable';
} else {
try {
if (typeof controller.sendConnectionTest !== 'function') {
const unavailable = new Error('Connection test is unavailable');
unavailable.code = 'test-target-unavailable';
throw unavailable;
}
await controller.sendConnectionTest(payload.botId);
} catch (error) {
testError = error;
}
}
value = { ...value, testMessage: publicConnectionTestResult(testError) };
}
} else if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await publicStatus(