mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
feat: 统一发送能力 接入退群通知与定时日报
This commit is contained in:
@@ -644,7 +644,7 @@ export class ScheduledReportService {
|
|||||||
fallbackStage: ScheduledReportExecutionStage,
|
fallbackStage: ScheduledReportExecutionStage,
|
||||||
status: 'failed' | 'waiting_to_send' | 'partial_success' = 'failed',
|
status: 'failed' | 'waiting_to_send' | 'partial_success' = 'failed',
|
||||||
patch: Partial<ScheduledReportExecution> = {},
|
patch: Partial<ScheduledReportExecution> = {},
|
||||||
notificationType: ScheduledReportNotificationType = 'failure',
|
notificationType: ScheduledReportNotificationType | null = 'failure',
|
||||||
code?: string,
|
code?: string,
|
||||||
errorStatus?: number,
|
errorStatus?: number,
|
||||||
errorType?: string
|
errorType?: string
|
||||||
@@ -691,13 +691,15 @@ export class ScheduledReportService {
|
|||||||
suggestedAction: normalized.suggestedAction,
|
suggestedAction: normalized.suggestedAction,
|
||||||
retryable: normalized.retryable
|
retryable: normalized.retryable
|
||||||
},
|
},
|
||||||
{
|
notificationType
|
||||||
|
? {
|
||||||
type: notificationType,
|
type: notificationType,
|
||||||
severity: normalized.severity,
|
severity: normalized.severity,
|
||||||
title: normalized.userTitle,
|
title: normalized.userTitle,
|
||||||
message: normalized.userMessage,
|
message: normalized.userMessage,
|
||||||
suggestedAction: normalized.suggestedAction
|
suggestedAction: normalized.suggestedAction
|
||||||
}
|
}
|
||||||
|
: undefined
|
||||||
)
|
)
|
||||||
return completed
|
return completed
|
||||||
}
|
}
|
||||||
@@ -840,7 +842,7 @@ export class ScheduledReportService {
|
|||||||
sendStatus: unavailable ? 'unavailable' : 'failed',
|
sendStatus: unavailable ? 'unavailable' : 'failed',
|
||||||
sendError: actionError
|
sendError: actionError
|
||||||
},
|
},
|
||||||
'partial_success',
|
unavailable ? null : 'partial_success',
|
||||||
unavailable ? 'WECHAT_SEND_UNAVAILABLE' : 'WECHAT_SEND_FAILED'
|
unavailable ? 'WECHAT_SEND_UNAVAILABLE' : 'WECHAT_SEND_FAILED'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export interface WechatActionGatewayDependencies {
|
|||||||
| WechatActionMemberEventReference
|
| WechatActionMemberEventReference
|
||||||
| Promise<WechatActionMemberEventReference | undefined>
|
| Promise<WechatActionMemberEventReference | undefined>
|
||||||
| undefined
|
| undefined
|
||||||
checkEntitlement?: (request: WechatActionRequest) => boolean | Promise<boolean>
|
|
||||||
getUserDataPath?: () => string
|
getUserDataPath?: () => string
|
||||||
now?: () => Date
|
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
|
const capabilityType = request.content.type
|
||||||
let capability: PersonalWechatSendCapability | undefined
|
let capability: PersonalWechatSendCapability | undefined
|
||||||
try {
|
try {
|
||||||
@@ -483,12 +455,6 @@ export function shouldUseAiPolicy(request: WechatActionRequest): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前默认允许发送,后续可在这里补充权限限制。 */
|
|
||||||
export function checkEntitlement(request: WechatActionRequest): boolean {
|
|
||||||
void request
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeActionRequest(value: unknown): WechatActionRequest | null {
|
export function normalizeActionRequest(value: unknown): WechatActionRequest | null {
|
||||||
if (!value || typeof value !== 'object') return null
|
if (!value || typeof value !== 'object') return null
|
||||||
const input = value as Partial<WechatActionRequest>
|
const input = value as Partial<WechatActionRequest>
|
||||||
|
|||||||
@@ -1937,7 +1937,13 @@ function App(): React.ReactElement {
|
|||||||
case 'report':
|
case 'report':
|
||||||
return renderReportWorkspace()
|
return renderReportWorkspace()
|
||||||
case 'exit-monitor':
|
case 'exit-monitor':
|
||||||
return <GroupExitMonitorWorkspace dbReady={isDatabaseConnected} contacts={contacts} />
|
return (
|
||||||
|
<GroupExitMonitorWorkspace
|
||||||
|
dbReady={isDatabaseConnected}
|
||||||
|
contacts={contacts}
|
||||||
|
onOpenSendSettings={openWechatSendSettings}
|
||||||
|
/>
|
||||||
|
)
|
||||||
case 'agent-hub':
|
case 'agent-hub':
|
||||||
return <AgentHubWorkspace />
|
return <AgentHubWorkspace />
|
||||||
case 'api':
|
case 'api':
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type GroupSelectionFilter = 'all' | 'selected' | 'unselected'
|
|||||||
interface GroupExitMonitorWorkspaceProps {
|
interface GroupExitMonitorWorkspaceProps {
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
contacts?: Contact[]
|
contacts?: Contact[]
|
||||||
|
onOpenSendSettings?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_STATE: GroupExitMonitorState = {
|
const EMPTY_STATE: GroupExitMonitorState = {
|
||||||
@@ -81,9 +82,11 @@ const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): st
|
|||||||
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
|
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
|
||||||
|
|
||||||
function EventNotificationStatus({
|
function EventNotificationStatus({
|
||||||
event
|
event,
|
||||||
|
onOpenSendSettings
|
||||||
}: {
|
}: {
|
||||||
event: GroupExitMonitorState['events'][number]
|
event: GroupExitMonitorState['events'][number]
|
||||||
|
onOpenSendSettings?: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const status = event.notificationStatus || event.notification?.status || 'not_requested'
|
const status = event.notificationStatus || event.notification?.status || 'not_requested'
|
||||||
const details =
|
const details =
|
||||||
@@ -108,6 +111,13 @@ function EventNotificationStatus({
|
|||||||
<div className={`exit-monitor-event-notification ${details.tone}`}>
|
<div className={`exit-monitor-event-notification ${details.tone}`}>
|
||||||
<span>{details.label}</span>
|
<span>{details.label}</span>
|
||||||
{details.description ? <small>{details.description}</small> : null}
|
{details.description ? <small>{details.description}</small> : null}
|
||||||
|
{(event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
||||||
|
event.notification?.errorCode === 'SEND_NOT_READY') &&
|
||||||
|
onOpenSendSettings ? (
|
||||||
|
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
|
||||||
|
去设置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -428,7 +438,8 @@ function ManageGroups({
|
|||||||
|
|
||||||
export function GroupExitMonitorWorkspace({
|
export function GroupExitMonitorWorkspace({
|
||||||
dbReady,
|
dbReady,
|
||||||
contacts = []
|
contacts = [],
|
||||||
|
onOpenSendSettings
|
||||||
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
||||||
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
||||||
const [checking, setChecking] = React.useState(false)
|
const [checking, setChecking] = React.useState(false)
|
||||||
@@ -815,7 +826,10 @@ export function GroupExitMonitorWorkspace({
|
|||||||
群人数 {event.previousCount} 人 → {event.currentCount} 人
|
群人数 {event.previousCount} 人 → {event.currentCount} 人
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<EventNotificationStatus event={event} />
|
<EventNotificationStatus
|
||||||
|
event={event}
|
||||||
|
onOpenSendSettings={onOpenSendSettings}
|
||||||
|
/>
|
||||||
<dl className="exit-monitor-event-details">
|
<dl className="exit-monitor-event-details">
|
||||||
<div>
|
<div>
|
||||||
<dt>微信名</dt>
|
<dt>微信名</dt>
|
||||||
|
|||||||
@@ -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: '设置' }).click()
|
||||||
await fixture.page.getByRole('button', { name: '微信发送', exact: true }).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: '发送能力' })).toBeVisible()
|
await expect(fixture.page.getByRole('heading', { name: '发送能力', exact: true })).toBeVisible()
|
||||||
|
|
||||||
expect(pageErrors).toEqual([])
|
expect(pageErrors).toEqual([])
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const personalWechatSupported = process.platform === 'darwin' || process.platfor
|
|||||||
const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1'
|
const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1'
|
||||||
const fixtureNowMs =
|
const fixtureNowMs =
|
||||||
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
||||||
|
let keepOneBotProcess = false
|
||||||
|
|
||||||
const formatFixtureDateTime = (timestampSeconds) => {
|
const formatFixtureDateTime = (timestampSeconds) => {
|
||||||
const date = new Date(timestampSeconds * 1000)
|
const date = new Date(timestampSeconds * 1000)
|
||||||
@@ -442,6 +443,11 @@ handle('wechat-personal:getSendCapability', () => ({
|
|||||||
? '个人微信已准备好发送日报'
|
? '个人微信已准备好发送日报'
|
||||||
: '微信消息发送目前仅支持 macOS 和 Windows'
|
: '微信消息发送目前仅支持 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:list', () => [...scheduledReportTasks])
|
||||||
handle('scheduled-report:listExecutions', (taskId) =>
|
handle('scheduled-report:listExecutions', (taskId) =>
|
||||||
taskId
|
taskId
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe('GroupExitMonitorService', () => {
|
|||||||
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
|
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the member event when the Gateway reports unavailable capability', async () => {
|
it('微信发送能力不可用时仍保留退群事件', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||||
ready: false,
|
ready: false,
|
||||||
|
|||||||
@@ -435,7 +435,11 @@ describe('scheduled report scheduling', () => {
|
|||||||
...makeDependencies({
|
...makeDependencies({
|
||||||
getAgentHubStatus: () => hubStatus,
|
getAgentHubStatus: () => hubStatus,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
getCapability: async () => unavailableCapability
|
send: async () => ({
|
||||||
|
success: false,
|
||||||
|
status: capability.senderStatus,
|
||||||
|
error: '连接器暂时不可用'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const created = await service.createTask({
|
const created = await service.createTask({
|
||||||
@@ -544,12 +548,88 @@ describe('scheduled report scheduling', () => {
|
|||||||
pngPath: '/tmp/saved.png',
|
pngPath: '/tmp/saved.png',
|
||||||
reportId: 'report-1',
|
reportId: 'report-1',
|
||||||
sendStatus: 'unavailable',
|
sendStatus: 'unavailable',
|
||||||
notificationStatus: 'sent'
|
notificationStatus: 'not_needed'
|
||||||
})
|
})
|
||||||
expect(generateReport).toHaveBeenCalledOnce()
|
expect(generateReport).toHaveBeenCalledOnce()
|
||||||
expect(saveHistory).toHaveBeenCalledOnce()
|
expect(saveHistory).toHaveBeenCalledOnce()
|
||||||
expect(send).not.toHaveBeenCalled()
|
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 () => {
|
it('keeps notifications pending when no reliable recipient is available', async () => {
|
||||||
@@ -559,7 +639,11 @@ describe('scheduled report scheduling', () => {
|
|||||||
const service = new ScheduledReportService({
|
const service = new ScheduledReportService({
|
||||||
storageDir,
|
storageDir,
|
||||||
...makeDependencies({
|
...makeDependencies({
|
||||||
getCapability: async () => unavailableCapability,
|
send: async () => ({
|
||||||
|
success: false,
|
||||||
|
status: capability.senderStatus,
|
||||||
|
error: '微信发送失败'
|
||||||
|
}),
|
||||||
getNotificationRecipient: () => undefined,
|
getNotificationRecipient: () => undefined,
|
||||||
sendNotification
|
sendNotification
|
||||||
})
|
})
|
||||||
@@ -591,7 +675,11 @@ describe('scheduled report scheduling', () => {
|
|||||||
const service = new ScheduledReportService({
|
const service = new ScheduledReportService({
|
||||||
storageDir,
|
storageDir,
|
||||||
...makeDependencies({
|
...makeDependencies({
|
||||||
getCapability: async () => unavailableCapability,
|
send: async () => ({
|
||||||
|
success: false,
|
||||||
|
status: capability.senderStatus,
|
||||||
|
error: '微信发送失败'
|
||||||
|
}),
|
||||||
sendNotification
|
sendNotification
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -205,6 +205,20 @@ describe('WechatActionGateway', () => {
|
|||||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
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 () => {
|
it('converts a transport throw into SEND_FAILED', async () => {
|
||||||
const gateway = createGateway()
|
const gateway = createGateway()
|
||||||
mocks.sender.send.mockRejectedValueOnce(new Error('connector timeout'))
|
mocks.sender.send.mockRejectedValueOnce(new Error('connector timeout'))
|
||||||
|
|||||||
Reference in New Issue
Block a user