@rei-standard/amsg-server 2.5.3 → 2.6.0-next.1
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/dist/chunk-6WA3XDPP.mjs +1826 -0
- package/dist/chunk-S5DV7AUH.cjs +1826 -0
- package/dist/cloudflare-5Ryuxz10.d.cts +2775 -0
- package/dist/cloudflare-5Ryuxz10.d.ts +2775 -0
- package/dist/cloudflare.cjs +22 -0
- package/dist/cloudflare.d.cts +3 -0
- package/dist/cloudflare.d.ts +3 -0
- package/dist/cloudflare.mjs +22 -0
- package/dist/index.cjs +50 -1126
- package/dist/index.d.cts +37 -1769
- package/dist/index.d.ts +37 -1769
- package/dist/index.mjs +43 -1119
- package/package.json +9 -3
|
@@ -0,0 +1,1826 @@
|
|
|
1
|
+
// src/server/lib/validation.js
|
|
2
|
+
import { validateAvatarUrl } from "@rei-standard/amsg-shared";
|
|
3
|
+
function isValidISO8601(dateString) {
|
|
4
|
+
const date = new Date(dateString);
|
|
5
|
+
return date instanceof Date && !isNaN(date.getTime());
|
|
6
|
+
}
|
|
7
|
+
function isValidUrl(urlString) {
|
|
8
|
+
try {
|
|
9
|
+
new URL(urlString);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isValidUUID(uuid) {
|
|
16
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
return uuidRegex.test(uuid);
|
|
18
|
+
}
|
|
19
|
+
function isValidUUIDv4(uuid) {
|
|
20
|
+
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
21
|
+
return uuidV4Regex.test(uuid);
|
|
22
|
+
}
|
|
23
|
+
var VALID_LLM_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
|
|
24
|
+
var SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
25
|
+
var SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
26
|
+
function validateSplitPattern(value) {
|
|
27
|
+
if (value === void 0 || value === null) return null;
|
|
28
|
+
const isArray = Array.isArray(value);
|
|
29
|
+
const items = isArray ? value : [value];
|
|
30
|
+
if (isArray && items.length === 0) return null;
|
|
31
|
+
if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
|
|
32
|
+
return `splitPattern \u6570\u7EC4\u6700\u591A ${SPLIT_PATTERN_MAX_ITEMS} \u9879`;
|
|
33
|
+
}
|
|
34
|
+
for (let i = 0; i < items.length; i++) {
|
|
35
|
+
const s = items[i];
|
|
36
|
+
const label = isArray ? `splitPattern[${i}]` : "splitPattern";
|
|
37
|
+
if (typeof s !== "string") return `${label} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`;
|
|
38
|
+
if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
|
|
39
|
+
return `${label} \u4E0D\u80FD\u8D85\u8FC7 ${SPLIT_PATTERN_MAX_LENGTH} \u5B57\u7B26`;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
new RegExp(s);
|
|
43
|
+
} catch (_) {
|
|
44
|
+
return `${label} \u4E0D\u662F\u6709\u6548\u6B63\u5219\u8868\u8FBE\u5F0F`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function validateLlmMessagesArray(messages) {
|
|
50
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
51
|
+
return "messages must be a non-empty array";
|
|
52
|
+
}
|
|
53
|
+
for (let i = 0; i < messages.length; i++) {
|
|
54
|
+
const m = messages[i];
|
|
55
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) {
|
|
56
|
+
return `messages[${i}] must be an object`;
|
|
57
|
+
}
|
|
58
|
+
if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
|
|
59
|
+
return `messages[${i}].role must be one of system / user / assistant / tool`;
|
|
60
|
+
}
|
|
61
|
+
if (typeof m.content === "string") {
|
|
62
|
+
if (!m.content) return `messages[${i}].content must be a non-empty string`;
|
|
63
|
+
} else if (Array.isArray(m.content)) {
|
|
64
|
+
if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
|
|
65
|
+
} else {
|
|
66
|
+
return `messages[${i}].content must be a non-empty string or a non-empty array`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function validateScheduleMessagePayload(payload) {
|
|
72
|
+
if (!payload.contactName || typeof payload.contactName !== "string") {
|
|
73
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["contactName"] } };
|
|
74
|
+
}
|
|
75
|
+
if (!payload.messageType || !["fixed", "prompted", "auto", "instant"].includes(payload.messageType)) {
|
|
76
|
+
return { valid: false, errorCode: "INVALID_MESSAGE_TYPE", errorMessage: "\u6D88\u606F\u7C7B\u578B\u65E0\u6548", details: { providedType: payload.messageType, allowedTypes: ["fixed", "prompted", "auto", "instant"] } };
|
|
77
|
+
}
|
|
78
|
+
if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
|
|
79
|
+
return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u683C\u5F0F\u65E0\u6548", details: { field: "firstSendTime" } };
|
|
80
|
+
}
|
|
81
|
+
if (payload.firstSendTime && new Date(payload.firstSendTime) <= /* @__PURE__ */ new Date()) {
|
|
82
|
+
return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u5FC5\u987B\u5728\u672A\u6765", details: { field: "firstSendTime", reason: "must be in the future" } };
|
|
83
|
+
}
|
|
84
|
+
if (!payload.pushSubscription || typeof payload.pushSubscription !== "object") {
|
|
85
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["pushSubscription"] } };
|
|
86
|
+
}
|
|
87
|
+
if (payload.recurrenceType && !["none", "daily", "weekly"].includes(payload.recurrenceType)) {
|
|
88
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["recurrenceType"] } };
|
|
89
|
+
}
|
|
90
|
+
if (payload.maxTokens !== void 0 && payload.maxTokens !== null && (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0)) {
|
|
91
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["maxTokens"] } };
|
|
92
|
+
}
|
|
93
|
+
if (payload.messageType === "fixed") {
|
|
94
|
+
if (!payload.userMessage) {
|
|
95
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["userMessage (required for fixed type)"] } };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const promptCheck = (() => {
|
|
99
|
+
const hasCompletePrompt = payload.completePrompt !== void 0 && payload.completePrompt !== null && payload.completePrompt !== "";
|
|
100
|
+
const hasMessages = payload.messages !== void 0 && payload.messages !== null;
|
|
101
|
+
if (hasCompletePrompt && hasMessages) {
|
|
102
|
+
return {
|
|
103
|
+
error: { code: "INVALID_PARAMETERS", message: "exactly one of `completePrompt` or `messages` must be provided\uFF08\u4E24\u8005\u4E0D\u80FD\u540C\u65F6\u51FA\u73B0\uFF09", details: { invalidFields: ["completePrompt", "messages"] } },
|
|
104
|
+
hasCompletePrompt: true,
|
|
105
|
+
hasMessages: true
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (hasMessages) {
|
|
109
|
+
const err = validateLlmMessagesArray(payload.messages);
|
|
110
|
+
if (err) {
|
|
111
|
+
return {
|
|
112
|
+
error: { code: "INVALID_PARAMETERS", message: err, details: { invalidFields: ["messages"] } },
|
|
113
|
+
hasCompletePrompt: false,
|
|
114
|
+
hasMessages: true
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { error: null, hasCompletePrompt, hasMessages };
|
|
119
|
+
})();
|
|
120
|
+
if (promptCheck.error) {
|
|
121
|
+
return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
|
|
122
|
+
}
|
|
123
|
+
const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
|
|
124
|
+
if (payload.messageType === "prompted" || payload.messageType === "auto") {
|
|
125
|
+
const missingAiFields = [];
|
|
126
|
+
if (!hasPrompt) missingAiFields.push("completePrompt or messages");
|
|
127
|
+
if (!payload.apiUrl) missingAiFields.push("apiUrl");
|
|
128
|
+
if (!payload.apiKey) missingAiFields.push("apiKey");
|
|
129
|
+
if (!payload.primaryModel) missingAiFields.push("primaryModel");
|
|
130
|
+
if (missingAiFields.length > 0) {
|
|
131
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: missingAiFields } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (payload.messageType === "instant") {
|
|
135
|
+
if (payload.recurrenceType && payload.recurrenceType !== "none") {
|
|
136
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u7684 recurrenceType \u5FC5\u987B\u4E3A none", details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
|
|
137
|
+
}
|
|
138
|
+
const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
|
|
139
|
+
const hasUserMessage = payload.userMessage;
|
|
140
|
+
if (!hasAiConfig && !hasUserMessage) {
|
|
141
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u5FC5\u987B\u63D0\u4F9B userMessage \u6216\u5B8C\u6574\u7684 AI \u914D\u7F6E", details: { missingFields: ["userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)"] } };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
|
|
145
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature (must be a finite number)"] } };
|
|
146
|
+
}
|
|
147
|
+
const avatarErr = validateAvatarUrl(payload.avatarUrl);
|
|
148
|
+
if (avatarErr) {
|
|
149
|
+
console.warn("[amsg-server] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", avatarErr);
|
|
150
|
+
payload.avatarUrl = null;
|
|
151
|
+
}
|
|
152
|
+
if (payload.uuid && !isValidUUID(payload.uuid)) {
|
|
153
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["uuid (invalid UUID format)"] } };
|
|
154
|
+
}
|
|
155
|
+
if (payload.messageSubtype !== void 0 && payload.messageSubtype !== null && typeof payload.messageSubtype !== "string") {
|
|
156
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["messageSubtype"] } };
|
|
157
|
+
}
|
|
158
|
+
const splitErr = validateSplitPattern(payload.splitPattern);
|
|
159
|
+
if (splitErr) {
|
|
160
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: splitErr, details: { invalidFields: ["splitPattern"] } };
|
|
161
|
+
}
|
|
162
|
+
return { valid: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/server/lib/encryption.js
|
|
166
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
|
167
|
+
function deriveUserEncryptionKey(userId, masterKey) {
|
|
168
|
+
return createHash("sha256").update(masterKey + userId).digest("hex").slice(0, 64);
|
|
169
|
+
}
|
|
170
|
+
function decryptPayload(encryptedPayload, encryptionKey) {
|
|
171
|
+
const { iv, authTag, encryptedData } = encryptedPayload;
|
|
172
|
+
const decipher = createDecipheriv(
|
|
173
|
+
"aes-256-gcm",
|
|
174
|
+
Buffer.from(encryptionKey, "hex"),
|
|
175
|
+
Buffer.from(iv, "base64")
|
|
176
|
+
);
|
|
177
|
+
decipher.setAuthTag(Buffer.from(authTag, "base64"));
|
|
178
|
+
const decrypted = Buffer.concat([
|
|
179
|
+
decipher.update(Buffer.from(encryptedData, "base64")),
|
|
180
|
+
decipher.final()
|
|
181
|
+
]);
|
|
182
|
+
return JSON.parse(decrypted.toString("utf8"));
|
|
183
|
+
}
|
|
184
|
+
function encryptPayload(payload, encryptionKey) {
|
|
185
|
+
const plaintext = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
186
|
+
const iv = randomBytes(12);
|
|
187
|
+
const cipher = createCipheriv("aes-256-gcm", Buffer.from(encryptionKey, "hex"), iv);
|
|
188
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
189
|
+
const authTag = cipher.getAuthTag();
|
|
190
|
+
return {
|
|
191
|
+
iv: iv.toString("base64"),
|
|
192
|
+
authTag: authTag.toString("base64"),
|
|
193
|
+
encryptedData: encrypted.toString("base64")
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function encryptForStorage(text, encryptionKey) {
|
|
197
|
+
const iv = randomBytes(16);
|
|
198
|
+
const cipher = createCipheriv("aes-256-gcm", Buffer.from(encryptionKey, "hex"), iv);
|
|
199
|
+
const encrypted = cipher.update(text, "utf8", "hex") + cipher.final("hex");
|
|
200
|
+
const authTag = cipher.getAuthTag();
|
|
201
|
+
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
|
202
|
+
}
|
|
203
|
+
function decryptFromStorage(encryptedText, encryptionKey) {
|
|
204
|
+
const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(":");
|
|
205
|
+
const decipher = createDecipheriv(
|
|
206
|
+
"aes-256-gcm",
|
|
207
|
+
Buffer.from(encryptionKey, "hex"),
|
|
208
|
+
Buffer.from(ivHex, "hex")
|
|
209
|
+
);
|
|
210
|
+
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
|
211
|
+
return decipher.update(encryptedDataHex, "hex", "utf8") + decipher.final("utf8");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/server/lib/request.js
|
|
215
|
+
var REQUEST_ERRORS = {
|
|
216
|
+
INVALID_JSON: { code: "INVALID_JSON", message: "\u8BF7\u6C42\u4F53\u4E0D\u662F\u6709\u6548\u7684 JSON" },
|
|
217
|
+
INVALID_REQUEST_BODY: { code: "INVALID_REQUEST_BODY", message: "\u8BF7\u6C42\u4F53\u683C\u5F0F\u65E0\u6548" },
|
|
218
|
+
INVALID_ENCRYPTED_PAYLOAD: { code: "INVALID_ENCRYPTED_PAYLOAD", message: "\u52A0\u5BC6\u6570\u636E\u683C\u5F0F\u9519\u8BEF" }
|
|
219
|
+
};
|
|
220
|
+
function parseBodyAsObject(body, options = {}) {
|
|
221
|
+
const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON;
|
|
222
|
+
const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY;
|
|
223
|
+
let parsed = body;
|
|
224
|
+
if (typeof parsed === "string") {
|
|
225
|
+
try {
|
|
226
|
+
parsed = JSON.parse(parsed);
|
|
227
|
+
} catch {
|
|
228
|
+
return { ok: false, error: invalidJson };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!isPlainObject(parsed)) {
|
|
232
|
+
return { ok: false, error: invalidType };
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, data: parsed };
|
|
235
|
+
}
|
|
236
|
+
function parseJsonBody(body) {
|
|
237
|
+
return parseBodyAsObject(body, {
|
|
238
|
+
invalidJson: REQUEST_ERRORS.INVALID_JSON,
|
|
239
|
+
invalidType: REQUEST_ERRORS.INVALID_REQUEST_BODY
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function isPlainObject(value) {
|
|
243
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
244
|
+
}
|
|
245
|
+
function isEncryptedEnvelope(payload) {
|
|
246
|
+
if (!isPlainObject(payload)) return false;
|
|
247
|
+
return typeof payload.iv === "string" && typeof payload.authTag === "string" && typeof payload.encryptedData === "string";
|
|
248
|
+
}
|
|
249
|
+
function parseEncryptedBody(body) {
|
|
250
|
+
const parsedBody = parseBodyAsObject(body, {
|
|
251
|
+
invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD,
|
|
252
|
+
invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD
|
|
253
|
+
});
|
|
254
|
+
if (!parsedBody.ok) {
|
|
255
|
+
return parsedBody;
|
|
256
|
+
}
|
|
257
|
+
if (!isEncryptedEnvelope(parsedBody.data)) {
|
|
258
|
+
return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD };
|
|
259
|
+
}
|
|
260
|
+
return parsedBody;
|
|
261
|
+
}
|
|
262
|
+
function getHeader(headers = {}, name) {
|
|
263
|
+
if (!headers || typeof headers !== "object") {
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
const lowerName = String(name || "").toLowerCase();
|
|
267
|
+
if (!lowerName) return "";
|
|
268
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
269
|
+
if (String(key).toLowerCase() === lowerName) {
|
|
270
|
+
return String(value || "").trim();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return "";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/server/handlers/get-user-key.js
|
|
277
|
+
function createGetUserKeyHandler(ctx) {
|
|
278
|
+
async function GET(url, headers) {
|
|
279
|
+
const effectiveHeaders = headers || url || {};
|
|
280
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
|
|
281
|
+
if (!tenantResult.ok) {
|
|
282
|
+
return tenantResult.error;
|
|
283
|
+
}
|
|
284
|
+
const { masterKey } = tenantResult.context;
|
|
285
|
+
const userId = getHeader(effectiveHeaders, "x-user-id");
|
|
286
|
+
if (!userId) {
|
|
287
|
+
return {
|
|
288
|
+
status: 400,
|
|
289
|
+
body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } }
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (!isValidUUIDv4(userId)) {
|
|
293
|
+
return {
|
|
294
|
+
status: 400,
|
|
295
|
+
body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
status: 200,
|
|
300
|
+
body: {
|
|
301
|
+
success: true,
|
|
302
|
+
data: {
|
|
303
|
+
userKey: deriveUserEncryptionKey(userId, masterKey),
|
|
304
|
+
version: 1
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return { GET };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/server/handlers/schedule-message.js
|
|
313
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
314
|
+
|
|
315
|
+
// src/server/lib/db-errors.js
|
|
316
|
+
function isUniqueViolation(error) {
|
|
317
|
+
if (!error || typeof error !== "object") return false;
|
|
318
|
+
const code = error.code;
|
|
319
|
+
if (code === "23505") return true;
|
|
320
|
+
const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
321
|
+
return message.includes("duplicate key") || message.includes("unique constraint");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/server/lib/message-processor.js
|
|
325
|
+
import { randomUUID } from "crypto";
|
|
326
|
+
import {
|
|
327
|
+
buildContentPush,
|
|
328
|
+
buildReasoningPush,
|
|
329
|
+
readReasoningContent,
|
|
330
|
+
stripReasoningTags
|
|
331
|
+
} from "@rei-standard/amsg-shared";
|
|
332
|
+
var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
333
|
+
var SLEEP_BETWEEN_MESSAGES_MS = 1500;
|
|
334
|
+
function splitOnceByRegex(chunk, regex) {
|
|
335
|
+
const out = chunk.split(regex).reduce((acc, part, i, arr) => {
|
|
336
|
+
if (i % 2 === 0 && part.trim()) {
|
|
337
|
+
const punctuation = arr[i + 1] || "";
|
|
338
|
+
acc.push(part.trim() + punctuation);
|
|
339
|
+
}
|
|
340
|
+
return acc;
|
|
341
|
+
}, []).filter((s) => s.length > 0);
|
|
342
|
+
return out.length > 0 ? out : [chunk];
|
|
343
|
+
}
|
|
344
|
+
function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
345
|
+
const sources = splitPattern == null ? null : Array.isArray(splitPattern) ? splitPattern : [splitPattern];
|
|
346
|
+
const regexes = sources && sources.length > 0 ? sources.map((s) => new RegExp(s)) : [DEFAULT_SPLIT_REGEX];
|
|
347
|
+
let chunks = [messageContent];
|
|
348
|
+
for (const regex of regexes) {
|
|
349
|
+
chunks = chunks.flatMap((c) => splitOnceByRegex(c, regex));
|
|
350
|
+
}
|
|
351
|
+
return chunks.length > 0 ? chunks : [messageContent];
|
|
352
|
+
}
|
|
353
|
+
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
354
|
+
try {
|
|
355
|
+
const masterKey = providedMasterKey || ctx.masterKey;
|
|
356
|
+
if (!masterKey) {
|
|
357
|
+
return { success: false, messagesSent: 0, error: "TENANT_MASTER_KEY_MISSING" };
|
|
358
|
+
}
|
|
359
|
+
const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
|
|
360
|
+
const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
361
|
+
let messageContent;
|
|
362
|
+
let llmResponse = null;
|
|
363
|
+
if (decryptedPayload.messageType === "fixed") {
|
|
364
|
+
messageContent = decryptedPayload.userMessage;
|
|
365
|
+
} else if (decryptedPayload.messageType === "instant") {
|
|
366
|
+
const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
|
|
367
|
+
if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
|
|
368
|
+
const aiResult = await _callAI(decryptedPayload);
|
|
369
|
+
messageContent = aiResult.content;
|
|
370
|
+
llmResponse = aiResult.response;
|
|
371
|
+
} else if (decryptedPayload.userMessage) {
|
|
372
|
+
messageContent = decryptedPayload.userMessage;
|
|
373
|
+
} else {
|
|
374
|
+
throw new Error("Invalid instant message: no content source available");
|
|
375
|
+
}
|
|
376
|
+
} else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
|
|
377
|
+
const aiResult = await _callAI(decryptedPayload);
|
|
378
|
+
messageContent = aiResult.content;
|
|
379
|
+
llmResponse = aiResult.response;
|
|
380
|
+
} else {
|
|
381
|
+
throw new Error("Invalid message configuration: no content source available");
|
|
382
|
+
}
|
|
383
|
+
const reasoning = readReasoningContent(llmResponse);
|
|
384
|
+
if (reasoning) {
|
|
385
|
+
messageContent = stripReasoningTags(messageContent);
|
|
386
|
+
}
|
|
387
|
+
const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
|
|
388
|
+
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
389
|
+
throw new Error("VAPID configuration missing - push notifications cannot be sent");
|
|
390
|
+
}
|
|
391
|
+
const pushSubscription = decryptedPayload.pushSubscription;
|
|
392
|
+
const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
|
|
393
|
+
const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
|
|
394
|
+
const messageSubtype = decryptedPayload.messageSubtype || "chat";
|
|
395
|
+
const avatarUrl = decryptedPayload.avatarUrl || null;
|
|
396
|
+
const metadata = decryptedPayload.metadata || {};
|
|
397
|
+
const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}_instant`;
|
|
398
|
+
if (reasoning) {
|
|
399
|
+
const reasoningPush = buildReasoningPush({
|
|
400
|
+
messageType: decryptedPayload.messageType,
|
|
401
|
+
source,
|
|
402
|
+
messageId: `${messageIdBase}_reasoning`,
|
|
403
|
+
sessionId,
|
|
404
|
+
reasoningContent: reasoning,
|
|
405
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
406
|
+
title: `\u6765\u81EA ${decryptedPayload.contactName}`,
|
|
407
|
+
contactName: decryptedPayload.contactName,
|
|
408
|
+
avatarUrl,
|
|
409
|
+
messageSubtype,
|
|
410
|
+
metadata
|
|
411
|
+
});
|
|
412
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
|
|
413
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
414
|
+
}
|
|
415
|
+
for (let i = 0; i < messages.length; i++) {
|
|
416
|
+
const contentPush = buildContentPush({
|
|
417
|
+
messageType: decryptedPayload.messageType,
|
|
418
|
+
source,
|
|
419
|
+
messageId: `${messageIdBase}_${i}`,
|
|
420
|
+
sessionId,
|
|
421
|
+
message: messages[i],
|
|
422
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
423
|
+
title: `\u6765\u81EA ${decryptedPayload.contactName}`,
|
|
424
|
+
contactName: decryptedPayload.contactName,
|
|
425
|
+
avatarUrl,
|
|
426
|
+
messageSubtype,
|
|
427
|
+
messageIndex: i + 1,
|
|
428
|
+
totalMessages: messages.length,
|
|
429
|
+
taskId: task.id || null,
|
|
430
|
+
metadata
|
|
431
|
+
});
|
|
432
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
|
|
433
|
+
if (i < messages.length - 1) {
|
|
434
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { success: true, messagesSent: messages.length };
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return { success: false, messagesSent: 0, error: error.message };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
|
|
443
|
+
let retryCount = 0;
|
|
444
|
+
const masterKey = providedMasterKey || ctx.masterKey;
|
|
445
|
+
if (!masterKey) {
|
|
446
|
+
return {
|
|
447
|
+
success: false,
|
|
448
|
+
error: { code: "TENANT_MASTER_KEY_MISSING", message: "\u79DF\u6237\u4E3B\u5BC6\u94A5\u4E0D\u5B58\u5728\u6216\u914D\u7F6E\u5F02\u5E38" }
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
while (retryCount <= maxRetries) {
|
|
452
|
+
let task;
|
|
453
|
+
try {
|
|
454
|
+
task = userId ? await ctx.db.getTaskByUuid(uuid, userId) : await ctx.db.getTaskByUuidOnly(uuid);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (retryCount < maxRetries) {
|
|
457
|
+
retryCount++;
|
|
458
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3 * retryCount));
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
success: false,
|
|
463
|
+
error: { code: "INTERNAL_ERROR", message: error.message, retriesAttempted: retryCount }
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (!task) {
|
|
467
|
+
return { success: false, error: { code: "TASK_NOT_FOUND", message: "\u4EFB\u52A1\u4E0D\u5B58\u5728\u6216\u5DF2\u5904\u7406" } };
|
|
468
|
+
}
|
|
469
|
+
const result = await processSingleMessage(task, ctx, masterKey);
|
|
470
|
+
if (!result.success) {
|
|
471
|
+
if (retryCount < maxRetries) {
|
|
472
|
+
retryCount++;
|
|
473
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3 * retryCount));
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
await ctx.db.updateTaskById(task.id, { status: "failed", retry_count: retryCount });
|
|
478
|
+
} catch (_updateError) {
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
success: false,
|
|
482
|
+
error: { code: "PROCESSING_ERROR", message: result.error, retriesAttempted: retryCount }
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
await ctx.db.deleteTaskById(task.id);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
try {
|
|
489
|
+
await ctx.db.updateTaskById(task.id, { status: "sent", retry_count: 0 });
|
|
490
|
+
} catch (_markSentError) {
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
success: false,
|
|
494
|
+
error: {
|
|
495
|
+
code: "POST_SEND_CLEANUP_FAILED",
|
|
496
|
+
message: "\u6D88\u606F\u5DF2\u53D1\u9001\uFF0C\u4F46\u4EFB\u52A1\u6E05\u7406\u5931\u8D25",
|
|
497
|
+
details: { error: error.message }
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function _callAI(payload) {
|
|
505
|
+
const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
|
|
506
|
+
const requestBody = buildAiRequestBody(payload);
|
|
507
|
+
const aiResponse = await fetch(normalizedApiUrl, {
|
|
508
|
+
method: "POST",
|
|
509
|
+
headers: {
|
|
510
|
+
"Content-Type": "application/json",
|
|
511
|
+
"Authorization": `Bearer ${payload.apiKey}`
|
|
512
|
+
},
|
|
513
|
+
body: JSON.stringify(requestBody),
|
|
514
|
+
signal: AbortSignal.timeout(3e5)
|
|
515
|
+
});
|
|
516
|
+
if (!aiResponse.ok) {
|
|
517
|
+
if (aiResponse.status === 405) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
throw new Error(
|
|
523
|
+
`AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const aiData = await aiResponse.json();
|
|
527
|
+
const content = aiData?.choices?.[0]?.message?.content;
|
|
528
|
+
if (typeof content !== "string" || !content.trim()) {
|
|
529
|
+
throw new Error("AI API error: response missing choices[0].message.content");
|
|
530
|
+
}
|
|
531
|
+
return { response: aiData, content: content.trim() };
|
|
532
|
+
}
|
|
533
|
+
function buildAiRequestBody(payload) {
|
|
534
|
+
const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
|
|
535
|
+
const requestBody = {
|
|
536
|
+
model: payload.primaryModel,
|
|
537
|
+
messages: llmMessages
|
|
538
|
+
};
|
|
539
|
+
if (payload.temperature !== void 0 && payload.temperature !== null) {
|
|
540
|
+
requestBody.temperature = payload.temperature;
|
|
541
|
+
} else if (!Array.isArray(payload.messages)) {
|
|
542
|
+
requestBody.temperature = 0.8;
|
|
543
|
+
}
|
|
544
|
+
if (payload.maxTokens === void 0 || payload.maxTokens === null) {
|
|
545
|
+
return requestBody;
|
|
546
|
+
}
|
|
547
|
+
if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
|
|
548
|
+
throw new Error("Invalid maxTokens: maxTokens must be a positive integer when provided.");
|
|
549
|
+
}
|
|
550
|
+
requestBody.max_tokens = payload.maxTokens;
|
|
551
|
+
return requestBody;
|
|
552
|
+
}
|
|
553
|
+
function normalizeAiApiUrl(apiUrl) {
|
|
554
|
+
if (typeof apiUrl !== "string" || !apiUrl.trim()) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
"Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
const trimmedApiUrl = apiUrl.trim();
|
|
560
|
+
let parsedUrl;
|
|
561
|
+
try {
|
|
562
|
+
parsedUrl = new URL(trimmedApiUrl);
|
|
563
|
+
} catch {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
|
|
569
|
+
if (/\/chat\/completions$/.test(path)) {
|
|
570
|
+
} else if (path === "/") {
|
|
571
|
+
path = "/v1/chat/completions";
|
|
572
|
+
} else if (/\/v\d+$/.test(path)) {
|
|
573
|
+
path = `${path}/chat/completions`;
|
|
574
|
+
}
|
|
575
|
+
parsedUrl.pathname = path;
|
|
576
|
+
return parsedUrl.toString();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/server/handlers/schedule-message.js
|
|
580
|
+
function createScheduleMessageHandler(ctx) {
|
|
581
|
+
async function POST(headers, body) {
|
|
582
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
583
|
+
if (!tenantResult.ok) {
|
|
584
|
+
return tenantResult.error;
|
|
585
|
+
}
|
|
586
|
+
const tenantCtx = tenantResult.context;
|
|
587
|
+
const db = tenantCtx.db;
|
|
588
|
+
const masterKey = tenantCtx.masterKey;
|
|
589
|
+
const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
|
|
590
|
+
const encryptionVersion = getHeader(headers, "x-encryption-version");
|
|
591
|
+
const userId = getHeader(headers, "x-user-id");
|
|
592
|
+
if (!isEncrypted) {
|
|
593
|
+
return { status: 400, body: { success: false, error: { code: "ENCRYPTION_REQUIRED", message: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6" } } };
|
|
594
|
+
}
|
|
595
|
+
if (!userId) {
|
|
596
|
+
return { status: 400, body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } } };
|
|
597
|
+
}
|
|
598
|
+
if (!isValidUUIDv4(userId)) {
|
|
599
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
|
|
600
|
+
}
|
|
601
|
+
if (encryptionVersion !== "1") {
|
|
602
|
+
return { status: 400, body: { success: false, error: { code: "UNSUPPORTED_ENCRYPTION_VERSION", message: "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301" } } };
|
|
603
|
+
}
|
|
604
|
+
const parsedBody = parseEncryptedBody(body);
|
|
605
|
+
if (!parsedBody.ok) {
|
|
606
|
+
return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
607
|
+
}
|
|
608
|
+
const encryptedBody = parsedBody.data;
|
|
609
|
+
let payload;
|
|
610
|
+
try {
|
|
611
|
+
const userKey2 = deriveUserEncryptionKey(userId, masterKey);
|
|
612
|
+
payload = decryptPayload(encryptedBody, userKey2);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (error instanceof SyntaxError) {
|
|
615
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_PAYLOAD_FORMAT", message: "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u4E0D\u662F\u6709\u6548 JSON" } } };
|
|
616
|
+
}
|
|
617
|
+
const message = typeof error.message === "string" ? error.message : "";
|
|
618
|
+
if (message.includes("auth") || message.includes("Unsupported state")) {
|
|
619
|
+
return { status: 400, body: { success: false, error: { code: "DECRYPTION_FAILED", message: "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25" } } };
|
|
620
|
+
}
|
|
621
|
+
return { status: 400, body: { success: false, error: { code: "DECRYPTION_FAILED", message: "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25" } } };
|
|
622
|
+
}
|
|
623
|
+
if (!isPlainObject(payload)) {
|
|
624
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_PAYLOAD_FORMAT", message: "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u5FC5\u987B\u662F JSON \u5BF9\u8C61" } } };
|
|
625
|
+
}
|
|
626
|
+
const validationResult = validateScheduleMessagePayload(payload);
|
|
627
|
+
if (!validationResult.valid) {
|
|
628
|
+
return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } };
|
|
629
|
+
}
|
|
630
|
+
const taskUuid = payload.uuid || randomUUID2();
|
|
631
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
632
|
+
const fullTaskData = {
|
|
633
|
+
contactName: payload.contactName,
|
|
634
|
+
avatarUrl: payload.avatarUrl || null,
|
|
635
|
+
messageType: payload.messageType,
|
|
636
|
+
messageSubtype: payload.messageSubtype || "chat",
|
|
637
|
+
userMessage: payload.userMessage || null,
|
|
638
|
+
firstSendTime: payload.firstSendTime,
|
|
639
|
+
recurrenceType: payload.recurrenceType || "none",
|
|
640
|
+
apiUrl: payload.apiUrl || null,
|
|
641
|
+
apiKey: payload.apiKey || null,
|
|
642
|
+
primaryModel: payload.primaryModel || null,
|
|
643
|
+
// Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
|
|
644
|
+
// style array). Validation has already enforced exactly-one-of, so
|
|
645
|
+
// exactly one of these will be non-null when an AI config is provided.
|
|
646
|
+
completePrompt: payload.completePrompt || null,
|
|
647
|
+
messages: Array.isArray(payload.messages) ? payload.messages : null,
|
|
648
|
+
maxTokens: payload.maxTokens ?? null,
|
|
649
|
+
temperature: payload.temperature ?? null,
|
|
650
|
+
// 0.6.0+: optional caller-provided regex (string or string[]) used by
|
|
651
|
+
// the message processor to chunk LLM output into individual pushes.
|
|
652
|
+
// null → processor falls back to the default /([。!?!?]+)/ regex.
|
|
653
|
+
splitPattern: payload.splitPattern ?? null,
|
|
654
|
+
pushSubscription: payload.pushSubscription,
|
|
655
|
+
metadata: payload.metadata || {}
|
|
656
|
+
};
|
|
657
|
+
const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
|
|
658
|
+
if (payload.messageType === "instant") {
|
|
659
|
+
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
660
|
+
return {
|
|
661
|
+
status: 500,
|
|
662
|
+
body: {
|
|
663
|
+
success: false,
|
|
664
|
+
error: {
|
|
665
|
+
code: "VAPID_CONFIG_ERROR",
|
|
666
|
+
message: "VAPID \u914D\u7F6E\u7F3A\u5931\uFF0C\u65E0\u6CD5\u53D1\u9001\u5373\u65F6\u6D88\u606F",
|
|
667
|
+
details: {
|
|
668
|
+
missingKeys: [
|
|
669
|
+
!ctx.vapid.email && "VAPID_EMAIL",
|
|
670
|
+
!ctx.vapid.publicKey && "NEXT_PUBLIC_VAPID_PUBLIC_KEY",
|
|
671
|
+
!ctx.vapid.privateKey && "VAPID_PRIVATE_KEY"
|
|
672
|
+
].filter(Boolean)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
let dbResult;
|
|
680
|
+
try {
|
|
681
|
+
dbResult = await db.createTask({
|
|
682
|
+
user_id: userId,
|
|
683
|
+
uuid: taskUuid,
|
|
684
|
+
encrypted_payload: encryptedPayload,
|
|
685
|
+
next_send_at: payload.firstSendTime,
|
|
686
|
+
message_type: payload.messageType
|
|
687
|
+
});
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (isUniqueViolation(error)) {
|
|
690
|
+
return {
|
|
691
|
+
status: 409,
|
|
692
|
+
body: {
|
|
693
|
+
success: false,
|
|
694
|
+
error: {
|
|
695
|
+
code: "TASK_UUID_CONFLICT",
|
|
696
|
+
message: "\u4EFB\u52A1 UUID \u5DF2\u5B58\u5728\uFF0C\u8BF7\u4F7F\u7528\u65B0\u7684 uuid \u91CD\u65B0\u63D0\u4EA4"
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
if (!dbResult) {
|
|
704
|
+
return { status: 500, body: { success: false, error: { code: "TASK_CREATE_FAILED", message: "\u521B\u5EFA\u4EFB\u52A1\u5931\u8D25" } } };
|
|
705
|
+
}
|
|
706
|
+
if (payload.messageType === "instant") {
|
|
707
|
+
try {
|
|
708
|
+
const sendResult = await processMessagesByUuid(taskUuid, {
|
|
709
|
+
...ctx,
|
|
710
|
+
db,
|
|
711
|
+
masterKey
|
|
712
|
+
}, 2, userId, masterKey);
|
|
713
|
+
if (!sendResult.success) {
|
|
714
|
+
return { status: 500, body: { success: false, error: { code: "MESSAGE_SEND_FAILED", message: "\u6D88\u606F\u53D1\u9001\u5931\u8D25", details: sendResult.error } } };
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
status: 200,
|
|
718
|
+
body: {
|
|
719
|
+
success: true,
|
|
720
|
+
data: {
|
|
721
|
+
uuid: taskUuid,
|
|
722
|
+
contactName: payload.contactName,
|
|
723
|
+
messagesSent: sendResult.messagesSent,
|
|
724
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
725
|
+
status: "sent",
|
|
726
|
+
retriesUsed: sendResult.retriesUsed || 0
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
} catch (error) {
|
|
731
|
+
return { status: 500, body: { success: false, error: { code: "MESSAGE_SEND_FAILED", message: "\u6D88\u606F\u53D1\u9001\u5931\u8D25", details: { error: error.message } } } };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
status: 201,
|
|
736
|
+
body: {
|
|
737
|
+
success: true,
|
|
738
|
+
data: {
|
|
739
|
+
id: dbResult.id,
|
|
740
|
+
uuid: dbResult.uuid,
|
|
741
|
+
contactName: payload.contactName,
|
|
742
|
+
nextSendAt: dbResult.next_send_at,
|
|
743
|
+
status: dbResult.status,
|
|
744
|
+
createdAt: dbResult.created_at
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
return { POST };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/server/lib/run-tick.js
|
|
753
|
+
async function runScheduledTick(ctx) {
|
|
754
|
+
const db = ctx.db;
|
|
755
|
+
const masterKey = ctx.masterKey;
|
|
756
|
+
const startTime = Date.now();
|
|
757
|
+
const tasks = await db.getPendingTasks(50);
|
|
758
|
+
const MAX_CONCURRENT = 8;
|
|
759
|
+
const results = {
|
|
760
|
+
totalTasks: tasks.length,
|
|
761
|
+
successCount: 0,
|
|
762
|
+
failedCount: 0,
|
|
763
|
+
deletedOnceOffTasks: 0,
|
|
764
|
+
updatedRecurringTasks: 0,
|
|
765
|
+
failedTasks: []
|
|
766
|
+
};
|
|
767
|
+
async function handleDeliveryFailure(task, reason) {
|
|
768
|
+
results.failedCount++;
|
|
769
|
+
try {
|
|
770
|
+
if (task.retry_count >= 3) {
|
|
771
|
+
await db.updateTaskById(task.id, { status: "failed" });
|
|
772
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
|
|
773
|
+
} else {
|
|
774
|
+
const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
|
|
775
|
+
await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
|
|
776
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
|
|
777
|
+
}
|
|
778
|
+
} catch (updateError) {
|
|
779
|
+
results.failedTasks.push({ taskId: task.id, reason, status: "retry_update_failed", updateError: updateError.message });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async function handlePostSendPersistenceFailure(task, reason) {
|
|
783
|
+
results.failedCount++;
|
|
784
|
+
let markedSent = false;
|
|
785
|
+
try {
|
|
786
|
+
await db.updateTaskById(task.id, { status: "sent", retry_count: 0 });
|
|
787
|
+
markedSent = true;
|
|
788
|
+
} catch (_markSentError) {
|
|
789
|
+
markedSent = false;
|
|
790
|
+
}
|
|
791
|
+
results.failedTasks.push({
|
|
792
|
+
taskId: task.id,
|
|
793
|
+
reason,
|
|
794
|
+
status: markedSent ? "post_send_cleanup_failed_marked_sent" : "post_send_cleanup_failed",
|
|
795
|
+
messageDelivered: true
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
async function processTask(task) {
|
|
799
|
+
let sendResult;
|
|
800
|
+
try {
|
|
801
|
+
sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
|
|
802
|
+
} catch (error) {
|
|
803
|
+
await handleDeliveryFailure(task, error.message || "\u6D88\u606F\u53D1\u9001\u5931\u8D25");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (!sendResult.success) {
|
|
807
|
+
await handleDeliveryFailure(task, sendResult.error || "\u6D88\u606F\u53D1\u9001\u5931\u8D25");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
|
|
812
|
+
const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
813
|
+
if (decryptedPayload.recurrenceType === "none") {
|
|
814
|
+
await db.deleteTaskById(task.id);
|
|
815
|
+
results.deletedOnceOffTasks++;
|
|
816
|
+
} else {
|
|
817
|
+
let nextSendAt;
|
|
818
|
+
const currentSendAt = new Date(task.next_send_at);
|
|
819
|
+
if (decryptedPayload.recurrenceType === "daily") {
|
|
820
|
+
nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1e3);
|
|
821
|
+
} else if (decryptedPayload.recurrenceType === "weekly") {
|
|
822
|
+
nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1e3);
|
|
823
|
+
}
|
|
824
|
+
await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
|
|
825
|
+
results.updatedRecurringTasks++;
|
|
826
|
+
}
|
|
827
|
+
results.successCount++;
|
|
828
|
+
} catch (error) {
|
|
829
|
+
await handlePostSendPersistenceFailure(task, error.message || "\u53D1\u9001\u540E\u72B6\u6001\u66F4\u65B0\u5931\u8D25");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const taskQueue = [...tasks];
|
|
833
|
+
const processing = [];
|
|
834
|
+
while (taskQueue.length > 0 || processing.length > 0) {
|
|
835
|
+
while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) {
|
|
836
|
+
const task = taskQueue.shift();
|
|
837
|
+
const promise = processTask(task);
|
|
838
|
+
processing.push(promise);
|
|
839
|
+
promise.finally(() => {
|
|
840
|
+
const index = processing.indexOf(promise);
|
|
841
|
+
if (index > -1) processing.splice(index, 1);
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
if (processing.length > 0) {
|
|
845
|
+
await Promise.race(processing);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
await db.cleanupOldTasks(7);
|
|
849
|
+
const executionTime = Date.now() - startTime;
|
|
850
|
+
return {
|
|
851
|
+
totalTasks: results.totalTasks,
|
|
852
|
+
successCount: results.successCount,
|
|
853
|
+
failedCount: results.failedCount,
|
|
854
|
+
processedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
855
|
+
executionTime,
|
|
856
|
+
details: {
|
|
857
|
+
deletedOnceOffTasks: results.deletedOnceOffTasks,
|
|
858
|
+
updatedRecurringTasks: results.updatedRecurringTasks,
|
|
859
|
+
failedTasks: results.failedTasks
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/server/handlers/update-message.js
|
|
865
|
+
function createUpdateMessageHandler(ctx) {
|
|
866
|
+
async function PUT(url, headers, body) {
|
|
867
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
868
|
+
if (!tenantResult.ok) {
|
|
869
|
+
return tenantResult.error;
|
|
870
|
+
}
|
|
871
|
+
const tenantCtx = tenantResult.context;
|
|
872
|
+
const db = tenantCtx.db;
|
|
873
|
+
const masterKey = tenantCtx.masterKey;
|
|
874
|
+
const u = new URL(url, "https://dummy");
|
|
875
|
+
const taskUuid = u.searchParams.get("id");
|
|
876
|
+
if (!taskUuid) {
|
|
877
|
+
return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
|
|
878
|
+
}
|
|
879
|
+
const userId = getHeader(headers, "x-user-id");
|
|
880
|
+
if (!userId) {
|
|
881
|
+
return { status: 400, body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } } };
|
|
882
|
+
}
|
|
883
|
+
if (!isValidUUIDv4(userId)) {
|
|
884
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
|
|
885
|
+
}
|
|
886
|
+
const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
|
|
887
|
+
const encryptionVersion = getHeader(headers, "x-encryption-version");
|
|
888
|
+
if (!isEncrypted) {
|
|
889
|
+
return { status: 400, body: { success: false, error: { code: "ENCRYPTION_REQUIRED", message: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6" } } };
|
|
890
|
+
}
|
|
891
|
+
if (encryptionVersion !== "1") {
|
|
892
|
+
return { status: 400, body: { success: false, error: { code: "UNSUPPORTED_ENCRYPTION_VERSION", message: "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301" } } };
|
|
893
|
+
}
|
|
894
|
+
const parsedBody = parseEncryptedBody(body);
|
|
895
|
+
if (!parsedBody.ok) {
|
|
896
|
+
return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
897
|
+
}
|
|
898
|
+
const encryptedBody = parsedBody.data;
|
|
899
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
900
|
+
let updates;
|
|
901
|
+
try {
|
|
902
|
+
updates = decryptPayload(encryptedBody, userKey);
|
|
903
|
+
} catch (_error) {
|
|
904
|
+
return { status: 400, body: { success: false, error: { code: "DECRYPTION_FAILED", message: "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25" } } };
|
|
905
|
+
}
|
|
906
|
+
if (!isPlainObject(updates)) {
|
|
907
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF" } } };
|
|
908
|
+
}
|
|
909
|
+
if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
|
|
910
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["nextSendAt"] } } } };
|
|
911
|
+
}
|
|
912
|
+
if (updates.recurrenceType && !["none", "daily", "weekly"].includes(updates.recurrenceType)) {
|
|
913
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["recurrenceType"] } } } };
|
|
914
|
+
}
|
|
915
|
+
if (Object.prototype.hasOwnProperty.call(updates, "maxTokens") && updates.maxTokens !== null && (!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)) {
|
|
916
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["maxTokens"] } } } };
|
|
917
|
+
}
|
|
918
|
+
if (updates.completePrompt && updates.messages !== void 0 && updates.messages !== null) {
|
|
919
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "completePrompt \u4E0E messages \u4E0D\u80FD\u540C\u65F6\u66F4\u65B0\uFF08\u4E8C\u9009\u4E00\uFF09", details: { invalidFields: ["completePrompt", "messages"] } } } };
|
|
920
|
+
}
|
|
921
|
+
if (updates.messages !== void 0 && updates.messages !== null) {
|
|
922
|
+
const msgErr = validateLlmMessagesArray(updates.messages);
|
|
923
|
+
if (msgErr) {
|
|
924
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: msgErr, details: { invalidFields: ["messages"] } } } };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (Object.prototype.hasOwnProperty.call(updates, "temperature") && updates.temperature !== null && (typeof updates.temperature !== "number" || !Number.isFinite(updates.temperature))) {
|
|
928
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature"] } } } };
|
|
929
|
+
}
|
|
930
|
+
if (Object.prototype.hasOwnProperty.call(updates, "splitPattern")) {
|
|
931
|
+
const splitErr = validateSplitPattern(updates.splitPattern);
|
|
932
|
+
if (splitErr) {
|
|
933
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: splitErr, details: { invalidFields: ["splitPattern"] } } } };
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
|
|
937
|
+
const avatarErr = validateAvatarUrl(updates.avatarUrl);
|
|
938
|
+
if (avatarErr) {
|
|
939
|
+
console.warn("[amsg-server] update-message avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u5FFD\u7565\uFF1A", avatarErr);
|
|
940
|
+
delete updates.avatarUrl;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const existingTask = await db.getTaskByUuid(taskUuid, userId);
|
|
944
|
+
if (!existingTask) {
|
|
945
|
+
const taskStatus = await db.getTaskStatus(taskUuid, userId);
|
|
946
|
+
if (!taskStatus) {
|
|
947
|
+
return { status: 404, body: { success: false, error: { code: "TASK_NOT_FOUND", message: "\u6307\u5B9A\u7684\u4EFB\u52A1\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5220\u9664" } } };
|
|
948
|
+
}
|
|
949
|
+
return { status: 409, body: { success: false, error: { code: "TASK_ALREADY_COMPLETED", message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u8D25\uFF0C\u65E0\u6CD5\u66F4\u65B0" } } };
|
|
950
|
+
}
|
|
951
|
+
const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
|
|
952
|
+
const promptUpdates = {};
|
|
953
|
+
if (updates.completePrompt) {
|
|
954
|
+
promptUpdates.completePrompt = updates.completePrompt;
|
|
955
|
+
promptUpdates.messages = null;
|
|
956
|
+
} else if (updates.messages !== void 0 && updates.messages !== null) {
|
|
957
|
+
promptUpdates.messages = updates.messages;
|
|
958
|
+
promptUpdates.completePrompt = null;
|
|
959
|
+
}
|
|
960
|
+
const updatedData = {
|
|
961
|
+
...existingData,
|
|
962
|
+
...promptUpdates,
|
|
963
|
+
...updates.userMessage && { userMessage: updates.userMessage },
|
|
964
|
+
...updates.recurrenceType && { recurrenceType: updates.recurrenceType },
|
|
965
|
+
...updates.avatarUrl && { avatarUrl: updates.avatarUrl },
|
|
966
|
+
...updates.metadata && { metadata: updates.metadata },
|
|
967
|
+
...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: updates.maxTokens ?? null },
|
|
968
|
+
...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: updates.temperature ?? null },
|
|
969
|
+
// splitPattern: hasOwnProperty so that explicit `null` (= revert to
|
|
970
|
+
// default) doesn't get swallowed by truthy-spread the way the optional
|
|
971
|
+
// string fields above are.
|
|
972
|
+
...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: updates.splitPattern ?? null }
|
|
973
|
+
};
|
|
974
|
+
const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
975
|
+
const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
|
|
976
|
+
const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
|
|
977
|
+
if (!result) {
|
|
978
|
+
return { status: 409, body: { success: false, error: { code: "UPDATE_CONFLICT", message: "\u4EFB\u52A1\u66F4\u65B0\u5931\u8D25\uFF0C\u4EFB\u52A1\u53EF\u80FD\u5DF2\u88AB\u4FEE\u6539\u6216\u5220\u9664" } } };
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
status: 200,
|
|
982
|
+
body: {
|
|
983
|
+
success: true,
|
|
984
|
+
data: {
|
|
985
|
+
uuid: taskUuid,
|
|
986
|
+
updatedFields: Object.keys(updates),
|
|
987
|
+
updatedAt: result.updated_at
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
return { PUT };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// src/server/handlers/cancel-message.js
|
|
996
|
+
function createCancelMessageHandler(ctx) {
|
|
997
|
+
async function DELETE(url, headers) {
|
|
998
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
999
|
+
if (!tenantResult.ok) {
|
|
1000
|
+
return tenantResult.error;
|
|
1001
|
+
}
|
|
1002
|
+
const db = tenantResult.context.db;
|
|
1003
|
+
const u = new URL(url, "https://dummy");
|
|
1004
|
+
const taskUuid = u.searchParams.get("id");
|
|
1005
|
+
if (!taskUuid) {
|
|
1006
|
+
return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
|
|
1007
|
+
}
|
|
1008
|
+
const userId = getHeader(headers, "x-user-id");
|
|
1009
|
+
if (!userId) {
|
|
1010
|
+
return { status: 400, body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } } };
|
|
1011
|
+
}
|
|
1012
|
+
if (!isValidUUIDv4(userId)) {
|
|
1013
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
|
|
1014
|
+
}
|
|
1015
|
+
const deleted = await db.deleteTaskByUuid(taskUuid, userId);
|
|
1016
|
+
if (!deleted) {
|
|
1017
|
+
return {
|
|
1018
|
+
status: 404,
|
|
1019
|
+
body: { success: false, error: { code: "TASK_NOT_FOUND", message: "\u6307\u5B9A\u7684\u4EFB\u52A1\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5220\u9664" } }
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
status: 200,
|
|
1024
|
+
body: {
|
|
1025
|
+
success: true,
|
|
1026
|
+
data: { uuid: taskUuid, message: "\u4EFB\u52A1\u5DF2\u6210\u529F\u53D6\u6D88", deletedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
return { DELETE };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// src/server/handlers/messages.js
|
|
1034
|
+
function createMessagesHandler(ctx) {
|
|
1035
|
+
async function GET(url, headers) {
|
|
1036
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
1037
|
+
if (!tenantResult.ok) {
|
|
1038
|
+
return tenantResult.error;
|
|
1039
|
+
}
|
|
1040
|
+
const tenantCtx = tenantResult.context;
|
|
1041
|
+
const db = tenantCtx.db;
|
|
1042
|
+
const masterKey = tenantCtx.masterKey;
|
|
1043
|
+
const userId = getHeader(headers, "x-user-id");
|
|
1044
|
+
if (!userId) {
|
|
1045
|
+
return {
|
|
1046
|
+
status: 400,
|
|
1047
|
+
body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u5FC5\u987B\u63D0\u4F9B X-User-Id \u8BF7\u6C42\u5934" } }
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
if (!isValidUUIDv4(userId)) {
|
|
1051
|
+
return {
|
|
1052
|
+
status: 400,
|
|
1053
|
+
body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
const u = new URL(url, "https://dummy");
|
|
1057
|
+
const status = u.searchParams.get("status") || "all";
|
|
1058
|
+
const limit = Math.min(parseInt(u.searchParams.get("limit") || "20", 10), 100);
|
|
1059
|
+
const offset = parseInt(u.searchParams.get("offset") || "0", 10);
|
|
1060
|
+
if (isNaN(limit) || limit < 1) {
|
|
1061
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_PARAMETERS", message: "limit \u53C2\u6570\u65E0\u6548\uFF0C\u5FC5\u987B\u4E3A\u6B63\u6574\u6570" } } };
|
|
1062
|
+
}
|
|
1063
|
+
if (isNaN(offset) || offset < 0) {
|
|
1064
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_PARAMETERS", message: "offset \u53C2\u6570\u65E0\u6548\uFF0C\u5FC5\u987B\u4E3A\u975E\u8D1F\u6574\u6570" } } };
|
|
1065
|
+
}
|
|
1066
|
+
const { tasks, total } = await db.listTasks(userId, { status, limit, offset });
|
|
1067
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1068
|
+
const decryptedTasks = tasks.map((task) => {
|
|
1069
|
+
const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
1070
|
+
return {
|
|
1071
|
+
id: task.id,
|
|
1072
|
+
uuid: task.uuid,
|
|
1073
|
+
contactName: decrypted.contactName,
|
|
1074
|
+
messageType: task.message_type,
|
|
1075
|
+
messageSubtype: decrypted.messageSubtype,
|
|
1076
|
+
nextSendAt: task.next_send_at,
|
|
1077
|
+
recurrenceType: decrypted.recurrenceType,
|
|
1078
|
+
status: task.status,
|
|
1079
|
+
retryCount: task.retry_count,
|
|
1080
|
+
createdAt: task.created_at,
|
|
1081
|
+
updatedAt: task.updated_at
|
|
1082
|
+
};
|
|
1083
|
+
});
|
|
1084
|
+
const responsePayload = {
|
|
1085
|
+
tasks: decryptedTasks,
|
|
1086
|
+
pagination: { total, limit, offset, hasMore: offset + limit < total }
|
|
1087
|
+
};
|
|
1088
|
+
const encryptedResponse = encryptPayload(responsePayload, userKey);
|
|
1089
|
+
return {
|
|
1090
|
+
status: 200,
|
|
1091
|
+
body: {
|
|
1092
|
+
success: true,
|
|
1093
|
+
encrypted: true,
|
|
1094
|
+
version: 1,
|
|
1095
|
+
data: encryptedResponse
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
return { GET };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// src/server/adapters/schema.sqlite.js
|
|
1103
|
+
var SQLITE_TABLE_SQL = `
|
|
1104
|
+
CREATE TABLE IF NOT EXISTS scheduled_messages (
|
|
1105
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1106
|
+
user_id TEXT NOT NULL,
|
|
1107
|
+
uuid TEXT,
|
|
1108
|
+
encrypted_payload TEXT NOT NULL,
|
|
1109
|
+
message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
|
|
1110
|
+
next_send_at TEXT NOT NULL,
|
|
1111
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
|
1112
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
1113
|
+
created_at TEXT NOT NULL,
|
|
1114
|
+
updated_at TEXT NOT NULL
|
|
1115
|
+
)
|
|
1116
|
+
`;
|
|
1117
|
+
var SQLITE_INDEXES = [
|
|
1118
|
+
{
|
|
1119
|
+
name: "idx_pending_tasks_optimized",
|
|
1120
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized
|
|
1121
|
+
ON scheduled_messages (status, next_send_at, id, retry_count)
|
|
1122
|
+
WHERE status = 'pending'`,
|
|
1123
|
+
description: "Main query index (Cron Job finds pending tasks)",
|
|
1124
|
+
critical: false
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
name: "idx_cleanup_completed",
|
|
1128
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_cleanup_completed
|
|
1129
|
+
ON scheduled_messages (status, updated_at)
|
|
1130
|
+
WHERE status IN ('sent', 'failed')`,
|
|
1131
|
+
description: "Cleanup query index",
|
|
1132
|
+
critical: false
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
name: "idx_failed_retry",
|
|
1136
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_failed_retry
|
|
1137
|
+
ON scheduled_messages (status, retry_count, next_send_at)
|
|
1138
|
+
WHERE status = 'failed' AND retry_count < 3`,
|
|
1139
|
+
description: "Failed retry index",
|
|
1140
|
+
critical: false
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
name: "idx_user_id",
|
|
1144
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_user_id
|
|
1145
|
+
ON scheduled_messages (user_id)`,
|
|
1146
|
+
description: "User task query index",
|
|
1147
|
+
critical: false
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
name: "uidx_uuid",
|
|
1151
|
+
sql: `CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid
|
|
1152
|
+
ON scheduled_messages (uuid)
|
|
1153
|
+
WHERE uuid IS NOT NULL`,
|
|
1154
|
+
description: "UUID uniqueness guard",
|
|
1155
|
+
critical: true
|
|
1156
|
+
}
|
|
1157
|
+
];
|
|
1158
|
+
|
|
1159
|
+
// src/server/adapters/d1.js
|
|
1160
|
+
var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
|
|
1161
|
+
"user_id",
|
|
1162
|
+
"uuid",
|
|
1163
|
+
"encrypted_payload",
|
|
1164
|
+
"message_type",
|
|
1165
|
+
"next_send_at",
|
|
1166
|
+
"status",
|
|
1167
|
+
"retry_count",
|
|
1168
|
+
"created_at",
|
|
1169
|
+
"updated_at"
|
|
1170
|
+
]);
|
|
1171
|
+
var D1Adapter = class {
|
|
1172
|
+
/** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
|
|
1173
|
+
constructor(db) {
|
|
1174
|
+
this._db = db;
|
|
1175
|
+
}
|
|
1176
|
+
/** @private */
|
|
1177
|
+
_now() {
|
|
1178
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1179
|
+
}
|
|
1180
|
+
/** @private */
|
|
1181
|
+
_iso(value) {
|
|
1182
|
+
const d = new Date(value);
|
|
1183
|
+
if (Number.isNaN(d.getTime())) {
|
|
1184
|
+
throw new Error(`[amsg-server D1] invalid timestamp: ${value}`);
|
|
1185
|
+
}
|
|
1186
|
+
return d.toISOString();
|
|
1187
|
+
}
|
|
1188
|
+
async initSchema() {
|
|
1189
|
+
await this._db.prepare(SQLITE_TABLE_SQL).run();
|
|
1190
|
+
const indexResults = [];
|
|
1191
|
+
for (const index of SQLITE_INDEXES) {
|
|
1192
|
+
try {
|
|
1193
|
+
await this._db.prepare(index.sql).run();
|
|
1194
|
+
indexResults.push({ name: index.name, status: "success", description: index.description, critical: !!index.critical });
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
indexResults.push({ name: index.name, status: "failed", description: index.description, critical: !!index.critical, error: error.message });
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const criticalFailures = indexResults.filter((i) => i.critical && i.status === "failed");
|
|
1200
|
+
if (criticalFailures.length > 0) {
|
|
1201
|
+
const names = criticalFailures.map((i) => i.name).join(", ");
|
|
1202
|
+
throw new Error(
|
|
1203
|
+
`Critical index creation failed (${names}). Please remove duplicate UUID rows and run initSchema again.`
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
columnsCreated: 10,
|
|
1208
|
+
indexesCreated: indexResults.filter((r) => r.status === "success").length,
|
|
1209
|
+
indexesFailed: indexResults.filter((r) => r.status === "failed").length,
|
|
1210
|
+
columns: [],
|
|
1211
|
+
indexes: indexResults
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
async dropSchema() {
|
|
1215
|
+
await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
|
|
1216
|
+
}
|
|
1217
|
+
async createTask(params) {
|
|
1218
|
+
const now = this._now();
|
|
1219
|
+
const nextSendAt = this._iso(params.next_send_at);
|
|
1220
|
+
const res = await this._db.prepare(
|
|
1221
|
+
`INSERT INTO scheduled_messages
|
|
1222
|
+
(user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at)
|
|
1223
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
|
|
1224
|
+
).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now).run();
|
|
1225
|
+
const id = res.meta.last_row_id;
|
|
1226
|
+
return this._db.prepare(
|
|
1227
|
+
`SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?`
|
|
1228
|
+
).bind(id).first();
|
|
1229
|
+
}
|
|
1230
|
+
async getTaskByUuid(uuid, userId) {
|
|
1231
|
+
return this._db.prepare(
|
|
1232
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
1233
|
+
FROM scheduled_messages
|
|
1234
|
+
WHERE uuid = ? AND user_id = ? AND status = 'pending'
|
|
1235
|
+
LIMIT 1`
|
|
1236
|
+
).bind(uuid, userId).first();
|
|
1237
|
+
}
|
|
1238
|
+
async getTaskByUuidOnly(uuid) {
|
|
1239
|
+
return this._db.prepare(
|
|
1240
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
1241
|
+
FROM scheduled_messages
|
|
1242
|
+
WHERE uuid = ? AND status = 'pending'
|
|
1243
|
+
LIMIT 1`
|
|
1244
|
+
).bind(uuid).first();
|
|
1245
|
+
}
|
|
1246
|
+
async updateTaskById(taskId, updates) {
|
|
1247
|
+
const sets = [];
|
|
1248
|
+
const values = [];
|
|
1249
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
1250
|
+
if (!UPDATABLE_COLUMNS.has(key)) {
|
|
1251
|
+
throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
|
|
1252
|
+
}
|
|
1253
|
+
sets.push(`${key} = ?`);
|
|
1254
|
+
values.push(key === "next_send_at" ? this._iso(value) : value);
|
|
1255
|
+
}
|
|
1256
|
+
if (!Object.prototype.hasOwnProperty.call(updates, "updated_at")) {
|
|
1257
|
+
sets.push("updated_at = ?");
|
|
1258
|
+
values.push(this._now());
|
|
1259
|
+
}
|
|
1260
|
+
values.push(taskId);
|
|
1261
|
+
await this._db.prepare(
|
|
1262
|
+
`UPDATE scheduled_messages SET ${sets.join(", ")} WHERE id = ?`
|
|
1263
|
+
).bind(...values).run();
|
|
1264
|
+
return this._db.prepare("SELECT * FROM scheduled_messages WHERE id = ?").bind(taskId).first();
|
|
1265
|
+
}
|
|
1266
|
+
async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) {
|
|
1267
|
+
const now = this._now();
|
|
1268
|
+
const sets = ["encrypted_payload = ?", "updated_at = ?"];
|
|
1269
|
+
const values = [encryptedPayload, now];
|
|
1270
|
+
if (extraFields) {
|
|
1271
|
+
for (const [key, value] of Object.entries(extraFields)) {
|
|
1272
|
+
if (!UPDATABLE_COLUMNS.has(key)) {
|
|
1273
|
+
throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
|
|
1274
|
+
}
|
|
1275
|
+
sets.push(`${key} = ?`);
|
|
1276
|
+
values.push(key === "next_send_at" ? this._iso(value) : value);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
values.push(uuid, userId);
|
|
1280
|
+
const res = await this._db.prepare(
|
|
1281
|
+
`UPDATE scheduled_messages SET ${sets.join(", ")}
|
|
1282
|
+
WHERE uuid = ? AND user_id = ? AND status = 'pending'`
|
|
1283
|
+
).bind(...values).run();
|
|
1284
|
+
if (!res.meta.changes) return null;
|
|
1285
|
+
return { uuid, updated_at: now };
|
|
1286
|
+
}
|
|
1287
|
+
async deleteTaskById(taskId) {
|
|
1288
|
+
const res = await this._db.prepare("DELETE FROM scheduled_messages WHERE id = ?").bind(taskId).run();
|
|
1289
|
+
return res.meta.changes > 0;
|
|
1290
|
+
}
|
|
1291
|
+
async deleteTaskByUuid(uuid, userId) {
|
|
1292
|
+
const res = await this._db.prepare(
|
|
1293
|
+
"DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?"
|
|
1294
|
+
).bind(uuid, userId).run();
|
|
1295
|
+
return res.meta.changes > 0;
|
|
1296
|
+
}
|
|
1297
|
+
async getPendingTasks(limit = 50) {
|
|
1298
|
+
const res = await this._db.prepare(
|
|
1299
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
1300
|
+
FROM scheduled_messages
|
|
1301
|
+
WHERE status = 'pending' AND next_send_at <= ?
|
|
1302
|
+
ORDER BY next_send_at ASC
|
|
1303
|
+
LIMIT ?`
|
|
1304
|
+
).bind(this._now(), limit).all();
|
|
1305
|
+
return res.results || [];
|
|
1306
|
+
}
|
|
1307
|
+
async listTasks(userId, opts = {}) {
|
|
1308
|
+
const { status = "all", limit = 20, offset = 0 } = opts;
|
|
1309
|
+
const conditions = ["user_id = ?"];
|
|
1310
|
+
const params = [userId];
|
|
1311
|
+
if (status !== "all") {
|
|
1312
|
+
conditions.push("status = ?");
|
|
1313
|
+
params.push(status);
|
|
1314
|
+
}
|
|
1315
|
+
const where = conditions.join(" AND ");
|
|
1316
|
+
const countRow = await this._db.prepare(
|
|
1317
|
+
`SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}`
|
|
1318
|
+
).bind(...params).first();
|
|
1319
|
+
const total = Number(countRow.count) || 0;
|
|
1320
|
+
const res = await this._db.prepare(
|
|
1321
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
|
|
1322
|
+
FROM scheduled_messages
|
|
1323
|
+
WHERE ${where}
|
|
1324
|
+
ORDER BY next_send_at ASC
|
|
1325
|
+
LIMIT ? OFFSET ?`
|
|
1326
|
+
).bind(...params, limit, offset).all();
|
|
1327
|
+
return { tasks: res.results || [], total };
|
|
1328
|
+
}
|
|
1329
|
+
async cleanupOldTasks(days = 7) {
|
|
1330
|
+
const safeDays = Math.max(1, Math.floor(Number(days)));
|
|
1331
|
+
const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1e3).toISOString();
|
|
1332
|
+
const res = await this._db.prepare(
|
|
1333
|
+
`DELETE FROM scheduled_messages
|
|
1334
|
+
WHERE status IN ('sent', 'failed') AND updated_at < ?`
|
|
1335
|
+
).bind(cutoff).run();
|
|
1336
|
+
return res.meta.changes || 0;
|
|
1337
|
+
}
|
|
1338
|
+
async getTaskStatus(uuid, userId) {
|
|
1339
|
+
const row = await this._db.prepare(
|
|
1340
|
+
"SELECT status FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1"
|
|
1341
|
+
).bind(uuid, userId).first();
|
|
1342
|
+
return row ? row.status : null;
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
function createD1Adapter(db) {
|
|
1346
|
+
if (!db || typeof db.prepare !== "function") {
|
|
1347
|
+
throw new Error("[amsg-server] createD1Adapter requires a D1 database binding (env.DB)");
|
|
1348
|
+
}
|
|
1349
|
+
return new D1Adapter(db);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/server/lib/constant-time.js
|
|
1353
|
+
async function constantTimeEqual(a, b) {
|
|
1354
|
+
const enc = new TextEncoder();
|
|
1355
|
+
const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
1356
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
1357
|
+
"raw",
|
|
1358
|
+
keyBytes,
|
|
1359
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1360
|
+
false,
|
|
1361
|
+
["sign"]
|
|
1362
|
+
);
|
|
1363
|
+
const da = new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(String(a))));
|
|
1364
|
+
const db = new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(String(b))));
|
|
1365
|
+
let diff = 0;
|
|
1366
|
+
for (let i = 0; i < da.length; i++) {
|
|
1367
|
+
diff |= da[i] ^ db[i];
|
|
1368
|
+
}
|
|
1369
|
+
return diff === 0;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// src/server/tenant/single-user-context.js
|
|
1373
|
+
function createSingleUserContextManager({ db, masterKey, serverToken } = {}) {
|
|
1374
|
+
if (!db) throw new Error("[amsg-server single-user] db (adapter) is required");
|
|
1375
|
+
if (!masterKey) throw new Error("[amsg-server single-user] masterKey is required");
|
|
1376
|
+
const token = String(serverToken || "").trim();
|
|
1377
|
+
async function isAuthorized(headers) {
|
|
1378
|
+
if (!token) return true;
|
|
1379
|
+
const provided = getHeader(headers, "x-client-token");
|
|
1380
|
+
if (!provided) return false;
|
|
1381
|
+
return constantTimeEqual(provided, token);
|
|
1382
|
+
}
|
|
1383
|
+
async function resolveTenant(headers) {
|
|
1384
|
+
if (!await isAuthorized(headers)) {
|
|
1385
|
+
return {
|
|
1386
|
+
ok: false,
|
|
1387
|
+
error: {
|
|
1388
|
+
status: 401,
|
|
1389
|
+
body: { success: false, error: { code: "INVALID_CLIENT_TOKEN", message: "\u5171\u4EAB\u5BC6\u94A5\u65E0\u6548\u6216\u7F3A\u5931" } }
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
ok: true,
|
|
1395
|
+
context: { tenantId: "single", tokenType: "tenant", db, masterKey }
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
async function initializeTenant() {
|
|
1399
|
+
const schema = await db.initSchema();
|
|
1400
|
+
return { tenantId: "single", schema };
|
|
1401
|
+
}
|
|
1402
|
+
return { resolveTenant, initializeTenant };
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// src/server/handlers/single-user-init.js
|
|
1406
|
+
function createSingleUserInitHandler(ctx) {
|
|
1407
|
+
async function POST(headers) {
|
|
1408
|
+
const auth = await ctx.tenantManager.resolveTenant(headers || {});
|
|
1409
|
+
if (!auth.ok) {
|
|
1410
|
+
return auth.error;
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
const result = await ctx.tenantManager.initializeTenant();
|
|
1414
|
+
return {
|
|
1415
|
+
status: 200,
|
|
1416
|
+
body: { success: true, data: { tenantId: result.tenantId, schema: result.schema } }
|
|
1417
|
+
};
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
return {
|
|
1420
|
+
status: 500,
|
|
1421
|
+
body: { success: false, error: { code: "INIT_FAILED", message: error.message } }
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return { POST };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// src/server/handlers/vapid-public-key.js
|
|
1429
|
+
function createVapidPublicKeyHandler(ctx) {
|
|
1430
|
+
async function GET(url, headers) {
|
|
1431
|
+
const effectiveHeaders = headers || url || {};
|
|
1432
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
|
|
1433
|
+
if (!tenantResult.ok) {
|
|
1434
|
+
return tenantResult.error;
|
|
1435
|
+
}
|
|
1436
|
+
const publicKey = ctx.vapid && ctx.vapid.publicKey;
|
|
1437
|
+
if (!publicKey) {
|
|
1438
|
+
return {
|
|
1439
|
+
status: 503,
|
|
1440
|
+
body: {
|
|
1441
|
+
success: false,
|
|
1442
|
+
error: {
|
|
1443
|
+
code: "VAPID_NOT_CONFIGURED",
|
|
1444
|
+
message: "VAPID \u516C\u94A5\u672A\u914D\u7F6E\uFF1A\u8BF7\u4E3A\u672C Worker \u8BBE\u7F6E VAPID_PUBLIC_KEY"
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
status: 200,
|
|
1451
|
+
body: { success: true, publicKey }
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
return { GET };
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/server/single-user.js
|
|
1458
|
+
function createSingleUserServer(config) {
|
|
1459
|
+
if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
|
|
1460
|
+
if (!config.masterKey) throw new Error("[amsg-server single-user] config.masterKey is required");
|
|
1461
|
+
const vapid = config.vapid || {};
|
|
1462
|
+
const tenantManager = createSingleUserContextManager({
|
|
1463
|
+
db: config.db,
|
|
1464
|
+
masterKey: config.masterKey,
|
|
1465
|
+
serverToken: config.serverToken
|
|
1466
|
+
});
|
|
1467
|
+
const ctx = {
|
|
1468
|
+
vapid: {
|
|
1469
|
+
email: vapid.email || "",
|
|
1470
|
+
publicKey: vapid.publicKey || "",
|
|
1471
|
+
privateKey: vapid.privateKey || ""
|
|
1472
|
+
},
|
|
1473
|
+
webpush: config.webpush || null,
|
|
1474
|
+
tenantManager
|
|
1475
|
+
};
|
|
1476
|
+
return {
|
|
1477
|
+
ctx,
|
|
1478
|
+
handlers: {
|
|
1479
|
+
init: createSingleUserInitHandler(ctx),
|
|
1480
|
+
getUserKey: createGetUserKeyHandler(ctx),
|
|
1481
|
+
scheduleMessage: createScheduleMessageHandler(ctx),
|
|
1482
|
+
updateMessage: createUpdateMessageHandler(ctx),
|
|
1483
|
+
cancelMessage: createCancelMessageHandler(ctx),
|
|
1484
|
+
messages: createMessagesHandler(ctx),
|
|
1485
|
+
vapidPublicKey: createVapidPublicKeyHandler(ctx)
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// src/server/lib/webcrypto-utils.js
|
|
1491
|
+
import { toUint8, concatBytes, base64UrlToBytes } from "@rei-standard/amsg-shared";
|
|
1492
|
+
var TEXT_ENCODER = new TextEncoder();
|
|
1493
|
+
var TEXT_DECODER = new TextDecoder("utf-8", { fatal: false });
|
|
1494
|
+
function utf8(str) {
|
|
1495
|
+
return TEXT_ENCODER.encode(String(str));
|
|
1496
|
+
}
|
|
1497
|
+
function bytesToBase64Url(buf) {
|
|
1498
|
+
const bytes = toUint8(buf);
|
|
1499
|
+
let bin = "";
|
|
1500
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1501
|
+
bin += String.fromCharCode(bytes[i]);
|
|
1502
|
+
}
|
|
1503
|
+
const b64 = typeof btoa === "function" ? btoa(bin) : Buffer.from(bin, "binary").toString("base64");
|
|
1504
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
1505
|
+
}
|
|
1506
|
+
function jsonToBase64Url(value) {
|
|
1507
|
+
return bytesToBase64Url(utf8(JSON.stringify(value)));
|
|
1508
|
+
}
|
|
1509
|
+
function randomBytes2(n) {
|
|
1510
|
+
const out = new Uint8Array(n);
|
|
1511
|
+
globalThis.crypto.getRandomValues(out);
|
|
1512
|
+
return out;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// src/server/lib/webpush-webcrypto.js
|
|
1516
|
+
import { normalizeVapidSubject } from "@rei-standard/amsg-shared";
|
|
1517
|
+
var KEY_INFO_PREFIX = utf8("WebPush: info\0");
|
|
1518
|
+
var CEK_INFO = utf8("Content-Encoding: aes128gcm\0");
|
|
1519
|
+
var NONCE_INFO = utf8("Content-Encoding: nonce\0");
|
|
1520
|
+
var VAPID_DEFAULT_TTL = 60;
|
|
1521
|
+
var VAPID_TOKEN_LIFETIME = 12 * 3600;
|
|
1522
|
+
var RECORD_SIZE = 4096;
|
|
1523
|
+
async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl }) {
|
|
1524
|
+
if (!subscription || typeof subscription.endpoint !== "string") {
|
|
1525
|
+
throw new Error("sendWebPush: invalid subscription");
|
|
1526
|
+
}
|
|
1527
|
+
if (typeof payload !== "string") {
|
|
1528
|
+
throw new Error("sendWebPush: payload must be a string");
|
|
1529
|
+
}
|
|
1530
|
+
if (!vapid || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
|
|
1531
|
+
throw new Error("VAPID_CONFIG_MISSING");
|
|
1532
|
+
}
|
|
1533
|
+
const subscriptionKeys = subscription.keys || {};
|
|
1534
|
+
if (typeof subscriptionKeys.p256dh !== "string" || typeof subscriptionKeys.auth !== "string") {
|
|
1535
|
+
throw new Error("sendWebPush: subscription.keys.p256dh and .auth are required");
|
|
1536
|
+
}
|
|
1537
|
+
const encryptedBody = await encryptPushPayload({
|
|
1538
|
+
plaintext: utf8(payload),
|
|
1539
|
+
uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh),
|
|
1540
|
+
authSecret: base64UrlToBytes(subscriptionKeys.auth)
|
|
1541
|
+
});
|
|
1542
|
+
const jwt = await buildVapidJwt({
|
|
1543
|
+
audience: originOf(subscription.endpoint),
|
|
1544
|
+
subject: normalizeVapidSubject(vapid.email),
|
|
1545
|
+
publicKey: vapid.publicKey,
|
|
1546
|
+
privateKey: vapid.privateKey
|
|
1547
|
+
});
|
|
1548
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
1549
|
+
if (typeof fetchFn !== "function") {
|
|
1550
|
+
throw new Error("sendWebPush: no fetch implementation available");
|
|
1551
|
+
}
|
|
1552
|
+
const res = await fetchFn(subscription.endpoint, {
|
|
1553
|
+
method: "POST",
|
|
1554
|
+
headers: {
|
|
1555
|
+
"Content-Type": "application/octet-stream",
|
|
1556
|
+
"Content-Encoding": "aes128gcm",
|
|
1557
|
+
"TTL": String(Number.isFinite(ttl) ? ttl : VAPID_DEFAULT_TTL),
|
|
1558
|
+
"Authorization": `vapid t=${jwt}, k=${vapid.publicKey}`
|
|
1559
|
+
},
|
|
1560
|
+
body: encryptedBody
|
|
1561
|
+
});
|
|
1562
|
+
if (!res.ok) {
|
|
1563
|
+
const text = await safeReadText(res);
|
|
1564
|
+
const err = new Error(
|
|
1565
|
+
`Web Push delivery failed: ${res.status} ${res.statusText || ""}${text ? ` \u2014 ${text}` : ""}`
|
|
1566
|
+
);
|
|
1567
|
+
err.code = "PUSH_SEND_FAILED";
|
|
1568
|
+
err.statusCode = res.status;
|
|
1569
|
+
throw err;
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
statusCode: res.status,
|
|
1573
|
+
body: await safeReadText(res),
|
|
1574
|
+
headers: res.headers
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
async function encryptPushPayload({ plaintext, uaPublicKey, authSecret }) {
|
|
1578
|
+
const asKeyPair = await globalThis.crypto.subtle.generateKey(
|
|
1579
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
1580
|
+
true,
|
|
1581
|
+
["deriveBits"]
|
|
1582
|
+
);
|
|
1583
|
+
const asPublicRaw = new Uint8Array(
|
|
1584
|
+
await globalThis.crypto.subtle.exportKey("raw", asKeyPair.publicKey)
|
|
1585
|
+
);
|
|
1586
|
+
const uaPublicCryptoKey = await globalThis.crypto.subtle.importKey(
|
|
1587
|
+
"raw",
|
|
1588
|
+
uaPublicKey,
|
|
1589
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
1590
|
+
false,
|
|
1591
|
+
[]
|
|
1592
|
+
);
|
|
1593
|
+
const ecdhSecret = new Uint8Array(
|
|
1594
|
+
await globalThis.crypto.subtle.deriveBits(
|
|
1595
|
+
{ name: "ECDH", public: uaPublicCryptoKey },
|
|
1596
|
+
asKeyPair.privateKey,
|
|
1597
|
+
256
|
|
1598
|
+
)
|
|
1599
|
+
);
|
|
1600
|
+
const keyInfo = concatBytes(KEY_INFO_PREFIX, uaPublicKey, asPublicRaw);
|
|
1601
|
+
const ikm = await hkdfSha256(authSecret, ecdhSecret, keyInfo, 32);
|
|
1602
|
+
const salt = randomBytes2(16);
|
|
1603
|
+
const cekBytes = await hkdfSha256(salt, ikm, CEK_INFO, 16);
|
|
1604
|
+
const cek = await globalThis.crypto.subtle.importKey(
|
|
1605
|
+
"raw",
|
|
1606
|
+
cekBytes,
|
|
1607
|
+
{ name: "AES-GCM" },
|
|
1608
|
+
false,
|
|
1609
|
+
["encrypt"]
|
|
1610
|
+
);
|
|
1611
|
+
const nonce = await hkdfSha256(salt, ikm, NONCE_INFO, 12);
|
|
1612
|
+
const padded = concatBytes(plaintext, new Uint8Array([2]));
|
|
1613
|
+
const ciphertext = new Uint8Array(
|
|
1614
|
+
await globalThis.crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, cek, padded)
|
|
1615
|
+
);
|
|
1616
|
+
const header = new Uint8Array(16 + 4 + 1 + asPublicRaw.byteLength);
|
|
1617
|
+
header.set(salt, 0);
|
|
1618
|
+
writeUint32BE(header, 16, RECORD_SIZE);
|
|
1619
|
+
header[20] = asPublicRaw.byteLength;
|
|
1620
|
+
header.set(asPublicRaw, 21);
|
|
1621
|
+
return concatBytes(header, ciphertext);
|
|
1622
|
+
}
|
|
1623
|
+
async function hkdfSha256(salt, ikm, info, length) {
|
|
1624
|
+
const baseKey = await globalThis.crypto.subtle.importKey(
|
|
1625
|
+
"raw",
|
|
1626
|
+
toUint8(ikm),
|
|
1627
|
+
{ name: "HKDF" },
|
|
1628
|
+
false,
|
|
1629
|
+
["deriveBits"]
|
|
1630
|
+
);
|
|
1631
|
+
const bits = await globalThis.crypto.subtle.deriveBits(
|
|
1632
|
+
{
|
|
1633
|
+
name: "HKDF",
|
|
1634
|
+
hash: "SHA-256",
|
|
1635
|
+
salt: toUint8(salt),
|
|
1636
|
+
info: toUint8(info)
|
|
1637
|
+
},
|
|
1638
|
+
baseKey,
|
|
1639
|
+
length * 8
|
|
1640
|
+
);
|
|
1641
|
+
return new Uint8Array(bits);
|
|
1642
|
+
}
|
|
1643
|
+
async function buildVapidJwt({ audience, subject, publicKey, privateKey }) {
|
|
1644
|
+
const header = jsonToBase64Url({ typ: "JWT", alg: "ES256" });
|
|
1645
|
+
const payload = jsonToBase64Url({
|
|
1646
|
+
aud: audience,
|
|
1647
|
+
exp: Math.floor(Date.now() / 1e3) + VAPID_TOKEN_LIFETIME,
|
|
1648
|
+
sub: subject
|
|
1649
|
+
});
|
|
1650
|
+
const signingInput = utf8(`${header}.${payload}`);
|
|
1651
|
+
const pubBytes = base64UrlToBytes(publicKey);
|
|
1652
|
+
const privBytes = base64UrlToBytes(privateKey);
|
|
1653
|
+
if (pubBytes.length !== 65 || pubBytes[0] !== 4) {
|
|
1654
|
+
throw new Error("VAPID publicKey must be a 65-byte uncompressed P-256 point (base64url).");
|
|
1655
|
+
}
|
|
1656
|
+
if (privBytes.length !== 32) {
|
|
1657
|
+
throw new Error("VAPID privateKey must be a 32-byte scalar (base64url).");
|
|
1658
|
+
}
|
|
1659
|
+
const jwk = {
|
|
1660
|
+
kty: "EC",
|
|
1661
|
+
crv: "P-256",
|
|
1662
|
+
d: bytesToBase64Url(privBytes),
|
|
1663
|
+
x: bytesToBase64Url(pubBytes.subarray(1, 33)),
|
|
1664
|
+
y: bytesToBase64Url(pubBytes.subarray(33, 65)),
|
|
1665
|
+
ext: true
|
|
1666
|
+
};
|
|
1667
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
1668
|
+
"jwk",
|
|
1669
|
+
jwk,
|
|
1670
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
1671
|
+
false,
|
|
1672
|
+
["sign"]
|
|
1673
|
+
);
|
|
1674
|
+
const sig = await globalThis.crypto.subtle.sign(
|
|
1675
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
1676
|
+
key,
|
|
1677
|
+
signingInput
|
|
1678
|
+
);
|
|
1679
|
+
return `${header}.${payload}.${bytesToBase64Url(sig)}`;
|
|
1680
|
+
}
|
|
1681
|
+
function originOf(endpoint) {
|
|
1682
|
+
return new URL(endpoint).origin;
|
|
1683
|
+
}
|
|
1684
|
+
function writeUint32BE(buf, offset, value) {
|
|
1685
|
+
buf[offset] = value >>> 24 & 255;
|
|
1686
|
+
buf[offset + 1] = value >>> 16 & 255;
|
|
1687
|
+
buf[offset + 2] = value >>> 8 & 255;
|
|
1688
|
+
buf[offset + 3] = value & 255;
|
|
1689
|
+
}
|
|
1690
|
+
async function safeReadText(res) {
|
|
1691
|
+
try {
|
|
1692
|
+
return await res.text();
|
|
1693
|
+
} catch {
|
|
1694
|
+
return "";
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
var SCHEDULED_DEFAULT_TTL = 2419200;
|
|
1698
|
+
function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
|
|
1699
|
+
return {
|
|
1700
|
+
async sendNotification(subscription, payload) {
|
|
1701
|
+
return sendWebPush({
|
|
1702
|
+
subscription,
|
|
1703
|
+
payload,
|
|
1704
|
+
ttl,
|
|
1705
|
+
vapid: {
|
|
1706
|
+
email: vapid.email,
|
|
1707
|
+
publicKey: vapid.publicKey,
|
|
1708
|
+
privateKey: vapid.privateKey
|
|
1709
|
+
},
|
|
1710
|
+
fetch: globalThis.fetch
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/server/cloudflare/single-user-worker.js
|
|
1717
|
+
function headersToObject(h) {
|
|
1718
|
+
const out = {};
|
|
1719
|
+
for (const [k, v] of h) out[k] = v;
|
|
1720
|
+
return out;
|
|
1721
|
+
}
|
|
1722
|
+
function jsonResponse(status, body, extraHeaders) {
|
|
1723
|
+
return new Response(JSON.stringify(body), {
|
|
1724
|
+
status,
|
|
1725
|
+
headers: { "Content-Type": "application/json; charset=utf-8", ...extraHeaders || {} }
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
var CORS_ALLOW_HEADERS = "Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryption-Version, X-Response-Encrypted, X-Client-Token";
|
|
1729
|
+
var CORS_ALLOW_METHODS = "GET, POST, PUT, DELETE, OPTIONS";
|
|
1730
|
+
function corsHeadersFor(cors, requestOrigin) {
|
|
1731
|
+
if (!cors || cors.origin == null) return null;
|
|
1732
|
+
const allowOrigin = typeof cors.origin === "function" ? cors.origin(requestOrigin) || null : cors.origin;
|
|
1733
|
+
if (!allowOrigin) return null;
|
|
1734
|
+
const headers = {
|
|
1735
|
+
"Access-Control-Allow-Origin": allowOrigin,
|
|
1736
|
+
"Access-Control-Allow-Methods": CORS_ALLOW_METHODS,
|
|
1737
|
+
"Access-Control-Allow-Headers": cors.allowHeaders || CORS_ALLOW_HEADERS,
|
|
1738
|
+
"Access-Control-Max-Age": String(cors.maxAge ?? 86400)
|
|
1739
|
+
};
|
|
1740
|
+
if (allowOrigin !== "*") headers["Vary"] = "Origin";
|
|
1741
|
+
return headers;
|
|
1742
|
+
}
|
|
1743
|
+
function createSingleUserCloudflareWorker(buildConfig) {
|
|
1744
|
+
async function resolveConfig(env) {
|
|
1745
|
+
const cfg = await buildConfig(env);
|
|
1746
|
+
if (!cfg.db) cfg.db = createD1Adapter(env.DB);
|
|
1747
|
+
return cfg;
|
|
1748
|
+
}
|
|
1749
|
+
async function fetch2(request, env) {
|
|
1750
|
+
try {
|
|
1751
|
+
const cfg = await resolveConfig(env);
|
|
1752
|
+
const cors = corsHeadersFor(cfg.cors, request.headers.get("origin") || "");
|
|
1753
|
+
const method = request.method.toUpperCase();
|
|
1754
|
+
if (method === "OPTIONS") {
|
|
1755
|
+
return cors ? new Response(null, { status: 204, headers: cors }) : jsonResponse(404, { success: false, error: { code: "NOT_FOUND", message: "Unknown route" } });
|
|
1756
|
+
}
|
|
1757
|
+
const server = createSingleUserServer(cfg);
|
|
1758
|
+
const url = request.url;
|
|
1759
|
+
const pathname = new URL(url).pathname.replace(/\/+$/, "") || "/";
|
|
1760
|
+
const headers = headersToObject(request.headers);
|
|
1761
|
+
let result;
|
|
1762
|
+
if (method === "POST" && pathname.endsWith("/init-tenant")) {
|
|
1763
|
+
result = await server.handlers.init.POST(headers, await request.text());
|
|
1764
|
+
} else if (method === "GET" && pathname.endsWith("/get-user-key")) {
|
|
1765
|
+
result = await server.handlers.getUserKey.GET(url, headers);
|
|
1766
|
+
} else if (method === "POST" && pathname.endsWith("/schedule-message")) {
|
|
1767
|
+
result = await server.handlers.scheduleMessage.POST(headers, await request.text());
|
|
1768
|
+
} else if (method === "GET" && pathname.endsWith("/messages")) {
|
|
1769
|
+
result = await server.handlers.messages.GET(url, headers);
|
|
1770
|
+
} else if (method === "PUT" && pathname.endsWith("/update-message")) {
|
|
1771
|
+
result = await server.handlers.updateMessage.PUT(url, headers, await request.text());
|
|
1772
|
+
} else if (method === "DELETE" && pathname.endsWith("/cancel-message")) {
|
|
1773
|
+
result = await server.handlers.cancelMessage.DELETE(url, headers);
|
|
1774
|
+
} else if (method === "GET" && pathname.endsWith("/vapid-public-key")) {
|
|
1775
|
+
result = await server.handlers.vapidPublicKey.GET(url, headers);
|
|
1776
|
+
} else {
|
|
1777
|
+
result = { status: 404, body: { success: false, error: { code: "NOT_FOUND", message: "Unknown route" } } };
|
|
1778
|
+
}
|
|
1779
|
+
return jsonResponse(result.status, result.body, cors);
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
console.error("[amsg single-user] fetch() unhandled error:", error && error.message);
|
|
1782
|
+
return jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF" } });
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
async function scheduled(event, env) {
|
|
1786
|
+
const cfg = await resolveConfig(env);
|
|
1787
|
+
const vapid = cfg.vapid || {};
|
|
1788
|
+
if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
|
|
1789
|
+
console.error("[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick");
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
try {
|
|
1793
|
+
await runScheduledTick({ db: cfg.db, masterKey: cfg.masterKey, vapid, webpush: cfg.webpush });
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return { fetch: fetch2, scheduled };
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
export {
|
|
1802
|
+
validateAvatarUrl,
|
|
1803
|
+
isValidISO8601,
|
|
1804
|
+
isValidUrl,
|
|
1805
|
+
isValidUUID,
|
|
1806
|
+
isValidUUIDv4,
|
|
1807
|
+
validateSplitPattern,
|
|
1808
|
+
validateLlmMessagesArray,
|
|
1809
|
+
validateScheduleMessagePayload,
|
|
1810
|
+
parseJsonBody,
|
|
1811
|
+
getHeader,
|
|
1812
|
+
deriveUserEncryptionKey,
|
|
1813
|
+
decryptPayload,
|
|
1814
|
+
encryptForStorage,
|
|
1815
|
+
decryptFromStorage,
|
|
1816
|
+
createGetUserKeyHandler,
|
|
1817
|
+
createScheduleMessageHandler,
|
|
1818
|
+
runScheduledTick,
|
|
1819
|
+
createUpdateMessageHandler,
|
|
1820
|
+
createCancelMessageHandler,
|
|
1821
|
+
createMessagesHandler,
|
|
1822
|
+
createD1Adapter,
|
|
1823
|
+
createSingleUserServer,
|
|
1824
|
+
createWebCryptoWebPush,
|
|
1825
|
+
createSingleUserCloudflareWorker
|
|
1826
|
+
};
|