@h-ai/feedback 0.1.0-alpha.70 → 0.1.0-alpha.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +27 -8
- package/README.ja.md +27 -8
- package/README.md +27 -8
- package/dist/bundle/index.d.ts +2 -0
- package/dist/bundle/index.js +1 -1
- package/dist/capacitor.d.ts +18 -0
- package/dist/capacitor.js +35 -0
- package/dist/capacitor.js.map +1 -0
- package/dist/{chunk-43AOXE4W.js → chunk-5QWKURWU.js} +11 -6
- package/dist/chunk-5QWKURWU.js.map +1 -0
- package/dist/{chunk-DYEK2FNM.js → chunk-PYKGS35A.js} +6 -6
- package/dist/chunk-PYKGS35A.js.map +1 -0
- package/dist/client/index.d.ts +88 -6
- package/dist/client/index.js +385 -71
- package/dist/client/index.js.map +1 -1
- package/dist/feedback-client-files-B88nCOo_.d.ts +54 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +79 -34
- package/dist/index.js.map +1 -1
- package/dist/tauri.d.ts +18 -0
- package/dist/tauri.js +15 -0
- package/dist/tauri.js.map +1 -0
- package/package.json +42 -6
- package/dist/chunk-43AOXE4W.js.map +0 -1
- package/dist/chunk-DYEK2FNM.js.map +0 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { FeedbackAttachmentData } from '@h-ai/api-contract/feedback';
|
|
2
|
+
import { HaiResult } from '@h-ai/core';
|
|
3
|
+
|
|
4
|
+
/** Web 下载、Tauri 保存对话框、Capacitor 原生分享的统一入口。原生插件按平台懒加载。 */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 保存取消是正常用户操作,不应提示成功或失败。
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import type { FeedbackSaveResult } from '@h-ai/feedback/client'
|
|
11
|
+
* const cancelled: FeedbackSaveResult = { status: 'cancelled' }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
interface FeedbackSaveResult {
|
|
15
|
+
status: 'saved' | 'shared' | 'cancelled';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 由平台入口提供的保存器;Web 不导入原生插件。
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import type { FeedbackFileSaver } from '@h-ai/feedback/client'
|
|
22
|
+
* const save: FeedbackFileSaver = async () => ({ status: 'cancelled' })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
type FeedbackFileSaver = (bytes: Uint8Array, filename: string, contentType: string, signal?: AbortSignal) => Promise<FeedbackSaveResult>;
|
|
26
|
+
/**
|
|
27
|
+
* 原生入口一次性配置;生成工程自动调用。传 undefined 可解除自定义平台适配。
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { configureFeedbackSaving } from '@h-ai/feedback/client'
|
|
31
|
+
* configureFeedbackSaving(undefined)
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
declare function configureFeedbackSaving(saver: FeedbackFileSaver | undefined): void;
|
|
35
|
+
/**
|
|
36
|
+
* 解码受权附件或导出结果,网络与权限仍由 typed API 负责。
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { feedbackDataBytes } from '@h-ai/feedback/client'
|
|
40
|
+
* const bytes = feedbackDataBytes({ name: 'data.txt', contentType: 'text/plain', dataBase64: 'YQ==' })
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare function feedbackDataBytes(data: FeedbackAttachmentData): Uint8Array;
|
|
44
|
+
/**
|
|
45
|
+
* 保存反馈文件。Tauri 只写用户选择的路径;Capacitor 只写临时缓存,分享结束后删除。
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* import { saveFeedbackFile } from '@h-ai/feedback/client'
|
|
49
|
+
* const result = await saveFeedbackFile(new Uint8Array([1]), 'feedback.zip')
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
declare function saveFeedbackFile(bytes: Uint8Array, filename: string, contentType?: string, signal?: AbortSignal): Promise<HaiResult<FeedbackSaveResult>>;
|
|
53
|
+
|
|
54
|
+
export { type FeedbackFileSaver as F, type FeedbackSaveResult as a, configureFeedbackSaving as c, feedbackDataBytes as f, saveFeedbackFile as s };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import * as _h_ai_core from '@h-ai/core';
|
|
3
3
|
import { HaiResult, PaginatedResult } from '@h-ai/core';
|
|
4
|
-
import { FeedbackSubmitInput, FeedbackReport, FeedbackListInput, FeedbackDetail, FeedbackEvidenceQuery, FeedbackEvidence, FeedbackSetStatusInput, FeedbackAppendEvidenceInput, FeedbackCompleteInput, FeedbackUploadInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackRetryInput, FeedbackExportInput, FeedbackJob } from '@h-ai/api-contract/feedback';
|
|
4
|
+
import { FeedbackBudget, FeedbackSubmitInput, FeedbackReport, FeedbackListInput, FeedbackDetail, FeedbackEvidenceQuery, FeedbackEvidence, FeedbackSetStatusInput, FeedbackAppendEvidenceInput, FeedbackCompleteInput, FeedbackUploadInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackRetryInput, FeedbackExportInput, FeedbackJob } from '@h-ai/api-contract/feedback';
|
|
5
5
|
export { FeedbackAppendEvidenceInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackCompleteInput, FeedbackDetail, FeedbackEvidence, FeedbackEvidenceQuery, FeedbackExportInput, FeedbackJob, FeedbackListInput, FeedbackReport, FeedbackRetryInput, FeedbackSetStatusInput, FeedbackSubmitInput, FeedbackUploadInput } from '@h-ai/api-contract/feedback';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -18,15 +18,21 @@ declare const FeedbackConfigSchema: z.ZodObject<{
|
|
|
18
18
|
appVersion: z.ZodOptional<z.ZodString>;
|
|
19
19
|
buildId: z.ZodOptional<z.ZodString>;
|
|
20
20
|
commit: z.ZodOptional<z.ZodString>;
|
|
21
|
-
|
|
21
|
+
notificationMode: z.ZodDefault<z.ZodEnum<{
|
|
22
|
+
disabled: "disabled";
|
|
23
|
+
console: "console";
|
|
24
|
+
reach: "reach";
|
|
25
|
+
}>>;
|
|
26
|
+
recipients: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
22
27
|
notificationProvider: z.ZodDefault<z.ZodString>;
|
|
23
|
-
adminUrl: z.ZodURL
|
|
28
|
+
adminUrl: z.ZodOptional<z.ZodURL>;
|
|
24
29
|
collectionTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
25
30
|
workerIntervalMs: z.ZodDefault<z.ZodNumber>;
|
|
26
31
|
leaseMs: z.ZodDefault<z.ZodNumber>;
|
|
27
32
|
maxAttempts: z.ZodDefault<z.ZodNumber>;
|
|
28
33
|
retentionDays: z.ZodDefault<z.ZodNumber>;
|
|
29
34
|
envAllowlist: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
35
|
+
maxTotalBytes: z.ZodDefault<z.ZodNumber>;
|
|
30
36
|
maxAttachmentBytes: z.ZodDefault<z.ZodNumber>;
|
|
31
37
|
maxEvidenceBytes: z.ZodDefault<z.ZodNumber>;
|
|
32
38
|
maxEvidenceCount: z.ZodDefault<z.ZodNumber>;
|
|
@@ -104,6 +110,7 @@ interface FeedbackFunctions {
|
|
|
104
110
|
close: () => Promise<HaiResult<void>>;
|
|
105
111
|
readonly isInitialized: boolean;
|
|
106
112
|
readonly config: FeedbackConfig | null;
|
|
113
|
+
readonly budget: FeedbackBudget;
|
|
107
114
|
submit: (input: FeedbackSubmitInput, context: FeedbackContext) => Promise<HaiResult<FeedbackReport>>;
|
|
108
115
|
list: (input: FeedbackListInput, context: FeedbackContext) => Promise<HaiResult<PaginatedResult<FeedbackReport>>>;
|
|
109
116
|
get: (id: string, context: FeedbackContext) => Promise<HaiResult<FeedbackDetail>>;
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-
|
|
2
|
-
export { HaiFeedbackError } from './chunk-
|
|
3
|
-
import { createFeedbackBundle } from './chunk-
|
|
1
|
+
import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-PYKGS35A.js';
|
|
2
|
+
export { HaiFeedbackError } from './chunk-PYKGS35A.js';
|
|
3
|
+
import { createFeedbackBundle } from './chunk-5QWKURWU.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema } from '@h-ai/api-contract/feedback';
|
|
5
6
|
import { core, ok, err } from '@h-ai/core';
|
|
6
|
-
import { reach } from '@h-ai/reach';
|
|
7
7
|
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
8
8
|
import { storage } from '@h-ai/storage';
|
|
9
9
|
import { Buffer } from 'buffer';
|
|
10
10
|
import { randomUUID, createHash } from 'crypto';
|
|
11
11
|
import { totalmem, arch, release, platform } from 'os';
|
|
12
12
|
import process from 'process';
|
|
13
|
-
import { FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema } from '@h-ai/api-contract/feedback';
|
|
14
13
|
|
|
15
14
|
var FeedbackConfigSchema = z.strictObject({
|
|
16
15
|
/** 反馈所属应用标识;应与浏览器及 API 入口配置一致。 */
|
|
@@ -21,12 +20,14 @@ var FeedbackConfigSchema = z.strictObject({
|
|
|
21
20
|
buildId: z.string().max(128).optional(),
|
|
22
21
|
/** 源码提交标识,帮助定位对应代码。 */
|
|
23
22
|
commit: z.string().max(128).optional(),
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
/**
|
|
23
|
+
/** disabled 不通知,console 输出安全摘要,reach 使用已有通知通道。 */
|
|
24
|
+
notificationMode: z.enum(["disabled", "console", "reach"]).default("reach"),
|
|
25
|
+
/** Reach 接收方,由实际 Provider 校验邮箱、手机号或业务目的地。 */
|
|
26
|
+
recipients: z.array(z.string().min(1).max(320)).max(20).default([]),
|
|
27
|
+
/** 已初始化 Reach Provider 的唯一名称,不限制为邮件。 */
|
|
27
28
|
notificationProvider: z.string().min(1).default("email"),
|
|
28
29
|
/** 管理员通知中的反馈管理页面地址。 */
|
|
29
|
-
adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)),
|
|
30
|
+
adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)).optional(),
|
|
30
31
|
/** 补充材料收集超时,单位毫秒;超时后冻结为完整或部分材料。 */
|
|
31
32
|
collectionTimeoutMs: z.number().int().min(0).max(3e5).default(6e4),
|
|
32
33
|
/** 持久任务扫描间隔,单位毫秒;零值由宿主手动运行任务。 */
|
|
@@ -39,12 +40,17 @@ var FeedbackConfigSchema = z.strictObject({
|
|
|
39
40
|
retentionDays: z.number().int().min(1).max(3650).default(30),
|
|
40
41
|
/** 允许采集的服务端环境变量名;凭据类名称仍按敏感信息策略排除。 */
|
|
41
42
|
envAllowlist: z.array(z.string().min(1).max(128)).max(100).default([]),
|
|
42
|
-
/**
|
|
43
|
+
/** 未压缩导出总预算;客户端、附件总量和传输预算由此派生。 */
|
|
44
|
+
maxTotalBytes: z.number().int().min(4 * 1024 * 1024).max(40 * 1024 * 1024).default(32 * 1024 * 1024),
|
|
45
|
+
/** 单个附件允许的最大字节数;同时受总预算约束。 */
|
|
43
46
|
maxAttachmentBytes: z.number().int().min(1).max(5 * 1024 * 1024).default(5 * 1024 * 1024),
|
|
44
47
|
/** 单份反馈累计证据允许的最大字节数。 */
|
|
45
|
-
maxEvidenceBytes: z.number().int().min(1024).max(
|
|
48
|
+
maxEvidenceBytes: z.number().int().min(1024).max(4 * 1024 * 1024).default(4 * 1024 * 1024),
|
|
46
49
|
/** 单份反馈累计证据允许的最大条数。 */
|
|
47
50
|
maxEvidenceCount: z.number().int().min(1).max(1e4).default(2e3)
|
|
51
|
+
}).superRefine((config, context) => {
|
|
52
|
+
if (config.notificationMode === "reach" && !config.recipients.length)
|
|
53
|
+
context.addIssue({ code: "custom", path: ["recipients"], message: "Reach recipients are required" });
|
|
48
54
|
});
|
|
49
55
|
var REPORTS = "hai_feedback_reports";
|
|
50
56
|
var EVIDENCE = "hai_feedback_evidence";
|
|
@@ -484,6 +490,7 @@ function escapeHtml(value) {
|
|
|
484
490
|
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character] ?? character);
|
|
485
491
|
}
|
|
486
492
|
function createFeedbackOperations(repo, config, capture) {
|
|
493
|
+
const budget = resolveFeedbackBudget(config);
|
|
487
494
|
const table = repo.tables;
|
|
488
495
|
const clock = feedbackClockSql();
|
|
489
496
|
let stopped = false;
|
|
@@ -557,12 +564,12 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
557
564
|
const revision = Number(row.revision) + 1;
|
|
558
565
|
const params = [additions.length, bytes, revision, Date.now()];
|
|
559
566
|
const identity = `${feedbackTextEquals("id", row.id, params)} AND ${feedbackTextEquals("scope_id", row.scope_id, params)}`;
|
|
560
|
-
params.push(row.revision, additions.length,
|
|
567
|
+
params.push(row.revision, additions.length, budget.maxEvidenceCount, bytes, budget.maxEvidenceBytes);
|
|
561
568
|
const changed = await tx.execute(`UPDATE ${table.reports} SET evidence_count = evidence_count + ?, evidence_bytes = evidence_bytes + ?, revision = ?, updated_at = ? WHERE ${identity} AND revision = ? AND deleting = 0 AND evidence_count + ? <= ? AND evidence_bytes + ? <= ?`, params);
|
|
562
569
|
if (!changed.success)
|
|
563
570
|
return changed;
|
|
564
571
|
if (!changed.data.changes)
|
|
565
|
-
return Number(row.evidence_count) + additions.length >
|
|
572
|
+
return Number(row.evidence_count) + additions.length > budget.maxEvidenceCount || Number(row.evidence_bytes) + bytes > budget.maxEvidenceBytes ? limited() : conflict();
|
|
566
573
|
for (const { key, event, bytes: itemBytes } of additions) {
|
|
567
574
|
const inserted = await tx.execute(`INSERT INTO ${table.evidence} (id, report_id, scope_id, user_id, app_id, source, sequence, kind, timestamp, request_id, error_id, revision, bytes, expires_at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [key, row.id, row.scope_id, row.user_id, row.app_id, event.source, event.sequence, event.kind, event.timestamp, event.requestId ?? null, event.errorId ?? null, revision, itemBytes, row.expires_at, JSON.stringify(event)]);
|
|
568
575
|
if (!inserted.success)
|
|
@@ -595,11 +602,14 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
595
602
|
});
|
|
596
603
|
}
|
|
597
604
|
async function notificationSummary(id) {
|
|
605
|
+
if (config.notificationMode === "disabled")
|
|
606
|
+
return reldb.sql.execute(`UPDATE ${table.reports} SET notification_status = 'disabled' WHERE id = ?`, [id]);
|
|
598
607
|
return reldb.sql.execute(`UPDATE ${table.reports} SET notification_status = CASE
|
|
599
608
|
WHEN EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND (status = 'failed' OR last_error IS NOT NULL)) THEN 'failed'
|
|
600
609
|
WHEN NOT EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND status <> 'succeeded') THEN 'sent'
|
|
601
610
|
WHEN EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND status = 'running') THEN 'sending'
|
|
602
|
-
|
|
611
|
+
WHEN EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND status = 'pending' AND result IS NOT NULL) THEN 'deferred'
|
|
612
|
+
ELSE 'pending' END WHERE id = ?`, [id, id, id, id, id]);
|
|
603
613
|
}
|
|
604
614
|
async function history(id, sql = reldb.sql) {
|
|
605
615
|
const result = await sql.query(`SELECT payload FROM ${table.history} WHERE report_id = ? ORDER BY id`, [id]);
|
|
@@ -673,13 +683,13 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
673
683
|
commit: sanitized.commit ?? config.commit,
|
|
674
684
|
status: "pending",
|
|
675
685
|
collectionStatus: "collecting",
|
|
676
|
-
notificationStatus: "pending",
|
|
686
|
+
notificationStatus: config.notificationMode === "disabled" ? "disabled" : "pending",
|
|
677
687
|
createdAt: now,
|
|
678
688
|
updatedAt: now,
|
|
679
689
|
version: 1,
|
|
680
690
|
evidenceRevision: 1
|
|
681
691
|
};
|
|
682
|
-
const row = { id, scope_id: actor.scopeId, user_id: actor.userId, app_id: config.appId, number: report.number, app_version: report.appVersion ?? null, summary: report.summary ?? "", submission_key: submissionKey, submission_hash: digest, status: "pending", collection_status: "collecting", notification_status:
|
|
692
|
+
const row = { id, scope_id: actor.scopeId, user_id: actor.userId, app_id: config.appId, number: report.number, app_version: report.appVersion ?? null, summary: report.summary ?? "", submission_key: submissionKey, submission_hash: digest, status: "pending", collection_status: "collecting", notification_status: report.notificationStatus, created_at: now, updated_at: now, version: 1, revision: 1, evidence_count: 0, evidence_bytes: 0, collect_deadline: now + config.collectionTimeoutMs, expires_at: now + config.retentionDays * 864e5, payload: report, deleting: 0 };
|
|
683
693
|
const result = await repo.transaction(async (tx) => {
|
|
684
694
|
const inserted = await repo.create({ ...row }, tx);
|
|
685
695
|
if (!inserted.success)
|
|
@@ -687,8 +697,10 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
687
697
|
const initial = await appendInside(tx, row, sanitized.evidence ?? []);
|
|
688
698
|
if (!initial.success)
|
|
689
699
|
return initial;
|
|
700
|
+
if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
|
|
701
|
+
return limited();
|
|
690
702
|
for (const attachment of sanitized.attachments ?? []) {
|
|
691
|
-
if (attachment.size >
|
|
703
|
+
if (attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
|
|
692
704
|
return invalid();
|
|
693
705
|
const attachmentId = hash(`${id}:${attachment.id}`);
|
|
694
706
|
const data = { ...attachment, feedbackId: id, status: "pending" };
|
|
@@ -696,7 +708,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
696
708
|
if (!saved.success)
|
|
697
709
|
return saved;
|
|
698
710
|
}
|
|
699
|
-
for (const recipient of config.recipients) {
|
|
711
|
+
for (const recipient of config.notificationMode === "disabled" ? [] : config.notificationMode === "console" ? ["console"] : config.recipients) {
|
|
700
712
|
const saved = await insertJob(tx, report, "notify", recipient, { recipient }, row.collect_deadline);
|
|
701
713
|
if (!saved.success)
|
|
702
714
|
return saved;
|
|
@@ -854,6 +866,11 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
854
866
|
if (!updated.data.changes)
|
|
855
867
|
return conflict();
|
|
856
868
|
const change = { id: randomUUID(), feedbackId: input.id, userId: actor.userId, from: repo.report(row.data).status, to: parsed.data.status, timestamp: now, note: parsed.data.note ? redactFeedbackText(parsed.data.note) : void 0 };
|
|
869
|
+
const previousHistory = await history(input.id, tx);
|
|
870
|
+
if (!previousHistory.success)
|
|
871
|
+
return previousHistory;
|
|
872
|
+
if (Buffer.byteLength(JSON.stringify([...previousHistory.data, change])) > 256 * 1024)
|
|
873
|
+
return limited();
|
|
857
874
|
const saved = await tx.execute(`INSERT INTO ${table.history} (id, report_id, payload) VALUES (?, ?, ?)`, [change.id, input.id, JSON.stringify(change)]);
|
|
858
875
|
return saved.success ? ok({ ...repo.report(row.data), status: parsed.data.status, version: parsed.data.expectedVersion + 1, updatedAt: now }) : saved;
|
|
859
876
|
});
|
|
@@ -1027,7 +1044,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1027
1044
|
const attachment = found.data;
|
|
1028
1045
|
const metadata = attachmentData(attachment);
|
|
1029
1046
|
const bytes = Buffer.from(parsed.data.dataBase64, "base64");
|
|
1030
|
-
if (bytes.length !== Number(attachment.size) || bytes.length >
|
|
1047
|
+
if (bytes.length !== Number(attachment.size) || bytes.length > budget.maxAttachmentBytes || !matchesContentType(bytes, metadata.contentType))
|
|
1031
1048
|
return invalid();
|
|
1032
1049
|
if (hash(bytes) !== attachment.checksum)
|
|
1033
1050
|
return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
|
|
@@ -1098,7 +1115,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1098
1115
|
const pendingReceipt = result.success && !!result.data && typeof result.data === "object" && "pending" in result.data && result.data.pending === true;
|
|
1099
1116
|
const purged = result.success && !!result.data && typeof result.data === "object" && "purged" in result.data && result.data.purged === true;
|
|
1100
1117
|
const status = result.success ? pendingReceipt ? "pending" : "succeeded" : job.kind !== "cleanup" && Number(job.attempts) + 1 >= config.maxAttempts ? "failed" : "pending";
|
|
1101
|
-
const saved = purged ? await reldb.sql.execute(`DELETE FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, token]) : await reldb.sql.execute(`UPDATE ${table.jobs} SET status = ?, token = NULL, lease_until = 0, next_run_at = ${clock} + ?, updated_at = ${clock}, last_error = ?, result = CASE WHEN ? = 1 THEN ? ELSE result END WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [status, Math.min(6e4, 1e3 * 2 ** Math.min(Number(job.attempts), 6)), result.success ? null : result.error.code, result.success ? 1 : 0, result.success ? JSON.stringify(result.data) : null, job.id, token]);
|
|
1118
|
+
const saved = purged ? await reldb.sql.execute(`DELETE FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, token]) : await reldb.sql.execute(`UPDATE ${table.jobs} SET status = ?, attempts = attempts - ?, token = NULL, lease_until = 0, next_run_at = ${clock} + ?, updated_at = ${clock}, last_error = ?, result = CASE WHEN ? = 1 THEN ? ELSE result END WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [status, pendingReceipt ? 1 : 0, Math.min(6e4, 1e3 * 2 ** Math.min(Number(job.attempts), 6)), result.success ? null : result.error.code, result.success ? 1 : 0, result.success ? JSON.stringify(result.data) : null, job.id, token]);
|
|
1102
1119
|
if (!saved.success)
|
|
1103
1120
|
return saved;
|
|
1104
1121
|
if (saved.data.changes && status === "failed" && job.kind === "collect") {
|
|
@@ -1168,24 +1185,34 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1168
1185
|
return updated.success ? ok({ collected: captured.length }) : updated;
|
|
1169
1186
|
}
|
|
1170
1187
|
if (job.kind === "notify") {
|
|
1188
|
+
if (config.notificationMode === "disabled")
|
|
1189
|
+
return ok({ disabled: true });
|
|
1171
1190
|
const recipient = "recipient" in payload && typeof payload.recipient === "string" ? payload.recipient : "";
|
|
1172
|
-
if (!config.recipients.includes(recipient))
|
|
1191
|
+
if (config.notificationMode === "reach" && !config.recipients.includes(recipient))
|
|
1173
1192
|
return invalid();
|
|
1174
|
-
if (reach.config?.dnd?.enabled)
|
|
1175
|
-
return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
|
|
1176
1193
|
const live = await withLease(job, (tx) => access(report.id, actor, void 0, tx));
|
|
1177
1194
|
if (!live.success)
|
|
1178
1195
|
return live;
|
|
1179
1196
|
const current2 = repo.report(live.data);
|
|
1197
|
+
if (config.notificationMode === "console") {
|
|
1198
|
+
core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId, summary: current2.summary, collectionStatus: current2.collectionStatus });
|
|
1199
|
+
return ok({ channel: "console" });
|
|
1200
|
+
}
|
|
1201
|
+
const { HaiReachError, reach } = await import('@h-ai/reach');
|
|
1180
1202
|
const materialStatus = {
|
|
1181
1203
|
collecting: feedbackM("feedback_materialCollecting"),
|
|
1182
1204
|
complete: feedbackM("feedback_materialComplete"),
|
|
1183
1205
|
partial: feedbackM("feedback_materialPartial"),
|
|
1184
1206
|
failed: feedbackM("feedback_materialFailed")
|
|
1185
1207
|
}[current2.collectionStatus];
|
|
1186
|
-
const detailUrl = new URL(config.adminUrl);
|
|
1187
|
-
detailUrl
|
|
1188
|
-
const
|
|
1208
|
+
const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
|
|
1209
|
+
detailUrl?.searchParams.set("feedback", report.id);
|
|
1210
|
+
const facts = [current2.number, `${current2.appId} ${current2.appVersion ?? ""}`, `${feedbackM("feedback_submittedAt")}: ${new Date(current2.createdAt).toISOString()}`, `${feedbackM("feedback_materialStatus")}: ${materialStatus}`, current2.summary ?? "", ...detailUrl ? [detailUrl.href] : []];
|
|
1211
|
+
const isEmail = reach.config?.providers.find((provider) => provider.name === config.notificationProvider)?.type === "smtp";
|
|
1212
|
+
const body = isEmail ? facts.map((value) => `<p>${escapeHtml(value)}</p>`).join("") : facts.join("\n");
|
|
1213
|
+
const sent = await reach.send({ provider: config.notificationProvider, to: recipient, subject: `${feedbackM("feedback_received")} ${current2.number}`, body, idempotencyKey: job.id, deferPolicy: "reject" });
|
|
1214
|
+
if (!sent.success && sent.error.code === HaiReachError.DND_BLOCKED.code)
|
|
1215
|
+
return ok({ pending: true, deferred: true });
|
|
1189
1216
|
if (!sent.success)
|
|
1190
1217
|
return sent;
|
|
1191
1218
|
if (!sent.data.success)
|
|
@@ -1218,7 +1245,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1218
1245
|
const info = attachmentData(file);
|
|
1219
1246
|
files.push({ id: info.id, name: info.name, contentType: info.contentType, checksum: info.checksum, bytes: stored2.data });
|
|
1220
1247
|
}
|
|
1221
|
-
const bundle = await createFeedbackBundle({ report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
|
|
1248
|
+
const bundle = await createFeedbackBundle({ maxBytes: budget.maxTotalBytes, report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
|
|
1222
1249
|
if (!bundle.success)
|
|
1223
1250
|
return bundle;
|
|
1224
1251
|
const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}-${job.token}.zip`;
|
|
@@ -1268,7 +1295,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1268
1295
|
return operations;
|
|
1269
1296
|
}
|
|
1270
1297
|
function allowedContentType(type) {
|
|
1271
|
-
return ["image/png", "image/jpeg", "image/webp", "text/plain", "application/json", "application/pdf"].includes(type);
|
|
1298
|
+
return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf"].includes(type);
|
|
1272
1299
|
}
|
|
1273
1300
|
function matchesContentType(bytes, type) {
|
|
1274
1301
|
if (type === "image/png")
|
|
@@ -1279,6 +1306,10 @@ function matchesContentType(bytes, type) {
|
|
|
1279
1306
|
return bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP";
|
|
1280
1307
|
if (type === "application/pdf")
|
|
1281
1308
|
return bytes.subarray(0, 5).toString() === "%PDF-";
|
|
1309
|
+
if (type === "video/webm")
|
|
1310
|
+
return bytes.length >= 12 && bytes.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]));
|
|
1311
|
+
if (type === "video/mp4")
|
|
1312
|
+
return bytes.length >= 12 && bytes.subarray(4, 8).toString() === "ftyp";
|
|
1282
1313
|
if (type === "application/json") {
|
|
1283
1314
|
try {
|
|
1284
1315
|
JSON.parse(bytes.toString("utf8"));
|
|
@@ -1306,12 +1337,20 @@ var feedback = {
|
|
|
1306
1337
|
let candidateCapture;
|
|
1307
1338
|
try {
|
|
1308
1339
|
const parsed = FeedbackConfigSchema.safeParse(config);
|
|
1309
|
-
if (!parsed.success
|
|
1310
|
-
return
|
|
1311
|
-
if (
|
|
1312
|
-
return
|
|
1313
|
-
if (
|
|
1314
|
-
|
|
1340
|
+
if (!parsed.success)
|
|
1341
|
+
return configFailure(parsed.error.issues.map((issue) => issue.path.join(".")));
|
|
1342
|
+
if (!reldb.isInitialized || !storage.isInitialized)
|
|
1343
|
+
return configFailure([!reldb.isInitialized ? "reldb" : "storage"]);
|
|
1344
|
+
if (parsed.data.notificationMode === "reach") {
|
|
1345
|
+
const provider = await import('@h-ai/reach').catch(() => void 0);
|
|
1346
|
+
if (!provider)
|
|
1347
|
+
return configFailure(["reach"]);
|
|
1348
|
+
const { reach } = provider;
|
|
1349
|
+
if (!reach.isInitialized)
|
|
1350
|
+
return configFailure(["reach"]);
|
|
1351
|
+
if (!reach.config?.providers.some((provider2) => provider2.name === parsed.data.notificationProvider))
|
|
1352
|
+
return configFailure(["notificationProvider"]);
|
|
1353
|
+
}
|
|
1315
1354
|
if (current) {
|
|
1316
1355
|
const previous = current;
|
|
1317
1356
|
current = null;
|
|
@@ -1365,6 +1404,9 @@ var feedback = {
|
|
|
1365
1404
|
get config() {
|
|
1366
1405
|
return current ? { ...current.config, recipients: [...current.config.recipients], envAllowlist: [...current.config.envAllowlist] } : null;
|
|
1367
1406
|
},
|
|
1407
|
+
get budget() {
|
|
1408
|
+
return resolveFeedbackBudget(current?.config);
|
|
1409
|
+
},
|
|
1368
1410
|
submit: (input, actor) => guarded(() => (current?.operations ?? unavailable).submit(input, actor)),
|
|
1369
1411
|
list: (input, actor) => guarded(() => (current?.operations ?? unavailable).list(input, actor)),
|
|
1370
1412
|
get: (id, actor) => guarded(() => (current?.operations ?? unavailable).get(id, actor)),
|
|
@@ -1379,6 +1421,9 @@ var feedback = {
|
|
|
1379
1421
|
getExport: (input, actor) => guarded(() => (current?.operations ?? unavailable).getExport(input, actor)),
|
|
1380
1422
|
runJobs: () => guarded(() => (current?.operations ?? unavailable).runJobs())
|
|
1381
1423
|
};
|
|
1424
|
+
function configFailure(fields) {
|
|
1425
|
+
return { success: false, error: { ...HaiFeedbackError.CONFIG_ERROR, message: feedbackM("feedback_configError"), ext: { module: "feedback", fields } } };
|
|
1426
|
+
}
|
|
1382
1427
|
async function guarded(work) {
|
|
1383
1428
|
const promise = core.context.run({ suppressLogObservers: true }, work);
|
|
1384
1429
|
inFlight.add(promise);
|