feat: 精确会话自动化规则

This commit is contained in:
Wxw-Gu
2026-09-21 11:06:59 +08:00
parent 95384a4a92
commit 069d836dea
25 changed files with 1710 additions and 122 deletions
+131 -2
View File
@@ -18,10 +18,11 @@ vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
import {
AUTOMATION_SEND_INTERVAL_MS,
WechatActionGateway
WechatActionGateway,
toPersonalWechatSendResult
} from '../../src/main/services/wechat-action-gateway'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { WechatActionResult } from '../../src/shared/wechat-action'
import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action'
const readyCapability: PersonalWechatSendCapability = {
supported: true,
@@ -375,4 +376,132 @@ describe('WechatActionGateway', () => {
expect(Date.now()).toBe(startedAt)
vi.useRealTimers()
})
/**
* 项目规则:**所有发送都走 WechatActionGateway**。
*
* 手动发送(`wechat-personal:send`)改造前是直连 `PersonalWechatSendService` 的,
* 于是没有审计、没有幂等、也不进 Send Log。这一组锁住改造后的语义。
*/
describe('用户手动发送(triggerType: user)', () => {
function manualAction(
gateway: WechatActionGateway,
overrides: Partial<WechatActionRequest> = {}
): Promise<WechatActionResult> {
return gateway.execute({
origin: 'user_manual',
purpose: 'manual_image',
triggerType: 'user',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'image', path: '/tmp/report.png' },
...overrides
} as WechatActionRequest)
}
it('放行、发送一次,并写入审计记录', async () => {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
directories.push(userData)
const gateway = new WechatActionGateway({ getUserDataPath: () => userData })
const result = await manualAction(gateway)
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
expect(mocks.sender.send).toHaveBeenCalledWith({
type: 'image',
to: 'room@chatroom',
isGroup: true,
filePath: '/tmp/report.png'
})
const audit = readJsonSync(join(userData, 'actions', 'wechat-actions.json'))
expect(audit).toEqual([
expect.objectContaining({
origin: 'user_manual',
purpose: 'manual_image',
triggerType: 'user',
recipientId: 'room@chatroom',
decision: 'allow',
sendStatus: 'sent'
})
])
})
it('不受 automation 的 purpose allowlist 限制,也不吃 3s 节流', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-21T01:00:00.000Z'))
const gateway = createGateway()
const first = await manualAction(gateway)
const second = await manualAction(gateway)
expect(first.status).toBe('sent')
expect(second.status).toBe('sent')
// 节流只对 triggerType === 'automation' 生效:两次点击必须立刻都发出去。
expect(mocks.sender.send).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('空文本由规范化拦下,不落到发送层', async () => {
const gateway = createGateway()
const result = await manualAction(gateway, {
purpose: 'manual_text',
content: { type: 'text', text: ' ' }
})
expect(result.status).toBe('blocked')
expect(result.errorCode).toBe('INVALID_REQUEST')
expect(mocks.sender.send).not.toHaveBeenCalled()
})
})
describe('toPersonalWechatSendResult', () => {
const fallback = { canSend: true } as unknown as Parameters<
typeof toPersonalWechatSendResult
>[1]
it('优先返回底层 sendResult(它带着真实的 status)', () => {
const underlying = { success: true, status: { canSend: true, marker: 'real' } }
expect(
toPersonalWechatSendResult(
{
actionId: 'a1',
status: 'sent',
decision: 'allow',
startedAt: '2026-09-21T01:00:00.000Z',
finishedAt: '2026-09-21T01:00:01.000Z',
sendResult: underlying
},
fallback
)
).toBe(underlying)
})
it('拿不到 sendResult 时按 action.status 合成', () => {
const sent = toPersonalWechatSendResult(
{
actionId: 'a1',
status: 'sent',
decision: 'allow',
startedAt: '2026-09-21T01:00:00.000Z',
finishedAt: '2026-09-21T01:00:01.000Z'
},
fallback
)
expect(sent).toEqual({ success: true, status: fallback })
const blocked = toPersonalWechatSendResult(
{
actionId: 'a2',
status: 'blocked',
decision: 'block',
errorCode: 'ACTION_NOT_ALLOWED',
reason: '自动化动作不允许执行',
startedAt: '2026-09-21T01:00:00.000Z',
finishedAt: '2026-09-21T01:00:01.000Z'
},
fallback
)
expect(blocked.success).toBe(false)
expect(blocked.error).toBe('自动化动作不允许执行')
})
})
})