企业微信通知增加原始输入
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Leader summaries with original input text
|
||||
|
||||
## Scope and authorization
|
||||
|
||||
- User requested removing the public task ID from the notification body and adding input. Latest correction explicitly selects the original input text, superseding the earlier Chinese business-field preference.
|
||||
- Feature task: owns this record, leader-summary formatter/input projection, dedicated notification worker reads, and relevant tests. Shared canonical documents remain unchanged; contract updates are promotion candidates.
|
||||
- Local implementation and validation only. No deployment, service restart, ERP access, or external message sending.
|
||||
- No subagents: repository instructions require explicit user consent, which has not been provided.
|
||||
|
||||
## Worktree safety
|
||||
|
||||
- Branch: `main`
|
||||
- Worktree: `/Users/andy/IdeaProjects/LWLT-AIBOT`
|
||||
- Base commit: `7ea7c211cc8e543a4089b1efd212aca2a838e1b7`
|
||||
- Existing changes: untracked `.idea/`, unrelated and preserved.
|
||||
- Other worktrees: none. Overlap result: Clear for notification source/tests.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- Remove task-number text while retaining internal task association, deduplication and diagnostic IDs.
|
||||
- Render the task's original submitted text from `original_text_ciphertext`, preserving multiline content. No parsed-field extraction or attachment expansion.
|
||||
- Bound the entire message to an internal 4,000-byte UTF-8 display budget, visibly abbreviating only excessive input while retaining the result. This is a local display safeguard, not an assertion about the external provider's documented limit.
|
||||
- Missing/invalid encrypted input must not prevent other task notifications; show an unavailable-input message instead.
|
||||
- Cover exact input preservation, all stable outcomes, missing/invalid ciphertext, result updates, multibyte boundaries and worker data plumbing with local regression tests.
|
||||
- Run repository-required checks and build; document unrelated local hygiene limitations separately.
|
||||
|
||||
## Status
|
||||
|
||||
- Complete locally; production deployment and actual WeChat delivery remain for the user.
|
||||
- Notification body now shows employee, business, status, submission time, original input and result. The generated task-number line is removed; task IDs still link internal delivery records, logs and deduplication.
|
||||
- Input is the first submitted text stored in `tasks.original_text_ciphertext`, shared by manual and AgentBus intake. Later conversational supplements and attachment contents are not fetched. Parse failures can therefore still show original input without a parsed operation.
|
||||
- Original punctuation, tabs and line layout are retained; line endings are normalized and nonprinting controls/outer whitespace removed. Only excessive original input is abbreviated with a visible notice under the 4,000-byte total display budget; the outcome remains complete.
|
||||
- Missing or undecryptable original text displays `原始输入暂不可用,请在平台查看。` and does not abort the projection batch. No input plaintext, ciphertext or response body is newly logged.
|
||||
- Existing encrypted outbox, future-only scope, one-attempt sends, fixed recipient configuration, task execution and database schema are unchanged. Previously materialized delivery text is not rewritten or resent.
|
||||
- Deployment requires a new server image/restart, with no extension update or SQL migration for this change.
|
||||
|
||||
## Validation and final review
|
||||
|
||||
- Targeted summary/transport contract checks: 20/20 PASS, including 9 new behavioral regressions for exact multiline text, failed parsing, result updates, unavailable input, UTF-8 length boundaries and decryption failure isolation.
|
||||
- `node --run check`: PASS; `node --run build`: PASS in the original checkout.
|
||||
- `node --run check:repo` in the original checkout: 8/10 PASS, with only pre-existing `.idea/`, root `.DS_Store` and `chrome-extension/.DS_Store` hygiene violations. All existing local files preserved.
|
||||
- Clean validation snapshot `/private/tmp/ltjt-leader-original-input-verify-w90er9zg` contains tracked files with current edits plus this task record, excludes local secrets and unrelated untracked/Finder files, and reuses installed dependencies through a symlink. Repository checks 10/10 PASS; full control-plane tests 192/192 PASS.
|
||||
- Full legacy suite in that snapshot: 285/287 PASS under the sandbox; two existing localhost HTTP tests failed solely on `listen EPERM: 127.0.0.1`. Both were rerun with the required localhost permission and PASS 2/2. They use mock parsers and no external services. No other test failures remain.
|
||||
- Final local read-only review: PASS. Worker selects the original encrypted column and decrypts only for summary rendering; formatter has no generated task ID; dispatcher still validates encrypted payload fingerprints and retains internal IDs. Scope is two production TypeScript files, two tests and this record.
|
||||
- `git diff --check`: PASS. No deployment, restart, ERP action or external notification was performed.
|
||||
|
||||
## Promotion candidates
|
||||
|
||||
- Update `agent设计规范/leader-summary-webhook-contract.md` and AUTH-005/canonical notification documentation during integration: task ID no longer appears as a generated message field, and original submitted text is included. Internal IDs, deduplication, delivery scope and one-attempt transport are unchanged.
|
||||
- The explicit request to display raw input supersedes the old body's exclusion of raw instructions and any content the user puts in them. No additional customer/passenger records, attachments, credentials or technical internals are fetched or appended. Notification text/ciphertext is not written to logs.
|
||||
@@ -98,6 +98,17 @@ function jsonObject(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
export function readLeaderSummaryOriginalText(config: AppConfig, ciphertext: unknown): string | null {
|
||||
if (typeof ciphertext !== 'string' || !ciphertext) return null;
|
||||
try {
|
||||
return decryptText(config, ciphertext);
|
||||
} catch {
|
||||
// One unreadable historical input must not stop this batch's notifications.
|
||||
// Neither ciphertext nor decrypted instructions belong in diagnostics.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class LeaderNotificationService {
|
||||
private projectorTimer: NodeJS.Timeout | null = null;
|
||||
private projectorInFlight: Promise<void> | null = null;
|
||||
@@ -433,6 +444,7 @@ export class LeaderNotificationService {
|
||||
task.business_route_id,
|
||||
task.success_receipt,
|
||||
task.created_at,
|
||||
task.original_text_ciphertext,
|
||||
assignee.username AS assignee_username,
|
||||
source_event.id AS source_outbox_event_id,
|
||||
COALESCE(delivery_state.has_exposed_needs_review, false) AS has_exposed_needs_review
|
||||
@@ -545,6 +557,7 @@ export class LeaderNotificationService {
|
||||
businessRouteId: text(row.business_route_id) || null,
|
||||
assigneeUsername: text(row.assignee_username),
|
||||
createdAt: String(row.created_at),
|
||||
originalText: readLeaderSummaryOriginalText(this.config, row.original_text_ciphertext),
|
||||
successReceipt: jsonObject(row.success_receipt),
|
||||
hadNeedsReview: hadExposedNeedsReview
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LeaderTaskSummaryInput {
|
||||
businessRouteId: string | null;
|
||||
assigneeUsername: string;
|
||||
createdAt: string | Date;
|
||||
originalText?: string | null;
|
||||
successReceipt?: Record<string, unknown> | null;
|
||||
hadNeedsReview?: boolean;
|
||||
}
|
||||
@@ -38,6 +39,29 @@ const NEEDS_REVIEW_STATUSES = new Set([
|
||||
'uncertain'
|
||||
]);
|
||||
|
||||
// A local display budget; only the original input may be abbreviated. Reserve
|
||||
// space for the outcome so a long instruction cannot hide the actual result.
|
||||
export const LEADER_SUMMARY_MAX_MESSAGE_BYTES = 4_000;
|
||||
const INPUT_TRUNCATION_NOTICE = '\n…(原文过长,剩余内容请在平台查看)';
|
||||
|
||||
function originalInputText(value: string | null | undefined, byteBudget: number): string {
|
||||
const original = typeof value === 'string'
|
||||
? value.replace(/\r\n?/gu, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').trim()
|
||||
: '';
|
||||
if (!original) return '原始输入暂不可用,请在平台查看。';
|
||||
if (Buffer.byteLength(original, 'utf8') <= byteBudget) return original;
|
||||
const prefixBudget = Math.max(0, byteBudget - Buffer.byteLength(INPUT_TRUNCATION_NOTICE, 'utf8'));
|
||||
let prefix = '';
|
||||
let bytes = 0;
|
||||
for (const character of original) {
|
||||
const nextBytes = Buffer.byteLength(character, 'utf8');
|
||||
if (bytes + nextBytes > prefixBudget) break;
|
||||
prefix += character;
|
||||
bytes += nextBytes;
|
||||
}
|
||||
return prefix + INPUT_TRUNCATION_NOTICE;
|
||||
}
|
||||
|
||||
function singleLine(value: unknown, maxLength: number): string {
|
||||
return String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
@@ -131,23 +155,26 @@ export function buildLeaderTaskSummary(
|
||||
const route = businessRouteById(input.businessRouteId);
|
||||
const businessLabel = singleLine(route?.directive || '其他任务', 120) || '其他任务';
|
||||
const assignee = singleLine(input.assigneeUsername, 160) || '未知员工';
|
||||
const taskId = singleLine(input.taskId, 200) || '任务编号未记录';
|
||||
const header = milestone === 'resolved' ? '【员工任务摘要·结果更新】' : '【员工任务摘要】';
|
||||
const prefix = [
|
||||
header,
|
||||
`员工:${assignee}`,
|
||||
`业务:${businessLabel}`,
|
||||
`状态:${statusLabel}`,
|
||||
`提交:${formatShanghaiTimestamp(input.createdAt)}`,
|
||||
'',
|
||||
'原始输入:',
|
||||
''
|
||||
].join('\n');
|
||||
const suffix = `\n\n结果:${resultText.slice(0, 300)}`;
|
||||
const inputBudget = LEADER_SUMMARY_MAX_MESSAGE_BYTES - Buffer.byteLength(prefix + suffix, 'utf8');
|
||||
return {
|
||||
milestone,
|
||||
deliveryStatus,
|
||||
statusLabel,
|
||||
businessLabel,
|
||||
resultText: resultText.slice(0, 300),
|
||||
messageText: [
|
||||
header,
|
||||
`员工:${assignee}`,
|
||||
`业务:${businessLabel}`,
|
||||
`状态:${statusLabel}`,
|
||||
`任务:${taskId}`,
|
||||
`提交:${formatShanghaiTimestamp(input.createdAt)}`,
|
||||
`结果:${resultText.slice(0, 300)}`
|
||||
].join('\n')
|
||||
messageText: prefix + originalInputText(input.originalText, inputBudget) + suffix
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ test('projection is organization-scoped, future-only, role-safe, source-limited
|
||||
assert.match(service, /assignee\.organization_id = task\.organization_id/);
|
||||
assert.match(service, /assignee\.role <> 'admin'/);
|
||||
assert.match(service, /task\.source IN \('manual', 'agentbus'\)/);
|
||||
assert.match(service, /task\.original_text_ciphertext,/);
|
||||
assert.match(service, /originalText: readLeaderSummaryOriginalText\(this\.config, row\.original_text_ciphertext\)/);
|
||||
assert.match(service, /event\.payload ->> 'archived'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /event\.payload ->> 'restored'.*IS DISTINCT FROM 'true'/s);
|
||||
assert.match(service, /pg_try_advisory_xact_lock/);
|
||||
|
||||
@@ -3,8 +3,12 @@ import test from 'node:test';
|
||||
import {
|
||||
buildLeaderTaskSummary,
|
||||
formatShanghaiTimestamp,
|
||||
isLeaderTaskSummaryStatus
|
||||
isLeaderTaskSummaryStatus,
|
||||
LEADER_SUMMARY_MAX_MESSAGE_BYTES
|
||||
} from '../src/leadership-task-summary.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { encryptText } from '../src/crypto.js';
|
||||
import { readLeaderSummaryOriginalText } from '../src/leader-notification-service.js';
|
||||
|
||||
const base = {
|
||||
taskId: 'TASK-20260907-001',
|
||||
@@ -13,6 +17,141 @@ const base = {
|
||||
createdAt: '2026-09-07T00:05:06.000Z'
|
||||
};
|
||||
|
||||
const originalHotelInput = [
|
||||
'安排酒店',
|
||||
'团号:LW-260915A-B',
|
||||
'入住日期:2026-09-16',
|
||||
'离店日期:2026-09-18',
|
||||
'酒店搜索:示例酒店 twn',
|
||||
'间数:9',
|
||||
'备注:请保留原话、全角标点及 两个空格。'
|
||||
].join('\n');
|
||||
|
||||
test('notification shows the original multiline input and omits the generated task-number field', () => {
|
||||
const summary = buildLeaderTaskSummary({
|
||||
...base,
|
||||
status: 'completed',
|
||||
originalText: originalHotelInput
|
||||
});
|
||||
assert.equal(summary?.messageText, [
|
||||
'【员工任务摘要】',
|
||||
'员工:employee-a',
|
||||
'业务:安排酒店',
|
||||
'状态:已完成',
|
||||
'提交:2026-09-07 08:05:06',
|
||||
'',
|
||||
'原始输入:',
|
||||
originalHotelInput,
|
||||
'',
|
||||
'结果:业务处理已完成,结果已记录。'
|
||||
].join('\n'));
|
||||
assert.doesNotMatch(summary?.messageText || '', /TASK-20260907-001|^任务:/mu);
|
||||
});
|
||||
|
||||
test('original input is available even when parsing failed or the business route is unknown', () => {
|
||||
for (const status of ['parse_failed', 'blocked', 'cancelled', 'saved_unverified', 'dry_run']) {
|
||||
const summary = buildLeaderTaskSummary({
|
||||
...base,
|
||||
businessRouteId: null,
|
||||
status,
|
||||
originalText: '这段输入未能解析:日期稍后补充。\n第二行仍须展示。'
|
||||
});
|
||||
assert.match(summary?.messageText || '', /业务:其他任务/);
|
||||
assert.match(summary?.messageText || '', /原始输入:\n这段输入未能解析:日期稍后补充。\n第二行仍须展示。/);
|
||||
assert.doesNotMatch(summary?.messageText || '', /TASK-20260907-001|^任务:/mu);
|
||||
}
|
||||
});
|
||||
|
||||
test('result-update messages retain the original input and full result', () => {
|
||||
const summary = buildLeaderTaskSummary({
|
||||
...base,
|
||||
status: 'completed',
|
||||
hadNeedsReview: true,
|
||||
originalText: originalHotelInput,
|
||||
successReceipt: { group_number: 'LW-260915A-B' }
|
||||
});
|
||||
assert.equal(summary?.milestone, 'resolved');
|
||||
assert.ok(summary?.messageText.startsWith('【员工任务摘要·结果更新】'));
|
||||
assert.ok(summary?.messageText.includes(originalHotelInput));
|
||||
assert.ok(summary?.messageText.endsWith('结果:业务处理已完成。团号:LW-260915A-B'));
|
||||
});
|
||||
|
||||
test('input formatting preserves punctuation and tabs, normalizes newlines and removes nonprinting controls', () => {
|
||||
const summary = buildLeaderTaskSummary({
|
||||
...base,
|
||||
status: 'completed',
|
||||
originalText: ' 安排酒店\r\n酒店:A酒店\tTWN\r间数:9\u0000\u0007 '
|
||||
});
|
||||
assert.ok(summary?.messageText.includes('原始输入:\n安排酒店\n酒店:A酒店\tTWN\n间数:9\n\n结果:'));
|
||||
assert.doesNotMatch(summary?.messageText || '', /\r|\u0000|\u0007/);
|
||||
});
|
||||
|
||||
test('missing input has an explicit fallback without inventing input from a receipt', () => {
|
||||
for (const originalText of [undefined, null, '', ' \n\t ']) {
|
||||
const summary = buildLeaderTaskSummary({
|
||||
...base,
|
||||
status: 'failed',
|
||||
originalText,
|
||||
successReceipt: { original_text: '不能作为原始输入的回执内容' }
|
||||
});
|
||||
assert.match(summary?.messageText || '', /原始输入:\n原始输入暂不可用,请在平台查看。/);
|
||||
assert.doesNotMatch(summary?.messageText || '', /不能作为原始输入的回执内容/);
|
||||
}
|
||||
});
|
||||
|
||||
test('UTF-8 budget retains fitting inputs and visibly truncates only overflowing input', () => {
|
||||
const options = { ...base, status: 'completed', originalText: 'x' };
|
||||
const fixedBytes = Buffer.byteLength(buildLeaderTaskSummary(options)!.messageText, 'utf8') - 1;
|
||||
const available = LEADER_SUMMARY_MAX_MESSAGE_BYTES - fixedBytes;
|
||||
const fittingInput = 'a'.repeat(available);
|
||||
const fitting = buildLeaderTaskSummary({ ...options, originalText: fittingInput })!;
|
||||
assert.equal(Buffer.byteLength(fitting.messageText, 'utf8'), LEADER_SUMMARY_MAX_MESSAGE_BYTES);
|
||||
assert.ok(fitting.messageText.includes(fittingInput));
|
||||
assert.doesNotMatch(fitting.messageText, /原文过长/);
|
||||
const overflowing = buildLeaderTaskSummary({ ...options, originalText: fittingInput + 'a' })!;
|
||||
assert.ok(Buffer.byteLength(overflowing.messageText, 'utf8') <= LEADER_SUMMARY_MAX_MESSAGE_BYTES);
|
||||
assert.match(overflowing.messageText, /原文过长,剩余内容请在平台查看/);
|
||||
assert.ok(overflowing.messageText.endsWith('结果:业务处理已完成,结果已记录。'));
|
||||
});
|
||||
|
||||
test('very long Chinese and emoji input cannot break Unicode or displace the outcome', () => {
|
||||
const summary = buildLeaderTaskSummary({
|
||||
...base,
|
||||
assigneeUsername: '员'.repeat(200),
|
||||
status: 'completed',
|
||||
hadNeedsReview: true,
|
||||
originalText: '酒店🏨中文\n'.repeat(20_000),
|
||||
successReceipt: { group_numbers: Array.from({ length: 10 }, (_, index) => `GROUP-${index}-${'A'.repeat(60)}`) }
|
||||
})!;
|
||||
assert.ok(Buffer.byteLength(summary.messageText, 'utf8') <= LEADER_SUMMARY_MAX_MESSAGE_BYTES);
|
||||
assert.equal(Buffer.from(summary.messageText).toString('utf8'), summary.messageText);
|
||||
assert.doesNotMatch(summary.messageText, /\uFFFD/);
|
||||
assert.match(summary.messageText, /原文过长,剩余内容请在平台查看/);
|
||||
assert.ok(summary.messageText.endsWith(`结果:${summary.resultText}`));
|
||||
});
|
||||
|
||||
test('worker reads the same encrypted original input for manual and AgentBus tasks', () => {
|
||||
const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 17).toString('base64') });
|
||||
const ciphertext = encryptText(config, originalHotelInput);
|
||||
const originalText = readLeaderSummaryOriginalText(config, ciphertext);
|
||||
assert.equal(originalText, originalHotelInput);
|
||||
const summary = buildLeaderTaskSummary({ ...base, status: 'completed', originalText });
|
||||
assert.ok(summary?.messageText.includes(originalHotelInput));
|
||||
assert.ok(!summary?.messageText.includes(ciphertext));
|
||||
});
|
||||
|
||||
test('missing, invalid or unreadable ciphertext does not prevent a notification', () => {
|
||||
const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 17).toString('base64') });
|
||||
const otherConfig = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 18).toString('base64') });
|
||||
for (const ciphertext of [null, undefined, '', {}, 'not-encrypted', encryptText(otherConfig, originalHotelInput)]) {
|
||||
const originalText = readLeaderSummaryOriginalText(config, ciphertext);
|
||||
assert.equal(originalText, null);
|
||||
const summary = buildLeaderTaskSummary({ ...base, status: 'completed', originalText });
|
||||
assert.match(summary?.messageText || '', /原始输入暂不可用/);
|
||||
assert.ok(summary?.messageText.endsWith('结果:业务处理已完成,结果已记录。'));
|
||||
}
|
||||
});
|
||||
|
||||
test('leader summary projects only stable outcomes', () => {
|
||||
assert.equal(buildLeaderTaskSummary({ ...base, status: 'running' }), null);
|
||||
assert.equal(buildLeaderTaskSummary({ ...base, status: 'awaiting_confirmation' }), null);
|
||||
|
||||
Reference in New Issue
Block a user