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'))