From 27f4108cf9433691a0b571698f038fb27352eb1a Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Wed, 2 Sep 2026 19:43:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=8F=91=E9=80=81?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=20=E6=8E=A5=E5=85=A5=E9=80=80=E7=BE=A4?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=B8=8E=E5=AE=9A=E6=97=B6=E6=97=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/scheduled-report-service.ts | 20 ++-- src/main/services/wechat-action-gateway.ts | 34 ------- src/renderer/src/App.tsx | 8 +- .../GroupExitMonitorWorkspace.tsx | 20 +++- tests/e2e/scheduled-reports.spec.ts | 2 +- tests/e2e/support/electron-main.cjs | 6 ++ tests/unit/group-exit-monitor-service.test.ts | 2 +- tests/unit/scheduled-report-service.test.ts | 98 ++++++++++++++++++- tests/unit/wechat-action-gateway.test.ts | 14 +++ 9 files changed, 150 insertions(+), 54 deletions(-) diff --git a/src/main/services/scheduled-report-service.ts b/src/main/services/scheduled-report-service.ts index 68282b5..37342fb 100644 --- a/src/main/services/scheduled-report-service.ts +++ b/src/main/services/scheduled-report-service.ts @@ -644,7 +644,7 @@ export class ScheduledReportService { fallbackStage: ScheduledReportExecutionStage, status: 'failed' | 'waiting_to_send' | 'partial_success' = 'failed', patch: Partial = {}, - notificationType: ScheduledReportNotificationType = 'failure', + notificationType: ScheduledReportNotificationType | null = 'failure', code?: string, errorStatus?: number, errorType?: string @@ -691,13 +691,15 @@ export class ScheduledReportService { suggestedAction: normalized.suggestedAction, retryable: normalized.retryable }, - { - type: notificationType, - severity: normalized.severity, - title: normalized.userTitle, - message: normalized.userMessage, - suggestedAction: normalized.suggestedAction - } + notificationType + ? { + type: notificationType, + severity: normalized.severity, + title: normalized.userTitle, + message: normalized.userMessage, + suggestedAction: normalized.suggestedAction + } + : undefined ) return completed } @@ -840,7 +842,7 @@ export class ScheduledReportService { sendStatus: unavailable ? 'unavailable' : 'failed', sendError: actionError }, - 'partial_success', + unavailable ? null : 'partial_success', unavailable ? 'WECHAT_SEND_UNAVAILABLE' : 'WECHAT_SEND_FAILED' ) } diff --git a/src/main/services/wechat-action-gateway.ts b/src/main/services/wechat-action-gateway.ts index d0812c8..a0f8c1f 100644 --- a/src/main/services/wechat-action-gateway.ts +++ b/src/main/services/wechat-action-gateway.ts @@ -32,7 +32,6 @@ export interface WechatActionGatewayDependencies { | WechatActionMemberEventReference | Promise | undefined - checkEntitlement?: (request: WechatActionRequest) => boolean | Promise getUserDataPath?: () => string now?: () => Date } @@ -140,33 +139,6 @@ export class WechatActionGateway { ) } - let entitled = true - try { - entitled = this.deps.checkEntitlement - ? await this.deps.checkEntitlement(request) - : await checkEntitlement(request) - } catch (error) { - entitled = false - return this.finishBlocked( - actionId, - request, - idempotencyKey, - startedAt, - 'POLICY_BLOCKED', - error instanceof Error ? error.message : String(error) - ) - } - if (!entitled) { - return this.finishBlocked( - actionId, - request, - idempotencyKey, - startedAt, - 'POLICY_BLOCKED', - '该发送动作未获得当前账号的使用权限' - ) - } - const capabilityType = request.content.type let capability: PersonalWechatSendCapability | undefined try { @@ -483,12 +455,6 @@ export function shouldUseAiPolicy(request: WechatActionRequest): boolean { return false } -/** 当前默认允许发送,后续可在这里补充权限限制。 */ -export function checkEntitlement(request: WechatActionRequest): boolean { - void request - return true -} - export function normalizeActionRequest(value: unknown): WechatActionRequest | null { if (!value || typeof value !== 'object') return null const input = value as Partial diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c4c39a1..2692870 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1937,7 +1937,13 @@ function App(): React.ReactElement { case 'report': return renderReportWorkspace() case 'exit-monitor': - return + return ( + + ) case 'agent-hub': return case 'api': diff --git a/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx index 16e6741..04c8fbe 100644 --- a/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx +++ b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx @@ -31,6 +31,7 @@ type GroupSelectionFilter = 'all' | 'selected' | 'unselected' interface GroupExitMonitorWorkspaceProps { dbReady: boolean contacts?: Contact[] + onOpenSendSettings?: () => void } const EMPTY_STATE: GroupExitMonitorState = { @@ -81,9 +82,11 @@ const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): st capability?.ready && capability.capabilities.text ? 'ready' : 'unready' function EventNotificationStatus({ - event + event, + onOpenSendSettings }: { event: GroupExitMonitorState['events'][number] + onOpenSendSettings?: () => void }): React.ReactElement { const status = event.notificationStatus || event.notification?.status || 'not_requested' const details = @@ -108,6 +111,13 @@ function EventNotificationStatus({
{details.label} {details.description ? {details.description} : null} + {(event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' || + event.notification?.errorCode === 'SEND_NOT_READY') && + onOpenSendSettings ? ( + + ) : null}
) } @@ -428,7 +438,8 @@ function ManageGroups({ export function GroupExitMonitorWorkspace({ dbReady, - contacts = [] + contacts = [], + onOpenSendSettings }: GroupExitMonitorWorkspaceProps): React.ReactElement { const [state, setState] = React.useState(EMPTY_STATE) const [checking, setChecking] = React.useState(false) @@ -815,7 +826,10 @@ export function GroupExitMonitorWorkspace({ 群人数 {event.previousCount} 人 → {event.currentCount} 人

) : null} - +
微信名
diff --git a/tests/e2e/scheduled-reports.spec.ts b/tests/e2e/scheduled-reports.spec.ts index 324ec55..1bad708 100644 --- a/tests/e2e/scheduled-reports.spec.ts +++ b/tests/e2e/scheduled-reports.spec.ts @@ -73,7 +73,7 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport await fixture.page.getByRole('button', { name: '设置' }).click() await fixture.page.getByRole('button', { name: '微信发送', exact: true }).click() await expect(fixture.page.getByRole('heading', { name: '微信发送' })).toBeVisible() - await expect(fixture.page.getByRole('heading', { name: '发送能力' })).toBeVisible() + await expect(fixture.page.getByRole('heading', { name: '发送能力', exact: true })).toBeVisible() expect(pageErrors).toEqual([]) } finally { diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 301fbf9..1eff916 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -20,6 +20,7 @@ const personalWechatSupported = process.platform === 'darwin' || process.platfor const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1' const fixtureNowMs = Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now() +let keepOneBotProcess = false const formatFixtureDateTime = (timestampSeconds) => { const date = new Date(timestampSeconds * 1000) @@ -442,6 +443,11 @@ handle('wechat-personal:getSendCapability', () => ({ ? '个人微信已准备好发送日报' : '微信消息发送目前仅支持 macOS 和 Windows' })) +handle('wechat-personal:getKeepProcess', () => keepOneBotProcess) +handle('wechat-personal:setKeepProcess', (keep) => { + keepOneBotProcess = Boolean(keep) + return keepOneBotProcess +}) handle('scheduled-report:list', () => [...scheduledReportTasks]) handle('scheduled-report:listExecutions', (taskId) => taskId diff --git a/tests/unit/group-exit-monitor-service.test.ts b/tests/unit/group-exit-monitor-service.test.ts index 3ee1b76..c42e2a5 100644 --- a/tests/unit/group-exit-monitor-service.test.ts +++ b/tests/unit/group-exit-monitor-service.test.ts @@ -181,7 +181,7 @@ describe('GroupExitMonitorService', () => { expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled() }) - it('keeps the member event when the Gateway reports unavailable capability', async () => { + it('微信发送能力不可用时仍保留退群事件', async () => { installGroupDb() mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({ ready: false, diff --git a/tests/unit/scheduled-report-service.test.ts b/tests/unit/scheduled-report-service.test.ts index 1a10af8..613ac9b 100644 --- a/tests/unit/scheduled-report-service.test.ts +++ b/tests/unit/scheduled-report-service.test.ts @@ -435,7 +435,11 @@ describe('scheduled report scheduling', () => { ...makeDependencies({ getAgentHubStatus: () => hubStatus, sendNotification, - getCapability: async () => unavailableCapability + send: async () => ({ + success: false, + status: capability.senderStatus, + error: '连接器暂时不可用' + }) }) }) const created = await service.createTask({ @@ -544,12 +548,88 @@ describe('scheduled report scheduling', () => { pngPath: '/tmp/saved.png', reportId: 'report-1', sendStatus: 'unavailable', - notificationStatus: 'sent' + notificationStatus: 'not_needed' }) expect(generateReport).toHaveBeenCalledOnce() expect(saveHistory).toHaveBeenCalledOnce() expect(send).not.toHaveBeenCalled() - expect(await service.listNotifications()).toHaveLength(1) + expect(await service.listNotifications()).toHaveLength(0) + }) + + it('keeps a generated report waiting without creating a notification when sending is unavailable', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-block-')) + await enableNotifications(storageDir) + const sendAction = vi.fn(async () => ({ + actionId: 'send-unavailable-action', + status: 'failed' as const, + decision: 'allow' as const, + errorCode: 'SEND_CAPABILITY_UNAVAILABLE' as const, + reason: '当前微信发送能力不可用', + startedAt: '2026-08-27T01:00:00.000Z', + finishedAt: '2026-08-27T01:00:01.000Z' + })) + const sendNotification = vi.fn() + const service = new ScheduledReportService({ + storageDir, + ...makeDependencies({ sendAction, sendNotification }) + }) + const created = await service.createTask({ + name: '发送能力未就绪日报', + group: '研发群', + target: '研发群@chatroom', + scheduleTime: '09:00' + }) + + const execution = await runScheduled(service, created.data!.id) + + expect(execution).toMatchObject({ + status: 'waiting_to_send', + errorCode: 'WECHAT_SEND_UNAVAILABLE', + sendStatus: 'unavailable', + notificationStatus: 'not_needed', + pngPath: '/tmp/saved.png' + }) + expect(sendNotification).not.toHaveBeenCalled() + expect(await service.listNotifications()).toHaveLength(0) + }) + + it('keeps unavailable sending waiting on retry without creating a notification', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-retry-')) + await enableNotifications(storageDir) + const sendAction = vi.fn(async () => ({ + actionId: 'send-unavailable-action', + status: 'failed' as const, + decision: 'allow' as const, + errorCode: 'SEND_CAPABILITY_UNAVAILABLE' as const, + reason: '当前微信发送能力不可用', + startedAt: '2026-08-27T01:00:00.000Z', + finishedAt: '2026-08-27T01:00:01.000Z' + })) + const sendNotification = vi.fn() + const service = new ScheduledReportService({ + storageDir, + ...makeDependencies({ sendAction, sendNotification }) + }) + const created = await service.createTask({ + name: '发送能力未就绪重试日报', + group: '研发群', + target: '研发群@chatroom', + scheduleTime: '09:00' + }) + const first = await runScheduled(service, created.data!.id) + + const retried = await service.retryScheduledReportSend(first.id) + + expect(retried.data).toMatchObject({ + status: 'waiting_to_send', + errorCode: 'WECHAT_SEND_UNAVAILABLE', + retryCount: 1, + sendStatus: 'unavailable', + notificationStatus: 'not_needed' + }) + expect(sendAction).toHaveBeenCalledTimes(2) + expect(sendNotification).not.toHaveBeenCalled() + expect(await service.listNotifications()).toHaveLength(0) }) it('keeps notifications pending when no reliable recipient is available', async () => { @@ -559,7 +639,11 @@ describe('scheduled report scheduling', () => { const service = new ScheduledReportService({ storageDir, ...makeDependencies({ - getCapability: async () => unavailableCapability, + send: async () => ({ + success: false, + status: capability.senderStatus, + error: '微信发送失败' + }), getNotificationRecipient: () => undefined, sendNotification }) @@ -591,7 +675,11 @@ describe('scheduled report scheduling', () => { const service = new ScheduledReportService({ storageDir, ...makeDependencies({ - getCapability: async () => unavailableCapability, + send: async () => ({ + success: false, + status: capability.senderStatus, + error: '微信发送失败' + }), sendNotification }) }) diff --git a/tests/unit/wechat-action-gateway.test.ts b/tests/unit/wechat-action-gateway.test.ts index 91d44a9..0f22a2d 100644 --- a/tests/unit/wechat-action-gateway.test.ts +++ b/tests/unit/wechat-action-gateway.test.ts @@ -205,6 +205,20 @@ describe('WechatActionGateway', () => { expect(mocks.sender.send).not.toHaveBeenCalled() }) + it('用户主动发送只依赖微信发送能力', async () => { + const gateway = createGateway() + const result = await gateway.execute({ + origin: 'user_tts', + purpose: 'tts_voice', + triggerType: 'user', + recipient: { type: 'contact', id: 'wxid_user' }, + content: { type: 'text', text: '用户主动发送' } + }) + + expect(result).toMatchObject({ status: 'sent', decision: 'allow' }) + expect(mocks.sender.send).toHaveBeenCalledOnce() + }) + it('converts a transport throw into SEND_FAILED', async () => { const gateway = createGateway() mocks.sender.send.mockRejectedValueOnce(new Error('connector timeout'))