@xisui-pa/chatserver 1.0.0 → 1.1.0
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.md +142 -39
- package/dist/index.js +1670 -569
- package/dist/index.js.map +1 -1
- package/dist/models/chatserver-models.js +116 -59
- package/dist/models/chatserver-models.js.map +1 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +14 -13
- package/schemas/models.schema.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3405,6 +3405,7 @@ const reference_1 = __webpack_require__(1117);
|
|
|
3405
3405
|
const registry_3 = __webpack_require__(418);
|
|
3406
3406
|
const api_1 = __webpack_require__(2981);
|
|
3407
3407
|
const image_generation_1 = __webpack_require__(3376);
|
|
3408
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
3408
3409
|
/**
|
|
3409
3410
|
* Values used when the config file does not specify an option.
|
|
3410
3411
|
*/
|
|
@@ -3451,12 +3452,6 @@ async function writeDefaultConfig(configPath) {
|
|
|
3451
3452
|
await node_fs_1.promises.mkdir(node_path_1.default.dirname(configPath), { recursive: true });
|
|
3452
3453
|
await node_fs_1.promises.writeFile(configPath, `${JSON.stringify(exports.DEFAULT_CONFIG, null, 2)}\n`, 'utf8');
|
|
3453
3454
|
}
|
|
3454
|
-
/**
|
|
3455
|
-
* Returns true for plain JSON objects (not null, arrays, or primitives).
|
|
3456
|
-
*/
|
|
3457
|
-
function isPlainObject(value) {
|
|
3458
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3459
|
-
}
|
|
3460
3455
|
/**
|
|
3461
3456
|
* Warns about keys that are not part of the schema.
|
|
3462
3457
|
*/
|
|
@@ -3470,28 +3465,28 @@ function warnUnknownKeys(record, known, where) {
|
|
|
3470
3465
|
* Normalizes filter fields from either a shorthand string or an object.
|
|
3471
3466
|
*/
|
|
3472
3467
|
function parseSpecFields(value, where) {
|
|
3473
|
-
if (
|
|
3468
|
+
if ((0, type_guards_1.isString)(value)) {
|
|
3474
3469
|
if (value.trim() === '') {
|
|
3475
3470
|
throw new ConfigError(`${where} must not be empty.`);
|
|
3476
3471
|
}
|
|
3477
3472
|
return { type: value.trim(), options: {} };
|
|
3478
3473
|
}
|
|
3479
|
-
if (!
|
|
3474
|
+
if (!(0, type_guards_1.isRecord)(value)) {
|
|
3480
3475
|
throw new ConfigError(`${where} must be a string or an object with a "type" and optional "options".`);
|
|
3481
3476
|
}
|
|
3482
3477
|
warnUnknownKeys(value, ['type', 'model', 'options'], where);
|
|
3483
|
-
if (
|
|
3478
|
+
if (!(0, type_guards_1.isString)(value.type) || value.type.trim() === '') {
|
|
3484
3479
|
throw new ConfigError(`${where}: "type" must be a non-empty string.`);
|
|
3485
3480
|
}
|
|
3486
3481
|
const options = value.options ?? {};
|
|
3487
|
-
if (!
|
|
3482
|
+
if (!(0, type_guards_1.isRecord)(options)) {
|
|
3488
3483
|
throw new ConfigError(`${where}: "options" must be an object.`);
|
|
3489
3484
|
}
|
|
3490
3485
|
return { type: value.type.trim(), options };
|
|
3491
3486
|
}
|
|
3492
3487
|
/** Validates the syntax of a model reference without reading models.json. */
|
|
3493
3488
|
function modelReference(value, where) {
|
|
3494
|
-
if (
|
|
3489
|
+
if (!(0, type_guards_1.isString)(value) || !value.trim()) {
|
|
3495
3490
|
throw new ConfigError(`${where} must be a non-empty <provider>/<model id> reference.`);
|
|
3496
3491
|
}
|
|
3497
3492
|
const reference = value.trim();
|
|
@@ -3514,7 +3509,7 @@ function imageDimensionLimit(value, fallback, where) {
|
|
|
3514
3509
|
}
|
|
3515
3510
|
/** Parses the chat transport and its bound reply model. */
|
|
3516
3511
|
function parseMessagerSpec(value, where) {
|
|
3517
|
-
if (!
|
|
3512
|
+
if (!(0, type_guards_1.isRecord)(value)) {
|
|
3518
3513
|
throw new ConfigError(`${where} must be an object with "name", "replier", and optional "options".`);
|
|
3519
3514
|
}
|
|
3520
3515
|
if (Object.hasOwn(value, 'tools')) {
|
|
@@ -3522,39 +3517,39 @@ function parseMessagerSpec(value, where) {
|
|
|
3522
3517
|
}
|
|
3523
3518
|
warnUnknownKeys(value, ['name', 'replier', 'persona', 'memoryTokenLimit', 'mainAccount', 'localTools', 'apiTools',
|
|
3524
3519
|
'imageGeneration', 'imageWidthLimit', 'imageHeightLimit', 'dailyImageGenLimit', 'options'], where);
|
|
3525
|
-
if (
|
|
3520
|
+
if (!(0, type_guards_1.isString)(value.name) || !value.name.trim()) {
|
|
3526
3521
|
throw new ConfigError(`${where}: "name" must be a non-empty registered messager name.`);
|
|
3527
3522
|
}
|
|
3528
3523
|
const name = value.name.trim();
|
|
3529
3524
|
if (value.persona !== undefined &&
|
|
3530
|
-
(
|
|
3525
|
+
(!(0, type_guards_1.isString)(value.persona) || !value.persona.trim())) {
|
|
3531
3526
|
throw new ConfigError(`${where}.persona must be a non-empty string.`);
|
|
3532
3527
|
}
|
|
3533
3528
|
const persona = value.persona?.trim();
|
|
3534
3529
|
const memoryTokenLimit = value.memoryTokenLimit;
|
|
3535
|
-
if (memoryTokenLimit !== undefined && (
|
|
3530
|
+
if (memoryTokenLimit !== undefined && (!(0, type_guards_1.isNumber)(memoryTokenLimit) || !Number.isInteger(memoryTokenLimit) ||
|
|
3536
3531
|
memoryTokenLimit < limit_1.MIN_MEMORY_TOKEN_LIMIT || memoryTokenLimit > limit_1.MAX_MEMORY_TOKEN_LIMIT)) {
|
|
3537
3532
|
throw new ConfigError(`${where}.memoryTokenLimit must be an integer between ${limit_1.MIN_MEMORY_TOKEN_LIMIT} and ${limit_1.MAX_MEMORY_TOKEN_LIMIT}.`);
|
|
3538
3533
|
}
|
|
3539
3534
|
if (value.mainAccount !== undefined &&
|
|
3540
|
-
(
|
|
3535
|
+
(!(0, type_guards_1.isString)(value.mainAccount) || !value.mainAccount.trim())) {
|
|
3541
3536
|
throw new ConfigError(`${where}.mainAccount must be a non-empty sender ID.`);
|
|
3542
3537
|
}
|
|
3543
3538
|
const mainAccount = value.mainAccount?.trim();
|
|
3544
|
-
if (value.imageGeneration !== undefined &&
|
|
3539
|
+
if (value.imageGeneration !== undefined && !(0, type_guards_1.isBoolean)(value.imageGeneration)) {
|
|
3545
3540
|
throw new ConfigError(`${where}.imageGeneration must be a boolean.`);
|
|
3546
3541
|
}
|
|
3547
3542
|
const imageWidthLimit = imageDimensionLimit(value.imageWidthLimit, limit_1.DEFAULT_IMAGE_WIDTH_LIMIT, `${where}.imageWidthLimit`);
|
|
3548
3543
|
const imageHeightLimit = imageDimensionLimit(value.imageHeightLimit, limit_1.DEFAULT_IMAGE_HEIGHT_LIMIT, `${where}.imageHeightLimit`);
|
|
3549
3544
|
const dailyImageGenLimit = value.dailyImageGenLimit === undefined ? limit_1.DEFAULT_DAILY_IMAGE_GEN_LIMIT : value.dailyImageGenLimit;
|
|
3550
|
-
if (
|
|
3545
|
+
if (!(0, type_guards_1.isNumber)(dailyImageGenLimit) || !Number.isSafeInteger(dailyImageGenLimit) || dailyImageGenLimit < 0) {
|
|
3551
3546
|
throw new ConfigError(`${where}.dailyImageGenLimit must be a nonnegative safe integer.`);
|
|
3552
3547
|
}
|
|
3553
3548
|
if (!(0, registry_2.hasMessager)(name)) {
|
|
3554
3549
|
throw new ConfigError(`${where}: unknown messager "${name}". Available: ${(0, registry_2.listMessagers)().join(', ')}.`);
|
|
3555
3550
|
}
|
|
3556
3551
|
const options = value.options ?? {};
|
|
3557
|
-
if (!
|
|
3552
|
+
if (!(0, type_guards_1.isRecord)(options)) {
|
|
3558
3553
|
throw new ConfigError(`${where}: "options" must be an object.`);
|
|
3559
3554
|
}
|
|
3560
3555
|
let localTools;
|
|
@@ -3587,16 +3582,16 @@ function parseFilterSpec(value, where) {
|
|
|
3587
3582
|
`Available: ${(0, registry_1.listFilters)().join(', ')}.`);
|
|
3588
3583
|
}
|
|
3589
3584
|
if (filter.type === 'model') {
|
|
3590
|
-
return { ...filter, model: modelReference(
|
|
3585
|
+
return { ...filter, model: modelReference((0, type_guards_1.isRecord)(value) ? value.model : undefined, `${where}.model`) };
|
|
3591
3586
|
}
|
|
3592
|
-
if (
|
|
3587
|
+
if ((0, type_guards_1.isRecord)(value) && value.model !== undefined) {
|
|
3593
3588
|
throw new ConfigError(`${where}: "model" is only supported for type "model".`);
|
|
3594
3589
|
}
|
|
3595
3590
|
return filter;
|
|
3596
3591
|
}
|
|
3597
3592
|
/** Validates the STT implementation, required model, and optional conversion command. */
|
|
3598
3593
|
function parseSttSpec(value, where) {
|
|
3599
|
-
if (!
|
|
3594
|
+
if (!(0, type_guards_1.isRecord)(value)) {
|
|
3600
3595
|
throw new ConfigError(`${where} must be an object with type "model"; replace old string STT references with { "type": "model", "model": "provider/model-id" }.`);
|
|
3601
3596
|
}
|
|
3602
3597
|
warnUnknownKeys(value, ['type', 'model', 'audioConversionCmd'], where);
|
|
@@ -3604,7 +3599,7 @@ function parseSttSpec(value, where) {
|
|
|
3604
3599
|
throw new ConfigError(`${where}.type must be "model".`);
|
|
3605
3600
|
}
|
|
3606
3601
|
const model = modelReference(value.model, `${where}.model`);
|
|
3607
|
-
if (value.audioConversionCmd !== undefined && (
|
|
3602
|
+
if (value.audioConversionCmd !== undefined && (!(0, type_guards_1.isString)(value.audioConversionCmd) ||
|
|
3608
3603
|
!value.audioConversionCmd.trim() || !value.audioConversionCmd.includes('{input}') || !value.audioConversionCmd.includes('{output}'))) {
|
|
3609
3604
|
throw new ConfigError(`${where}.audioConversionCmd must be a nonempty command containing {input} and {output}.`);
|
|
3610
3605
|
}
|
|
@@ -3612,13 +3607,13 @@ function parseSttSpec(value, where) {
|
|
|
3612
3607
|
}
|
|
3613
3608
|
/** Keeps application-controlled speech fields out of provider-specific options. */
|
|
3614
3609
|
function parseTtsSpec(value, where) {
|
|
3615
|
-
if (!
|
|
3610
|
+
if (!(0, type_guards_1.isRecord)(value) || value.type !== 'model') {
|
|
3616
3611
|
throw new ConfigError(`${where} must be an object with type "model".`);
|
|
3617
3612
|
}
|
|
3618
3613
|
warnUnknownKeys(value, ['type', 'model', 'options'], where);
|
|
3619
3614
|
const model = modelReference(value.model, `${where}.model`);
|
|
3620
3615
|
const options = value.options === undefined ? {} : value.options;
|
|
3621
|
-
if (!
|
|
3616
|
+
if (!(0, type_guards_1.isRecord)(options)) {
|
|
3622
3617
|
throw new ConfigError(`${where}.options must be an object.`);
|
|
3623
3618
|
}
|
|
3624
3619
|
for (const key of ['model', 'input', 'response_format', 'stream_format']) {
|
|
@@ -3633,7 +3628,7 @@ function parseTtsSpec(value, where) {
|
|
|
3633
3628
|
*/
|
|
3634
3629
|
function parseChat(value, index) {
|
|
3635
3630
|
const where = `chats[${index}]`;
|
|
3636
|
-
if (!
|
|
3631
|
+
if (!(0, type_guards_1.isRecord)(value)) {
|
|
3637
3632
|
throw new ConfigError(`${where} must be an object.`);
|
|
3638
3633
|
}
|
|
3639
3634
|
warnUnknownKeys(value, ['messager', 'filters', 'stt', 'tts'], where);
|
|
@@ -3651,7 +3646,7 @@ function parseChat(value, index) {
|
|
|
3651
3646
|
* Validates a parsed JSON value and merges it over the defaults.
|
|
3652
3647
|
*/
|
|
3653
3648
|
function validateConfig(input, configPath) {
|
|
3654
|
-
if (
|
|
3649
|
+
if (!(0, type_guards_1.isRecord)(input)) {
|
|
3655
3650
|
throw new ConfigError(`${configPath} must contain a JSON object.`);
|
|
3656
3651
|
}
|
|
3657
3652
|
const record = input;
|
|
@@ -3722,6 +3717,7 @@ const node_path_1 = __importDefault(__webpack_require__(6760));
|
|
|
3722
3717
|
const limit_1 = __webpack_require__(9258);
|
|
3723
3718
|
const images_1 = __webpack_require__(454);
|
|
3724
3719
|
const input_budget_1 = __webpack_require__(6280);
|
|
3720
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
3725
3721
|
/** Rejects URLs and malformed data instead of fetching arbitrary locations. */
|
|
3726
3722
|
function validateDataUrl(value) {
|
|
3727
3723
|
const match = /^data:image\/(?:png|jpeg|gif|webp);base64,(.+)$/.exec(value);
|
|
@@ -3851,17 +3847,17 @@ class ConversationImageStore {
|
|
|
3851
3847
|
throw new Error('Image cache file exceeds the size limit.');
|
|
3852
3848
|
}
|
|
3853
3849
|
const value = JSON.parse(await node_fs_1.promises.readFile(filePath, 'utf8'));
|
|
3854
|
-
if (value?.version !== 1 || !
|
|
3850
|
+
if (value?.version !== 1 || !(0, type_guards_1.isRecord)(value.images)) {
|
|
3855
3851
|
throw new Error('Expected a version 1 image cache file.');
|
|
3856
3852
|
}
|
|
3857
3853
|
const images = Object.create(null);
|
|
3858
3854
|
const now = Date.now();
|
|
3859
3855
|
for (const [id, image] of Object.entries(value.images)) {
|
|
3860
3856
|
if (!image || image.id !== id || !id || id.length > limit_1.MAX_MEMORY_ID_CHARS ||
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
!Number.isFinite(Date.parse(image.timestamp)) ||
|
|
3864
|
-
(image.senderName !== undefined &&
|
|
3857
|
+
!(0, type_guards_1.isString)(image.messageId) || !image.messageId || image.messageId.length > limit_1.MAX_MEMORY_ID_CHARS ||
|
|
3858
|
+
!(0, type_guards_1.isString)(image.senderId) || !(0, type_guards_1.isString)(image.timestamp) ||
|
|
3859
|
+
!Number.isFinite(Date.parse(image.timestamp)) || !(0, type_guards_1.isString)(image.dataUrl) ||
|
|
3860
|
+
(image.senderName !== undefined && !(0, type_guards_1.isString)(image.senderName)) ||
|
|
3865
3861
|
(image.origin !== undefined && image.origin !== 'generated') ||
|
|
3866
3862
|
(image.origin === 'generated'
|
|
3867
3863
|
? !['pending', 'sent', 'failed'].includes(image.deliveryStatus ?? '')
|
|
@@ -3944,22 +3940,23 @@ exports.ConversationImageStore = ConversationImageStore;
|
|
|
3944
3940
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
3945
3941
|
exports.parseMemoryUpdates = parseMemoryUpdates;
|
|
3946
3942
|
const limit_1 = __webpack_require__(9258);
|
|
3943
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
3947
3944
|
/** Validates model output before any memory changes can reach the filesystem. */
|
|
3948
3945
|
function parseMemoryUpdates(value) {
|
|
3949
|
-
if (
|
|
3946
|
+
if (!(0, type_guards_1.isRecord)(value) ||
|
|
3950
3947
|
!('notes' in value) || !Array.isArray(value.notes) || value.notes.length > limit_1.MAX_MEMORY_NOTES_PER_UPDATE ||
|
|
3951
3948
|
!('forget' in value) || !Array.isArray(value.forget) || value.forget.length > limit_1.MAX_MEMORY_FORGET_IDS ||
|
|
3952
|
-
!value.forget.every((id) =>
|
|
3949
|
+
!value.forget.every((id) => (0, type_guards_1.isString)(id) && id.length <= limit_1.MAX_MEMORY_ID_CHARS)) {
|
|
3953
3950
|
throw new Error('Reply model memoryUpdates must contain notes and forget arrays.');
|
|
3954
3951
|
}
|
|
3955
3952
|
const notes = value.notes.map((note) => {
|
|
3956
|
-
if (
|
|
3957
|
-
!('kind' in note) ||
|
|
3953
|
+
if (!(0, type_guards_1.isRecord)(note) ||
|
|
3954
|
+
!('kind' in note) || !(0, type_guards_1.isString)(note.kind) ||
|
|
3958
3955
|
!['summary', 'fact', 'question', 'inference'].includes(note.kind) ||
|
|
3959
|
-
!('text' in note) ||
|
|
3956
|
+
!('text' in note) || !(0, type_guards_1.isString)(note.text) || !note.text.trim() || note.text.length > limit_1.MAX_MEMORY_NOTE_CHARS ||
|
|
3960
3957
|
!('sourceIds' in note) || !Array.isArray(note.sourceIds) ||
|
|
3961
3958
|
note.sourceIds.length === 0 || note.sourceIds.length > limit_1.MAX_MEMORY_SOURCE_IDS ||
|
|
3962
|
-
!note.sourceIds.every((id) =>
|
|
3959
|
+
!note.sourceIds.every((id) => (0, type_guards_1.isString)(id) && id.length > 0 && id.length <= limit_1.MAX_MEMORY_ID_CHARS)) {
|
|
3963
3960
|
throw new Error('Each memory note needs a kind, nonempty text, and valid sourceIds.');
|
|
3964
3961
|
}
|
|
3965
3962
|
return {
|
|
@@ -3989,12 +3986,9 @@ const node_crypto_1 = __webpack_require__(7598);
|
|
|
3989
3986
|
const node_fs_1 = __webpack_require__(3024);
|
|
3990
3987
|
const node_path_1 = __importDefault(__webpack_require__(6760));
|
|
3991
3988
|
const tokens_1 = __webpack_require__(4956);
|
|
3992
|
-
|
|
3993
|
-
function isRecord(value) {
|
|
3994
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3995
|
-
}
|
|
3989
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
3996
3990
|
function isTimestamp(value) {
|
|
3997
|
-
return
|
|
3991
|
+
return (0, type_guards_1.isString)(value) && Number.isFinite(Date.parse(value));
|
|
3998
3992
|
}
|
|
3999
3993
|
/** Keeps cache links bounded and supports memory files written before image generation. */
|
|
4000
3994
|
function imageIds(value) {
|
|
@@ -4002,20 +3996,20 @@ function imageIds(value) {
|
|
|
4002
3996
|
return undefined;
|
|
4003
3997
|
}
|
|
4004
3998
|
if (!Array.isArray(value) || value.length > limit_1.MAX_CACHED_IMAGES_PER_CONVERSATION ||
|
|
4005
|
-
!value.every((id) =>
|
|
3999
|
+
!value.every((id) => (0, type_guards_1.isString)(id) && id.length > 0 && id.length <= limit_1.MAX_MEMORY_ID_CHARS)) {
|
|
4006
4000
|
throw new Error('Invalid stored reply image IDs.');
|
|
4007
4001
|
}
|
|
4008
4002
|
return [...new Set(value)];
|
|
4009
4003
|
}
|
|
4010
4004
|
/** Parses only supported fields; expiry is always derived from source time. */
|
|
4011
4005
|
function parseFile(value) {
|
|
4012
|
-
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.notes) || !Array.isArray(value.replies)) {
|
|
4006
|
+
if (!(0, type_guards_1.isRecord)(value) || value.version !== 1 || !Array.isArray(value.notes) || !Array.isArray(value.replies)) {
|
|
4013
4007
|
throw new Error('Expected a version 1 conversation memory file.');
|
|
4014
4008
|
}
|
|
4015
4009
|
const notes = value.notes.map((note) => {
|
|
4016
|
-
if (!isRecord(note) ||
|
|
4017
|
-
|
|
4018
|
-
|
|
4010
|
+
if (!(0, type_guards_1.isRecord)(note) || !(0, type_guards_1.isString)(note.id) || note.id.length > limit_1.MAX_MEMORY_ID_CHARS ||
|
|
4011
|
+
!(0, type_guards_1.isString)(note.kind) || !['summary', 'fact', 'question', 'inference'].includes(note.kind) ||
|
|
4012
|
+
!(0, type_guards_1.isString)(note.text) || !isTimestamp(note.sourceTimestamp)) {
|
|
4019
4013
|
throw new Error('Invalid stored memory note.');
|
|
4020
4014
|
}
|
|
4021
4015
|
return {
|
|
@@ -4027,10 +4021,10 @@ function parseFile(value) {
|
|
|
4027
4021
|
};
|
|
4028
4022
|
});
|
|
4029
4023
|
const replies = value.replies.map((reply) => {
|
|
4030
|
-
if (!isRecord(reply) ||
|
|
4031
|
-
|
|
4032
|
-
(reply.channel !== undefined &&
|
|
4033
|
-
(reply.direct !== undefined &&
|
|
4024
|
+
if (!(0, type_guards_1.isRecord)(reply) || !(0, type_guards_1.isString)(reply.id) || reply.id.length > limit_1.MAX_MEMORY_ID_CHARS ||
|
|
4025
|
+
!(0, type_guards_1.isString)(reply.text) || !isTimestamp(reply.sentAt) ||
|
|
4026
|
+
(reply.channel !== undefined && !(0, type_guards_1.isString)(reply.channel)) ||
|
|
4027
|
+
(reply.direct !== undefined && !(0, type_guards_1.isBoolean)(reply.direct))) {
|
|
4034
4028
|
throw new Error('Invalid stored sent reply.');
|
|
4035
4029
|
}
|
|
4036
4030
|
return {
|
|
@@ -4364,11 +4358,12 @@ exports.IgnoreFile = IgnoreFile;
|
|
|
4364
4358
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
4365
4359
|
exports.IgnoreSenderFilter = void 0;
|
|
4366
4360
|
const interface_1 = __webpack_require__(2590);
|
|
4361
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
4367
4362
|
/**
|
|
4368
4363
|
* Distinguishes a dynamic source from a fixed list of sender IDs.
|
|
4369
4364
|
*/
|
|
4370
4365
|
function isSenderIdSource(value) {
|
|
4371
|
-
return
|
|
4366
|
+
return (0, type_guards_1.isFunction)(value.getSenderIds);
|
|
4372
4367
|
}
|
|
4373
4368
|
/**
|
|
4374
4369
|
* Removes listed senders from snapshots and denies only when no incoming sources remain.
|
|
@@ -4535,6 +4530,7 @@ const node_path_1 = __importDefault(__webpack_require__(6760));
|
|
|
4535
4530
|
const ignore_file_1 = __webpack_require__(5230);
|
|
4536
4531
|
const ignore_sender_1 = __webpack_require__(2977);
|
|
4537
4532
|
const model_1 = __webpack_require__(6488);
|
|
4533
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
4538
4534
|
const factories = new Map();
|
|
4539
4535
|
/**
|
|
4540
4536
|
* Registers a filter implementation under a config-file name.
|
|
@@ -4572,7 +4568,7 @@ function stringArrayOption(options, key, label) {
|
|
|
4572
4568
|
if (value === undefined) {
|
|
4573
4569
|
return undefined;
|
|
4574
4570
|
}
|
|
4575
|
-
if (!Array.isArray(value) || !value.every((item) =>
|
|
4571
|
+
if (!Array.isArray(value) || !value.every((item) => (0, type_guards_1.isString)(item))) {
|
|
4576
4572
|
throw new Error(`${label}: option "${key}" must be an array of strings.`);
|
|
4577
4573
|
}
|
|
4578
4574
|
return value;
|
|
@@ -4585,7 +4581,7 @@ function stringOption(options, key, label) {
|
|
|
4585
4581
|
if (value === undefined) {
|
|
4586
4582
|
return undefined;
|
|
4587
4583
|
}
|
|
4588
|
-
if (
|
|
4584
|
+
if (!(0, type_guards_1.isString)(value)) {
|
|
4589
4585
|
throw new Error(`${label}: option "${key}" must be a string.`);
|
|
4590
4586
|
}
|
|
4591
4587
|
return value;
|
|
@@ -4796,8 +4792,8 @@ var __webpack_unused_export__;
|
|
|
4796
4792
|
|
|
4797
4793
|
// Keep matching config schema defaults and bounds in sync when changing them here.
|
|
4798
4794
|
__webpack_unused_export__ = ({ value: true });
|
|
4799
|
-
exports.
|
|
4800
|
-
exports.MAX_TIMEZONE_CHARS = exports.MAX_CALCULATE_ARGUMENTS = exports.MAX_CALCULATE_TOKENS = exports.MAX_CALCULATE_EXPRESSION_CHARS = exports.MAX_SEARCH_TITLE_CHARS = exports.MAX_SEARCH_SNIPPET_CHARS = exports.MAX_SEARCH_QUERY_CHARS = exports.MAX_SEARCH_RESULTS = exports.MAX_WEB_TITLE_CHARS = exports.MAX_HTML_NODES = exports.WEB_TRUNCATION_THRESHOLD_CHARS = exports.MAX_WEB_TEXT_CHARS = exports.MAX_READ_URL_CHARS = exports.MAX_WEB_REDIRECTS = exports.WEB_REQUEST_TIMEOUT_MS = exports.MAX_WEB_DECODED_BYTES = exports.MAX_WEB_DOWNLOAD_BYTES = exports.MAX_TOOL_OUTPUT_CHARS = exports.MAX_TOOL_ARGUMENT_CHARS = exports.IGNORE_FILE_RELOAD_INTERVAL_MS = exports.
|
|
4795
|
+
exports.MAX_TIMER_MS = exports.WECHAT_SEND_CANCEL_TIMEOUT_MS = exports.WECHAT_SEND_TIMEOUT_MS = exports.MAX_TTS_INPUT_CHARS = exports.MAX_VOICE_PADDING_MS = exports.MIN_VOICE_PADDING_MS = exports.AUDIO_CLEANUP_RETRY_DELAY_MS = exports.AUDIO_CLEANUP_RETRIES = exports.AUDIO_CONVERSION_STDERR_CHARS = exports.AUDIO_CONVERSION_TIMEOUT_MS = exports.MAX_TRANSCRIPT_CHARS = __webpack_unused_export__ = exports.MAX_MEDIA_ATTEMPTS = exports.CACHED_IMAGE_REFERENCE_TOKENS = exports.MAX_IMAGE_CACHE_FILE_BYTES = exports.MAX_CACHED_IMAGES_PER_CONVERSATION = exports.MAX_IMAGES_PER_SNAPSHOT = exports.MAX_ENCODED_AUDIO_BYTES = exports.MAX_AUDIO_BYTES = exports.MAX_ENCODED_IMAGE_BYTES = exports.MAX_IMAGE_BYTES = exports.DEFAULT_DAILY_IMAGE_GEN_LIMIT = exports.DEFAULT_IMAGE_HEIGHT_LIMIT = exports.DEFAULT_IMAGE_WIDTH_LIMIT = exports.MAX_MEMORY_ID_CHARS = exports.MAX_MEMORY_SOURCE_IDS = exports.MAX_MEMORY_FORGET_IDS = exports.MAX_MEMORY_NOTES_PER_UPDATE = exports.MAX_MEMORY_NOTE_CHARS = exports.MEMORY_NOTE_TOKENS = exports.MAX_MEMORY_FILE_BYTES = exports.MEMORY_CLEANUP_INTERVAL_MS = exports.MEMORY_RETENTION_MS = exports.MAX_MEMORY_TOKEN_LIMIT = exports.MIN_MEMORY_TOKEN_LIMIT = exports.DEFAULT_MEMORY_TOKEN_LIMIT = exports.HARD_REPLY_TOKEN_LIMIT = exports.SOFT_REPLY_TOKEN_LIMIT = exports.TRUNCATION_MARKER_TOKENS = exports.IMAGE_TOKEN_RESERVE = exports.TOOL_RESULT_TOKENS = exports.MODEL_INPUT_TOKENS = exports.MESSAGER_CONTEXT_TOKENS = exports.SENDER_NAME_TOKENS = exports.CONVERSATION_ID_TOKENS = exports.MIN_MESSAGE_TEXT_TOKENS = exports.MESSAGE_TEXT_TOKENS = exports.MAX_MESSAGES_PER_SNAPSHOT = exports.CONVERSATION_METADATA_TOKENS = exports.CONVERSATION_TEXT_TOKENS = void 0;
|
|
4796
|
+
exports.MAX_TIMEZONE_CHARS = exports.MAX_CALCULATE_ARGUMENTS = exports.MAX_CALCULATE_TOKENS = exports.MAX_CALCULATE_EXPRESSION_CHARS = exports.MAX_SEARCH_TITLE_CHARS = exports.MAX_SEARCH_SNIPPET_CHARS = exports.MAX_SEARCH_QUERY_CHARS = exports.MAX_SEARCH_RESULTS = exports.MAX_WEB_TITLE_CHARS = exports.MAX_HTML_NODES = exports.WEB_TRUNCATION_THRESHOLD_CHARS = exports.MAX_WEB_TEXT_CHARS = exports.MAX_READ_URL_CHARS = exports.MAX_WEB_REDIRECTS = exports.WEB_REQUEST_TIMEOUT_MS = exports.MAX_WEB_DECODED_BYTES = exports.MAX_WEB_DOWNLOAD_BYTES = exports.MAX_TOOL_OUTPUT_CHARS = exports.MAX_TOOL_ARGUMENT_CHARS = exports.IGNORE_FILE_RELOAD_INTERVAL_MS = exports.WECHAT_RECENT_MESSAGE_LIMIT = exports.MAX_SSE_ERROR_BYTES = exports.MAX_SSE_FRAME_CHARS = exports.MAX_SSE_LINE_CHARS = exports.WECHAT_MAX_EVENT_CURSOR = exports.WECHAT_NAME_MISS_TTL_MS = exports.WECHAT_NAME_TTL_MS = exports.WECHAT_NAME_CACHE_SIZE = exports.WECHAT_MAX_BACKOFF_EXPONENT = exports.WECHAT_READER_CANCEL_TIMEOUT_MS = exports.WECHAT_REQUEST_TIMEOUT_MS = exports.WECHAT_RECONNECT_INTERVAL_MS = exports.PENDING_MEDIA_RETRY_DELAY_MS = exports.MAX_PENDING_MEDIA_RETRIES = exports.MAX_REPLY_DELAY_MS = exports.MIN_REPLY_DELAY_MS = __webpack_unused_export__ = __webpack_unused_export__ = exports.MAX_MODEL_TOOL_CALLS = exports.MODEL_RETRY_DELAY_MS = exports.MAX_MODEL_RETRIES = exports.DEFAULT_MODEL_TIMEOUT_MS = void 0;
|
|
4801
4797
|
// Model input and conversation snapshots.
|
|
4802
4798
|
/**
|
|
4803
4799
|
* Budget calculations with the current values:
|
|
@@ -4863,8 +4859,8 @@ exports.MAX_MEMORY_NOTES_PER_UPDATE = 20;
|
|
|
4863
4859
|
exports.MAX_MEMORY_FORGET_IDS = 50;
|
|
4864
4860
|
/** Maximum supporting source IDs per proposed memory note. */
|
|
4865
4861
|
exports.MAX_MEMORY_SOURCE_IDS = 64;
|
|
4866
|
-
/** Maximum characters in
|
|
4867
|
-
exports.MAX_MEMORY_ID_CHARS =
|
|
4862
|
+
/** Maximum characters in memory, source, and cached image IDs; accommodates encoded WeChat IDs. */
|
|
4863
|
+
exports.MAX_MEMORY_ID_CHARS = 1_024;
|
|
4868
4864
|
// Images, voice input, and local audio conversion.
|
|
4869
4865
|
/** Default maximum generated image width in pixels. */
|
|
4870
4866
|
exports.DEFAULT_IMAGE_WIDTH_LIMIT = 1024;
|
|
@@ -4891,7 +4887,7 @@ exports.CACHED_IMAGE_REFERENCE_TOKENS = 1_000;
|
|
|
4891
4887
|
/** Maximum download attempts per image or voice source. */
|
|
4892
4888
|
exports.MAX_MEDIA_ATTEMPTS = 3;
|
|
4893
4889
|
/** Extra JSON response bytes allowed around base64 media. */
|
|
4894
|
-
|
|
4890
|
+
__webpack_unused_export__ = 64 * 1_024;
|
|
4895
4891
|
/** Maximum characters accepted from a speech-to-text model. */
|
|
4896
4892
|
exports.MAX_TRANSCRIPT_CHARS = 32_000;
|
|
4897
4893
|
/** Default deadline for a local audio conversion command. */
|
|
@@ -4908,17 +4904,15 @@ exports.MIN_VOICE_PADDING_MS = 500;
|
|
|
4908
4904
|
exports.MAX_VOICE_PADDING_MS = 2_000;
|
|
4909
4905
|
/** Maximum characters submitted to a speech synthesis endpoint. */
|
|
4910
4906
|
exports.MAX_TTS_INPUT_CHARS = 4_096;
|
|
4911
|
-
/** Maximum wait for
|
|
4912
|
-
exports.
|
|
4913
|
-
/**
|
|
4914
|
-
exports.
|
|
4915
|
-
/** Deadline for a best-effort voice-job cancellation after failure or shutdown. */
|
|
4916
|
-
exports.WECHAT_VOICE_CANCEL_TIMEOUT_MS = 5_000;
|
|
4907
|
+
/** Maximum wait for one WeChat text send or media job to confirm delivery. */
|
|
4908
|
+
exports.WECHAT_SEND_TIMEOUT_MS = 180_000;
|
|
4909
|
+
/** Deadline for a best-effort send or job cancellation after failure or shutdown. */
|
|
4910
|
+
exports.WECHAT_SEND_CANCEL_TIMEOUT_MS = 5_000;
|
|
4917
4911
|
// Model requests, pacing, and transport defaults.
|
|
4918
|
-
/**
|
|
4919
|
-
exports.MAX_TIMER_MS =
|
|
4912
|
+
/** Maximum configured timeout or timer delay: one hour. */
|
|
4913
|
+
exports.MAX_TIMER_MS = 60 * 60 * 1_000;
|
|
4920
4914
|
/** Default provider deadline for generation and transcription requests. */
|
|
4921
|
-
exports.DEFAULT_MODEL_TIMEOUT_MS =
|
|
4915
|
+
exports.DEFAULT_MODEL_TIMEOUT_MS = 300_000;
|
|
4922
4916
|
/** Retries after the initial attempt of a complete model generation. */
|
|
4923
4917
|
exports.MAX_MODEL_RETRIES = 3;
|
|
4924
4918
|
/** Delay between complete model-generation retries. */
|
|
@@ -4937,18 +4931,30 @@ exports.MAX_REPLY_DELAY_MS = 180_000;
|
|
|
4937
4931
|
exports.MAX_PENDING_MEDIA_RETRIES = 2;
|
|
4938
4932
|
/** Initial delay for pending-media retries; doubles on each retry. */
|
|
4939
4933
|
exports.PENDING_MEDIA_RETRY_DELAY_MS = 30_000;
|
|
4940
|
-
/** Default delay
|
|
4941
|
-
exports.
|
|
4942
|
-
/** Default deadline for each WeChat REST request. */
|
|
4934
|
+
/** Default base delay before reconnecting an interrupted WeChat event stream. */
|
|
4935
|
+
exports.WECHAT_RECONNECT_INTERVAL_MS = 1_000;
|
|
4936
|
+
/** Default deadline for each WeChat REST request and SSE connect/idle wait. */
|
|
4943
4937
|
exports.WECHAT_REQUEST_TIMEOUT_MS = 30_000;
|
|
4944
|
-
/**
|
|
4945
|
-
exports.
|
|
4946
|
-
/** Maximum exponent used by WeChat
|
|
4938
|
+
/** Maximum wait for a WeChat stream reader to acknowledge cancellation. */
|
|
4939
|
+
exports.WECHAT_READER_CANCEL_TIMEOUT_MS = 100;
|
|
4940
|
+
/** Maximum exponent used by WeChat event-stream reconnect backoff. */
|
|
4947
4941
|
exports.WECHAT_MAX_BACKOFF_EXPONENT = 6;
|
|
4948
|
-
/**
|
|
4949
|
-
exports.
|
|
4950
|
-
/**
|
|
4951
|
-
exports.
|
|
4942
|
+
/** Maximum cached WeChat contact and group-member display names. */
|
|
4943
|
+
exports.WECHAT_NAME_CACHE_SIZE = 1_000;
|
|
4944
|
+
/** Lifetime of a successfully resolved WeChat display name. */
|
|
4945
|
+
exports.WECHAT_NAME_TTL_MS = 10 * 60_000;
|
|
4946
|
+
/** Lifetime of a failed or empty WeChat display-name lookup. */
|
|
4947
|
+
exports.WECHAT_NAME_MISS_TTL_MS = 60_000;
|
|
4948
|
+
/** Largest signed 64-bit event cursor accepted from the WeChat daemon. */
|
|
4949
|
+
exports.WECHAT_MAX_EVENT_CURSOR = 9223372036854775807n;
|
|
4950
|
+
/** Maximum characters buffered for one unterminated SSE line. */
|
|
4951
|
+
exports.MAX_SSE_LINE_CHARS = 1_048_576;
|
|
4952
|
+
/** Maximum characters accepted in one SSE data payload. */
|
|
4953
|
+
exports.MAX_SSE_FRAME_CHARS = 1_048_576;
|
|
4954
|
+
/** Maximum bytes read from a rejected SSE response body. */
|
|
4955
|
+
exports.MAX_SSE_ERROR_BYTES = 16_384;
|
|
4956
|
+
/** Recent live message IDs retained for replay deduplication; cursors are the primary guard. */
|
|
4957
|
+
exports.WECHAT_RECENT_MESSAGE_LIMIT = 2_000;
|
|
4952
4958
|
/** Default interval between ignore-file reload checks; zero disables caching. */
|
|
4953
4959
|
exports.IGNORE_FILE_RELOAD_INTERVAL_MS = 1_000;
|
|
4954
4960
|
// Tool execution and web content.
|
|
@@ -5001,6 +5007,7 @@ exports.MAX_TIMEZONE_CHARS = 100;
|
|
|
5001
5007
|
var __webpack_unused_export__;
|
|
5002
5008
|
|
|
5003
5009
|
__webpack_unused_export__ = ({ value: true });
|
|
5010
|
+
exports.audioExtension = audioExtension;
|
|
5004
5011
|
exports.audioFile = audioFile;
|
|
5005
5012
|
const limit_1 = __webpack_require__(9258);
|
|
5006
5013
|
const media_1 = __webpack_require__(2358);
|
|
@@ -5009,12 +5016,21 @@ const formats = {
|
|
|
5009
5016
|
wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
5010
5017
|
m4a: 'audio/mp4', mp4: 'audio/mp4', webm: 'audio/webm'
|
|
5011
5018
|
};
|
|
5019
|
+
/** Maps an upstream audio MIME type to the extension used for STT uploads. */
|
|
5020
|
+
function audioExtension(mimeType) {
|
|
5021
|
+
const type = mimeType.split(';')[0].trim().toLowerCase();
|
|
5022
|
+
const extension = Object.keys(formats).find((key) => formats[key] === type);
|
|
5023
|
+
if (extension === undefined) {
|
|
5024
|
+
throw new media_1.MediaUnavailableError(`Unsupported voice MIME type "${mimeType}"; agent-wechat must provide decoded WAV or another supported audio format.`);
|
|
5025
|
+
}
|
|
5026
|
+
return extension;
|
|
5027
|
+
}
|
|
5012
5028
|
/** Decodes supported upstream audio without writing files or attempting local conversion. */
|
|
5013
5029
|
function audioFile(data, format, id) {
|
|
5014
5030
|
const extension = format.toLowerCase();
|
|
5015
5031
|
const mimeType = formats[extension];
|
|
5016
5032
|
if (!Object.hasOwn(formats, extension)) {
|
|
5017
|
-
throw new media_1.MediaUnavailableError(`Unsupported voice format "${format}"; agent-wechat must
|
|
5033
|
+
throw new media_1.MediaUnavailableError(`Unsupported voice format "${format}"; agent-wechat must provide decoded WAV or another supported audio format.`);
|
|
5018
5034
|
}
|
|
5019
5035
|
if (!data || data.length > limit_1.MAX_ENCODED_AUDIO_BYTES || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
5020
5036
|
throw new media_1.MediaUnavailableError(`Voice data must be valid base64 no larger than ${limit_1.MAX_AUDIO_BYTES / (1024 * 1024)} MiB.`);
|
|
@@ -5156,6 +5172,7 @@ exports.createMessager = createMessager;
|
|
|
5156
5172
|
const node_fs_1 = __webpack_require__(3024);
|
|
5157
5173
|
const node_path_1 = __importDefault(__webpack_require__(6760));
|
|
5158
5174
|
const wechat_1 = __webpack_require__(6146);
|
|
5175
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
5159
5176
|
const factories = new Map();
|
|
5160
5177
|
/**
|
|
5161
5178
|
* Registers a messager implementation under a config-file name.
|
|
@@ -5187,25 +5204,25 @@ function createMessager(name, options, context) {
|
|
|
5187
5204
|
}
|
|
5188
5205
|
registerMessager('wechat', (options, context) => {
|
|
5189
5206
|
const label = `messager "wechat" (chat "${context.chatName}")`;
|
|
5190
|
-
const known = ['baseUrl', 'tokenEnv', 'tokenFile', 'chatIds', '
|
|
5207
|
+
const known = ['baseUrl', 'accountId', 'tokenEnv', 'tokenFile', 'chatIds', 'reconnectIntervalMs', 'timeoutMs'];
|
|
5191
5208
|
for (const key of Object.keys(options)) {
|
|
5192
5209
|
if (!known.includes(key)) {
|
|
5193
5210
|
throw new Error(`${label}: unknown option "${key}".`);
|
|
5194
5211
|
}
|
|
5195
5212
|
}
|
|
5196
|
-
for (const key of ['baseUrl', 'tokenEnv', 'tokenFile']) {
|
|
5213
|
+
for (const key of ['baseUrl', 'accountId', 'tokenEnv', 'tokenFile']) {
|
|
5197
5214
|
if (options[key] !== undefined &&
|
|
5198
|
-
(
|
|
5215
|
+
(!(0, type_guards_1.isString)(options[key]) || options[key].trim() === '')) {
|
|
5199
5216
|
throw new Error(`${label}: option "${key}" must be a non-empty string.`);
|
|
5200
5217
|
}
|
|
5201
5218
|
}
|
|
5202
|
-
for (const key of ['
|
|
5203
|
-
if (options[key] !== undefined &&
|
|
5219
|
+
for (const key of ['reconnectIntervalMs', 'timeoutMs']) {
|
|
5220
|
+
if (options[key] !== undefined && !(0, type_guards_1.isNumber)(options[key])) {
|
|
5204
5221
|
throw new Error(`${label}: option "${key}" must be a number.`);
|
|
5205
5222
|
}
|
|
5206
5223
|
}
|
|
5207
5224
|
if (options.chatIds !== undefined && (!Array.isArray(options.chatIds) ||
|
|
5208
|
-
!options.chatIds.every((id) =>
|
|
5225
|
+
!options.chatIds.every((id) => (0, type_guards_1.isString)(id) && id.trim() !== ''))) {
|
|
5209
5226
|
throw new Error(`${label}: option "chatIds" must be an array of non-empty strings.`);
|
|
5210
5227
|
}
|
|
5211
5228
|
const tokenEnv = options.tokenEnv ?? 'AGENT_WECHAT_TOKEN';
|
|
@@ -5218,13 +5235,13 @@ registerMessager('wechat', (options, context) => {
|
|
|
5218
5235
|
}
|
|
5219
5236
|
return new wechat_1.WechatMessagerAdapter(token, {
|
|
5220
5237
|
baseUrl: options.baseUrl,
|
|
5238
|
+
accountId: options.accountId,
|
|
5221
5239
|
chatIds: options.chatIds === undefined ? undefined : [...new Set([
|
|
5222
5240
|
...options.chatIds,
|
|
5223
5241
|
...(context.mainAccount === undefined ? [] : [context.mainAccount])
|
|
5224
5242
|
])],
|
|
5225
|
-
|
|
5226
|
-
timeoutMs: options.timeoutMs
|
|
5227
|
-
bundleGapMs: options.bundleGapMs
|
|
5243
|
+
reconnectIntervalMs: options.reconnectIntervalMs,
|
|
5244
|
+
timeoutMs: options.timeoutMs
|
|
5228
5245
|
});
|
|
5229
5246
|
});
|
|
5230
5247
|
|
|
@@ -5244,25 +5261,35 @@ const promises_1 = __webpack_require__(8500);
|
|
|
5244
5261
|
const images_1 = __webpack_require__(454);
|
|
5245
5262
|
const audio_1 = __webpack_require__(796);
|
|
5246
5263
|
const media_1 = __webpack_require__(2358);
|
|
5247
|
-
|
|
5264
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
5265
|
+
const DIRECTIONS = new Set(['incoming', 'outgoing']);
|
|
5266
|
+
const SEND_STATES = new Set(['queued', 'submitting', 'accepted', 'failed', 'needs_review', 'cancelled']);
|
|
5267
|
+
const JOB_STATES = new Set([
|
|
5268
|
+
'queued', 'running', 'accepted', 'failed', 'needs_review', 'cancelled', 'expired', 'unsupported'
|
|
5269
|
+
]);
|
|
5270
|
+
const TERMINAL_STATES = new Set(['failed', 'needs_review', 'cancelled', 'expired', 'unsupported']);
|
|
5271
|
+
const IMAGE_VARIANTS = ['original', 'standard', 'thumbnail'];
|
|
5272
|
+
const VOICE_VARIANTS = ['normal', 'encoded'];
|
|
5273
|
+
const EVENT_CURSOR_PATTERN = /^(0|[1-9][0-9]{0,18})$/;
|
|
5274
|
+
/** Trims one optional name field; blank values never win. */
|
|
5275
|
+
function trimmedName(value) {
|
|
5276
|
+
return (0, type_guards_1.isString)(value) ? value.trim() || undefined : undefined;
|
|
5277
|
+
}
|
|
5278
|
+
/** Never pass raw image/voice message content to models as the message body. */
|
|
5248
5279
|
function messageText(message) {
|
|
5249
|
-
return message.type === 3 ? '[Image]' : message.type === 34 ? '[Voice]' : message.
|
|
5250
|
-
}
|
|
5251
|
-
/** The API resolves contact remarks/nicknames separately from the stable sender ID. */
|
|
5252
|
-
function displayName(message) {
|
|
5253
|
-
return typeof message.senderName === 'string' ? message.senderName.trim() || undefined : undefined;
|
|
5280
|
+
return message.type === 3 ? '[Image]' : message.type === 34 ? '[Voice]' : message.body ?? '';
|
|
5254
5281
|
}
|
|
5255
|
-
/**
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
super(`WeChat ${endpoint.split('?')[0]} returned HTTP ${status}.`);
|
|
5260
|
-
this.status = status;
|
|
5282
|
+
/** Resolves a message time; a null or unparsable value cannot be ordered against the cutoff. */
|
|
5283
|
+
function messageTime(message) {
|
|
5284
|
+
if (message.createdAt === null) {
|
|
5285
|
+
return undefined;
|
|
5261
5286
|
}
|
|
5287
|
+
const time = Date.parse(message.createdAt);
|
|
5288
|
+
return Number.isFinite(time) ? time : undefined;
|
|
5262
5289
|
}
|
|
5263
|
-
/**
|
|
5264
|
-
function
|
|
5265
|
-
return
|
|
5290
|
+
/** List routes may include tombstones for rows removed since the first page. */
|
|
5291
|
+
function isTombstone(value) {
|
|
5292
|
+
return (0, type_guards_1.isRecord)(value) && value.deleted === true && (0, type_guards_1.isString)(value.id);
|
|
5266
5293
|
}
|
|
5267
5294
|
/** Validates a positive duration, including values supplied programmatically. */
|
|
5268
5295
|
function duration(value, fallback, label) {
|
|
@@ -5272,28 +5299,322 @@ function duration(value, fallback, label) {
|
|
|
5272
5299
|
}
|
|
5273
5300
|
return result;
|
|
5274
5301
|
}
|
|
5275
|
-
/**
|
|
5302
|
+
/** Parses one account entry from the list or single-account route. */
|
|
5303
|
+
function parseAccount(value) {
|
|
5304
|
+
if (!(0, type_guards_1.isRecord)(value) || !(0, type_guards_1.isString)(value.id) || !value.id ||
|
|
5305
|
+
!(0, type_guards_1.isBoolean)(value.authenticated)) {
|
|
5306
|
+
throw new Error('Unexpected WeChat account entry.');
|
|
5307
|
+
}
|
|
5308
|
+
const username = (0, type_guards_1.isString)(value.ownUsername) ? value.ownUsername.trim() : '';
|
|
5309
|
+
return { id: value.id, authenticated: value.authenticated,
|
|
5310
|
+
...(username ? { ownUsername: username } : {}) };
|
|
5311
|
+
}
|
|
5312
|
+
/** Event cursors are decimal i64 strings and may exceed Number.MAX_SAFE_INTEGER. */
|
|
5313
|
+
function parseEventCursor(value, label) {
|
|
5314
|
+
if (!(0, type_guards_1.isString)(value) || !EVENT_CURSOR_PATTERN.test(value) ||
|
|
5315
|
+
BigInt(value) > limit_1.WECHAT_MAX_EVENT_CURSOR) {
|
|
5316
|
+
throw new Error(`Unexpected WeChat ${label}.`);
|
|
5317
|
+
}
|
|
5318
|
+
return value;
|
|
5319
|
+
}
|
|
5320
|
+
/** True when `cursor` is already covered by the checkpoint, so a replay must be skipped. */
|
|
5321
|
+
function cursorCovered(cursor, checkpoint) {
|
|
5322
|
+
return checkpoint !== undefined && BigInt(cursor) <= BigInt(checkpoint);
|
|
5323
|
+
}
|
|
5324
|
+
/** Parses the daemon's `{ items, nextCursor }` page shape and optional event high-water. */
|
|
5325
|
+
function parsePage(value, label) {
|
|
5326
|
+
if (!(0, type_guards_1.isRecord)(value) || !Array.isArray(value.items) ||
|
|
5327
|
+
!(value.nextCursor === null || (0, type_guards_1.isString)(value.nextCursor)) ||
|
|
5328
|
+
!(value.asOfEventCursor === undefined || (0, type_guards_1.isString)(value.asOfEventCursor))) {
|
|
5329
|
+
throw new Error(`Unexpected WeChat ${label} response.`);
|
|
5330
|
+
}
|
|
5331
|
+
return {
|
|
5332
|
+
items: value.items,
|
|
5333
|
+
nextCursor: value.nextCursor,
|
|
5334
|
+
...(value.asOfEventCursor === undefined
|
|
5335
|
+
? {}
|
|
5336
|
+
: { asOfEventCursor: parseEventCursor(value.asOfEventCursor, `${label} event cursor`) })
|
|
5337
|
+
};
|
|
5338
|
+
}
|
|
5339
|
+
/** Parses the readable fields of one saved message. */
|
|
5340
|
+
function parseMessage(value, account, threadId) {
|
|
5341
|
+
if (!(0, type_guards_1.isRecord)(value) || !(0, type_guards_1.isString)(value.id) || !value.id ||
|
|
5342
|
+
value.accountId !== account || value.threadId !== threadId ||
|
|
5343
|
+
!(value.senderId === null || (0, type_guards_1.isString)(value.senderId)) ||
|
|
5344
|
+
!(0, type_guards_1.isNumber)(value.type) || !Number.isSafeInteger(value.type) ||
|
|
5345
|
+
!(value.direction === null || ((0, type_guards_1.isString)(value.direction) && DIRECTIONS.has(value.direction))) ||
|
|
5346
|
+
!(value.body === null || (0, type_guards_1.isString)(value.body)) ||
|
|
5347
|
+
!(value.createdAt === null || (0, type_guards_1.isString)(value.createdAt))) {
|
|
5348
|
+
throw new Error('Unexpected WeChat message fields.');
|
|
5349
|
+
}
|
|
5350
|
+
if (value.createdAt !== null && !Number.isFinite(Date.parse(value.createdAt))) {
|
|
5351
|
+
throw new Error('Unexpected WeChat message time.');
|
|
5352
|
+
}
|
|
5353
|
+
return {
|
|
5354
|
+
id: value.id,
|
|
5355
|
+
accountId: value.accountId,
|
|
5356
|
+
threadId: value.threadId,
|
|
5357
|
+
senderId: value.senderId,
|
|
5358
|
+
type: value.type,
|
|
5359
|
+
direction: value.direction,
|
|
5360
|
+
body: value.body,
|
|
5361
|
+
createdAt: value.createdAt
|
|
5362
|
+
};
|
|
5363
|
+
}
|
|
5364
|
+
/** Parses one attachment copy; tombstones are ignored. */
|
|
5365
|
+
function parseMediaAsset(value, account, messageId) {
|
|
5366
|
+
if (isTombstone(value)) {
|
|
5367
|
+
return undefined;
|
|
5368
|
+
}
|
|
5369
|
+
// Another account's or another message's attachment is never ours to fetch.
|
|
5370
|
+
if ((0, type_guards_1.isRecord)(value) && (value.accountId !== account || value.messageId !== messageId)) {
|
|
5371
|
+
return undefined;
|
|
5372
|
+
}
|
|
5373
|
+
if (!(0, type_guards_1.isRecord)(value) || !(0, type_guards_1.isString)(value.id) || !value.id ||
|
|
5374
|
+
!(0, type_guards_1.isString)(value.kind) || !(0, type_guards_1.isString)(value.variant) ||
|
|
5375
|
+
!(0, type_guards_1.isString)(value.state) || !(0, type_guards_1.isBoolean)(value.retryable) ||
|
|
5376
|
+
!(value.actualVariant === null || value.actualVariant === undefined || (0, type_guards_1.isString)(value.actualVariant)) ||
|
|
5377
|
+
!(value.contentType === null || value.contentType === undefined || (0, type_guards_1.isString)(value.contentType))) {
|
|
5378
|
+
throw new Error('Unexpected WeChat media asset fields.');
|
|
5379
|
+
}
|
|
5380
|
+
return {
|
|
5381
|
+
id: value.id,
|
|
5382
|
+
kind: value.kind,
|
|
5383
|
+
variant: value.variant,
|
|
5384
|
+
actualVariant: value.actualVariant ?? null,
|
|
5385
|
+
state: value.state,
|
|
5386
|
+
contentType: value.contentType ?? null,
|
|
5387
|
+
retryable: value.retryable
|
|
5388
|
+
};
|
|
5389
|
+
}
|
|
5390
|
+
/** Media copy preference for the best available quality of each supported kind. */
|
|
5391
|
+
function variantRank(kind, asset) {
|
|
5392
|
+
const order = kind === 'image' ? IMAGE_VARIANTS : VOICE_VARIANTS;
|
|
5393
|
+
const variant = asset.actualVariant ?? asset.variant;
|
|
5394
|
+
const index = order.indexOf(variant);
|
|
5395
|
+
return index === -1 ? order.length : index;
|
|
5396
|
+
}
|
|
5397
|
+
/** Client media errors are terminal; timeouts and rate limits remain retryable. */
|
|
5398
|
+
function isTerminalMediaStatus(status) {
|
|
5399
|
+
return status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
5400
|
+
}
|
|
5401
|
+
/**
|
|
5402
|
+
* Cancels one reader without letting an unresponsive source block shutdown, then
|
|
5403
|
+
* releases the lock when the pending read has settled.
|
|
5404
|
+
*/
|
|
5405
|
+
async function cancelBounded(reader, wait) {
|
|
5406
|
+
let timer;
|
|
5407
|
+
const deadline = new Promise((resolve) => {
|
|
5408
|
+
timer = setTimeout(() => resolve('timeout'), wait);
|
|
5409
|
+
});
|
|
5410
|
+
try {
|
|
5411
|
+
await Promise.race([reader.cancel().catch(() => 'cancelled'), deadline]);
|
|
5412
|
+
}
|
|
5413
|
+
finally {
|
|
5414
|
+
clearTimeout(timer);
|
|
5415
|
+
}
|
|
5416
|
+
try {
|
|
5417
|
+
reader.releaseLock();
|
|
5418
|
+
}
|
|
5419
|
+
catch {
|
|
5420
|
+
// A still-pending read holds the lock; the response is discarded either way.
|
|
5421
|
+
}
|
|
5422
|
+
}
|
|
5423
|
+
/** Reads at most `maxBytes` of a response body, bounded by a deadline and optional abort. */
|
|
5424
|
+
async function readBoundedText(response, maxBytes, timeout, signal) {
|
|
5425
|
+
if (response.body === null) {
|
|
5426
|
+
return '';
|
|
5427
|
+
}
|
|
5428
|
+
const reader = response.body.getReader();
|
|
5429
|
+
if (signal?.aborted === true) {
|
|
5430
|
+
await cancelBounded(reader, limit_1.WECHAT_READER_CANCEL_TIMEOUT_MS);
|
|
5431
|
+
return undefined;
|
|
5432
|
+
}
|
|
5433
|
+
const chunks = [];
|
|
5434
|
+
let size = 0;
|
|
5435
|
+
let timer;
|
|
5436
|
+
let resolveDeadline = () => { };
|
|
5437
|
+
const deadline = new Promise((resolve) => {
|
|
5438
|
+
resolveDeadline = () => resolve('aborted');
|
|
5439
|
+
timer = setTimeout(() => resolve('timeout'), timeout);
|
|
5440
|
+
signal?.addEventListener('abort', resolveDeadline, { once: true });
|
|
5441
|
+
});
|
|
5442
|
+
try {
|
|
5443
|
+
while (true) {
|
|
5444
|
+
const next = await Promise.race([reader.read(), deadline]);
|
|
5445
|
+
if (next === 'timeout' || next === 'aborted') {
|
|
5446
|
+
return undefined;
|
|
5447
|
+
}
|
|
5448
|
+
if (next.done) {
|
|
5449
|
+
break;
|
|
5450
|
+
}
|
|
5451
|
+
size += next.value.byteLength;
|
|
5452
|
+
if (size > maxBytes) {
|
|
5453
|
+
return undefined;
|
|
5454
|
+
}
|
|
5455
|
+
chunks.push(next.value);
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
catch {
|
|
5459
|
+
return undefined;
|
|
5460
|
+
}
|
|
5461
|
+
finally {
|
|
5462
|
+
clearTimeout(timer);
|
|
5463
|
+
signal?.removeEventListener('abort', resolveDeadline);
|
|
5464
|
+
await cancelBounded(reader, limit_1.WECHAT_READER_CANCEL_TIMEOUT_MS);
|
|
5465
|
+
}
|
|
5466
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
5467
|
+
}
|
|
5468
|
+
/** Retains HTTP status for media retry classification without retaining response bodies. */
|
|
5469
|
+
class WechatRequestError extends Error {
|
|
5470
|
+
status;
|
|
5471
|
+
constructor(status, endpoint) {
|
|
5472
|
+
super(`WeChat ${endpoint.split('?')[0]} returned HTTP ${status}.`);
|
|
5473
|
+
this.status = status;
|
|
5474
|
+
}
|
|
5475
|
+
}
|
|
5476
|
+
/** The daemon no longer retains events after the supplied cursor. */
|
|
5477
|
+
class EventCursorExpiredError extends Error {
|
|
5478
|
+
constructor() {
|
|
5479
|
+
super('WeChat event cursor expired.');
|
|
5480
|
+
}
|
|
5481
|
+
}
|
|
5482
|
+
/** The daemon account or signed-in username changed while an event was in flight. */
|
|
5483
|
+
class AccountChangedError extends Error {
|
|
5484
|
+
}
|
|
5485
|
+
/**
|
|
5486
|
+
* Incremental SSE parser. UTF-8 decoding happens before this class, so chunks may
|
|
5487
|
+
* split lines but never characters; only complete frames are returned.
|
|
5488
|
+
*/
|
|
5489
|
+
class SseParser {
|
|
5490
|
+
buffer = '';
|
|
5491
|
+
data = [];
|
|
5492
|
+
dataChars = 0;
|
|
5493
|
+
id;
|
|
5494
|
+
event;
|
|
5495
|
+
/** The previous chunk ended with CR, so a leading LF completes that CRLF. */
|
|
5496
|
+
pendingCr = false;
|
|
5497
|
+
/** Appends decoded text and returns every frame completed by it. */
|
|
5498
|
+
push(value) {
|
|
5499
|
+
let chunk = value;
|
|
5500
|
+
if (this.pendingCr) {
|
|
5501
|
+
this.pendingCr = false;
|
|
5502
|
+
if (chunk.startsWith('\n')) {
|
|
5503
|
+
chunk = chunk.slice(1);
|
|
5504
|
+
}
|
|
5505
|
+
}
|
|
5506
|
+
this.buffer += chunk;
|
|
5507
|
+
const frames = [];
|
|
5508
|
+
let start = 0;
|
|
5509
|
+
let index = 0;
|
|
5510
|
+
while (index < this.buffer.length) {
|
|
5511
|
+
const character = this.buffer[index];
|
|
5512
|
+
if (character !== '\n' && character !== '\r') {
|
|
5513
|
+
index += 1;
|
|
5514
|
+
continue;
|
|
5515
|
+
}
|
|
5516
|
+
if (index - start > limit_1.MAX_SSE_LINE_CHARS) {
|
|
5517
|
+
throw new Error('WeChat event stream line exceeded its size limit.');
|
|
5518
|
+
}
|
|
5519
|
+
const line = this.buffer.slice(start, index);
|
|
5520
|
+
if (character === '\r' && index === this.buffer.length - 1) {
|
|
5521
|
+
// A CRLF may be split across chunks; the terminator already applied.
|
|
5522
|
+
this.pendingCr = true;
|
|
5523
|
+
index += 1;
|
|
5524
|
+
}
|
|
5525
|
+
else {
|
|
5526
|
+
index += character === '\r' && this.buffer[index + 1] === '\n' ? 2 : 1;
|
|
5527
|
+
}
|
|
5528
|
+
const frame = this.line(line);
|
|
5529
|
+
if (frame !== undefined) {
|
|
5530
|
+
frames.push(frame);
|
|
5531
|
+
}
|
|
5532
|
+
start = index;
|
|
5533
|
+
}
|
|
5534
|
+
this.buffer = this.buffer.slice(start);
|
|
5535
|
+
if (this.buffer.length > limit_1.MAX_SSE_LINE_CHARS) {
|
|
5536
|
+
throw new Error('WeChat event stream line exceeded its size limit.');
|
|
5537
|
+
}
|
|
5538
|
+
return frames;
|
|
5539
|
+
}
|
|
5540
|
+
/** Consumes one line without dispatching anything until a blank line arrives. */
|
|
5541
|
+
line(line) {
|
|
5542
|
+
if (line === '') {
|
|
5543
|
+
return this.dispatch();
|
|
5544
|
+
}
|
|
5545
|
+
if (line.startsWith(':')) {
|
|
5546
|
+
// A comment is also the daemon's 15-second keepalive.
|
|
5547
|
+
return undefined;
|
|
5548
|
+
}
|
|
5549
|
+
const colon = line.indexOf(':');
|
|
5550
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
5551
|
+
let value = colon === -1 ? '' : line.slice(colon + 1);
|
|
5552
|
+
if (value.startsWith(' ')) {
|
|
5553
|
+
value = value.slice(1);
|
|
5554
|
+
}
|
|
5555
|
+
if (field === 'data') {
|
|
5556
|
+
this.dataChars += value.length + (this.data.length === 0 ? 0 : 1);
|
|
5557
|
+
if (this.dataChars > limit_1.MAX_SSE_FRAME_CHARS) {
|
|
5558
|
+
throw new Error('WeChat event frame exceeded its size limit.');
|
|
5559
|
+
}
|
|
5560
|
+
this.data.push(value);
|
|
5561
|
+
}
|
|
5562
|
+
else if (field === 'id') {
|
|
5563
|
+
if (!value.includes('\u0000')) {
|
|
5564
|
+
this.id = value;
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
else if (field === 'event') {
|
|
5568
|
+
this.event = value;
|
|
5569
|
+
}
|
|
5570
|
+
return undefined;
|
|
5571
|
+
}
|
|
5572
|
+
dispatch() {
|
|
5573
|
+
if (this.data.length === 0 && this.event === undefined) {
|
|
5574
|
+
this.id = undefined;
|
|
5575
|
+
return undefined;
|
|
5576
|
+
}
|
|
5577
|
+
const frame = {
|
|
5578
|
+
data: this.data.join('\n'),
|
|
5579
|
+
...(this.id === undefined ? {} : { id: this.id }),
|
|
5580
|
+
...(this.event === undefined ? {} : { event: this.event })
|
|
5581
|
+
};
|
|
5582
|
+
this.data = [];
|
|
5583
|
+
this.dataChars = 0;
|
|
5584
|
+
this.id = undefined;
|
|
5585
|
+
this.event = undefined;
|
|
5586
|
+
return frame;
|
|
5587
|
+
}
|
|
5588
|
+
}
|
|
5589
|
+
/** Receives text and lazy media references from agent-wechat's event stream; never listens on a port. */
|
|
5276
5590
|
class WechatMessagerAdapter {
|
|
5277
5591
|
options;
|
|
5278
5592
|
name = 'wechat';
|
|
5279
5593
|
baseUrl;
|
|
5280
|
-
|
|
5594
|
+
reconnect;
|
|
5281
5595
|
timeout;
|
|
5282
|
-
bundleGap;
|
|
5283
|
-
cursors = new Map();
|
|
5284
|
-
chatFailures = new Map();
|
|
5285
5596
|
operations = new Set();
|
|
5597
|
+
names = new Map();
|
|
5598
|
+
/** Recent live message IDs, bounded; the SSE cursor checkpoint is the primary dedupe. */
|
|
5599
|
+
seen = new Set();
|
|
5600
|
+
/** Nonterminal outbound operations waiting for a matching event-stream update. */
|
|
5601
|
+
pending = new Map();
|
|
5286
5602
|
token;
|
|
5287
5603
|
account;
|
|
5604
|
+
selfUsername;
|
|
5288
5605
|
handler;
|
|
5289
5606
|
startedAt = 0;
|
|
5290
|
-
|
|
5291
|
-
|
|
5607
|
+
streamAbort = new AbortController();
|
|
5608
|
+
streaming;
|
|
5292
5609
|
sending = Promise.resolve();
|
|
5610
|
+
after;
|
|
5611
|
+
attempted = false;
|
|
5293
5612
|
failures = 0;
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5613
|
+
lastEventAt;
|
|
5614
|
+
lastStreamConnectedAt;
|
|
5615
|
+
lastStreamActivityAt;
|
|
5616
|
+
lastError;
|
|
5617
|
+
lastErrorAt;
|
|
5297
5618
|
constructor(token, options = {}) {
|
|
5298
5619
|
this.options = options;
|
|
5299
5620
|
this.token = token.trim();
|
|
@@ -5305,502 +5626,1189 @@ class WechatMessagerAdapter {
|
|
|
5305
5626
|
url.search || url.hash) {
|
|
5306
5627
|
throw new Error('WeChat baseUrl must be an HTTP(S) URL without credentials, query, or fragment.');
|
|
5307
5628
|
}
|
|
5308
|
-
|
|
5309
|
-
|
|
5629
|
+
const root = url.toString().replace(/\/+$/, '');
|
|
5630
|
+
// The convention is the server root; strip a supplied `/v1` so no route becomes `/v1/v1`.
|
|
5631
|
+
this.baseUrl = root.endsWith('/v1') ? root.slice(0, -3) : root;
|
|
5632
|
+
this.reconnect = duration(options.reconnectIntervalMs, limit_1.WECHAT_RECONNECT_INTERVAL_MS, 'reconnectIntervalMs');
|
|
5310
5633
|
this.timeout = duration(options.timeoutMs, limit_1.WECHAT_REQUEST_TIMEOUT_MS, 'timeoutMs');
|
|
5311
|
-
|
|
5634
|
+
if (options.accountId !== undefined && !options.accountId.trim()) {
|
|
5635
|
+
throw new Error('WeChat accountId must be a non-empty daemon account ID.');
|
|
5636
|
+
}
|
|
5312
5637
|
}
|
|
5638
|
+
/** Resolves the configured account, then starts authenticated event streaming. */
|
|
5313
5639
|
async start(handler) {
|
|
5314
5640
|
if (this.handler !== undefined) {
|
|
5315
5641
|
throw new Error('WeChat adapter is already started.');
|
|
5316
5642
|
}
|
|
5643
|
+
this.resetState();
|
|
5644
|
+
this.streamAbort = new AbortController();
|
|
5317
5645
|
this.startedAt = Date.now();
|
|
5318
|
-
this.cursors.clear();
|
|
5319
|
-
this.chatFailures.clear();
|
|
5320
|
-
this.failures = 0;
|
|
5321
|
-
this.lastSuccessfulPollAt = undefined;
|
|
5322
|
-
this.lastPollError = undefined;
|
|
5323
|
-
this.lastPollErrorAt = undefined;
|
|
5324
5646
|
this.handler = handler;
|
|
5325
5647
|
try {
|
|
5326
|
-
this.
|
|
5648
|
+
this.adoptAccount(await this.readAccount(), true);
|
|
5327
5649
|
}
|
|
5328
5650
|
catch (error) {
|
|
5329
5651
|
this.handler = undefined;
|
|
5330
5652
|
throw error;
|
|
5331
5653
|
}
|
|
5332
|
-
|
|
5654
|
+
// The long-lived stream stays in the background so a transient daemon failure
|
|
5655
|
+
// cannot prevent the server from starting.
|
|
5656
|
+
this.streaming = this.streamLoop().catch((error) => {
|
|
5657
|
+
if (this.handler !== undefined) {
|
|
5658
|
+
console.error('[wechat] event stream stopped unexpectedly:', error);
|
|
5659
|
+
}
|
|
5660
|
+
});
|
|
5333
5661
|
}
|
|
5334
|
-
/** Aborts
|
|
5662
|
+
/** Aborts stream reads and backoff sleeps, then waits for processing and sends to finish. */
|
|
5335
5663
|
async stop() {
|
|
5336
5664
|
this.handler = undefined;
|
|
5337
|
-
|
|
5665
|
+
this.streamAbort.abort();
|
|
5338
5666
|
for (const controller of this.operations) {
|
|
5339
5667
|
controller.abort();
|
|
5340
5668
|
}
|
|
5341
|
-
await Promise.allSettled([this.
|
|
5342
|
-
this.
|
|
5343
|
-
this.
|
|
5669
|
+
await Promise.allSettled([this.streaming, this.sending]);
|
|
5670
|
+
this.streaming = undefined;
|
|
5671
|
+
this.rejectPending('WeChat adapter stopped before delivery was confirmed.');
|
|
5672
|
+
this.names.clear();
|
|
5673
|
+
this.seen.clear();
|
|
5674
|
+
this.after = undefined;
|
|
5344
5675
|
this.account = undefined;
|
|
5676
|
+
this.selfUsername = undefined;
|
|
5345
5677
|
}
|
|
5346
5678
|
/** Serializes deliveries; the server schedules the delay before building its snapshot. */
|
|
5347
5679
|
reply(message, reply, signal) {
|
|
5348
5680
|
const send = this.sending.then(async () => {
|
|
5349
5681
|
this.requireActiveAccount(message);
|
|
5350
5682
|
signal?.throwIfAborted();
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
if (
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
return { data, mimeType };
|
|
5368
|
-
});
|
|
5369
|
-
for (const [index, image] of images.entries()) {
|
|
5683
|
+
// Fix the originating origin so a later switch cannot retarget this delivery.
|
|
5684
|
+
const account = this.accountId;
|
|
5685
|
+
const accountPath = this.accountPath;
|
|
5686
|
+
const destination = reply.direct ? message.senderId : reply.channel ?? message.channel;
|
|
5687
|
+
if (!destination) {
|
|
5688
|
+
throw new Error('WeChat reply requires a destination thread ID.');
|
|
5689
|
+
}
|
|
5690
|
+
// Validate every attachment before uploading or sending any part of the reply.
|
|
5691
|
+
const images = (reply.images ?? []).map((image) => this.validateImage(image));
|
|
5692
|
+
if (images.length > 0 && (reply.voice || reply.audio !== undefined)) {
|
|
5693
|
+
throw new Error('WeChat image replies cannot also contain voice audio.');
|
|
5694
|
+
}
|
|
5695
|
+
const voice = reply.voice ? this.validateVoice(reply.audio) : undefined;
|
|
5696
|
+
if (images.length > 0) {
|
|
5697
|
+
// The v2 API has no combined image/text send; a caption is a separate text send.
|
|
5698
|
+
for (const image of images) {
|
|
5370
5699
|
this.requireActiveAccount(message);
|
|
5371
5700
|
signal?.throwIfAborted();
|
|
5372
|
-
|
|
5373
|
-
chatId, image, ...(index === 0 && reply.text.trim() ? { text: reply.text } : {})
|
|
5374
|
-
}, signal);
|
|
5375
|
-
if (!isRecord(result) || result.success !== true) {
|
|
5376
|
-
throw new Error('WeChat rejected the image send. Check the agent-server logs.');
|
|
5377
|
-
}
|
|
5701
|
+
await this.sendMedia(message, account, accountPath, destination, 'image', image, signal);
|
|
5378
5702
|
}
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
if (reply.voice) {
|
|
5382
|
-
if (reply.audio === undefined) {
|
|
5383
|
-
throw new Error('Voice reply requires synthesized audio.');
|
|
5703
|
+
if (reply.text.trim()) {
|
|
5704
|
+
await this.sendText(message, account, accountPath, destination, reply.text, signal);
|
|
5384
5705
|
}
|
|
5385
|
-
await this.sendVoice(message, chatId, reply.audio, signal);
|
|
5386
5706
|
return;
|
|
5387
5707
|
}
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5708
|
+
if (voice !== undefined) {
|
|
5709
|
+
await this.sendMedia(message, account, accountPath, destination, 'voice', voice, signal);
|
|
5710
|
+
return;
|
|
5391
5711
|
}
|
|
5712
|
+
await this.sendText(message, account, accountPath, destination, reply.text, signal);
|
|
5392
5713
|
});
|
|
5393
5714
|
// A failed attempt must not poison the queue or cause an automatic retry.
|
|
5394
5715
|
this.sending = send.catch(() => { });
|
|
5395
5716
|
return send;
|
|
5396
5717
|
}
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5718
|
+
getStatus() {
|
|
5719
|
+
return {
|
|
5720
|
+
active: this.handler !== undefined,
|
|
5721
|
+
lastEventAt: this.lastEventAt,
|
|
5722
|
+
lastStreamConnectedAt: this.lastStreamConnectedAt,
|
|
5723
|
+
lastStreamActivityAt: this.lastStreamActivityAt,
|
|
5724
|
+
consecutiveFailures: this.failures,
|
|
5725
|
+
lastError: this.lastError,
|
|
5726
|
+
lastErrorAt: this.lastErrorAt
|
|
5727
|
+
};
|
|
5728
|
+
}
|
|
5729
|
+
/** Daemon account ID, never the signed-in contact username. */
|
|
5730
|
+
get accountId() {
|
|
5731
|
+
if (this.account === undefined) {
|
|
5732
|
+
throw new Error('WeChat adapter is not started.');
|
|
5733
|
+
}
|
|
5734
|
+
return this.account;
|
|
5735
|
+
}
|
|
5736
|
+
get accountPath() {
|
|
5737
|
+
return `/v1/accounts/${encodeURIComponent(this.accountId)}`;
|
|
5738
|
+
}
|
|
5739
|
+
resetState() {
|
|
5740
|
+
this.rejectPending('WeChat adapter restarted before delivery was confirmed.');
|
|
5741
|
+
this.names.clear();
|
|
5742
|
+
this.seen.clear();
|
|
5743
|
+
this.after = undefined;
|
|
5744
|
+
this.attempted = false;
|
|
5745
|
+
this.failures = 0;
|
|
5746
|
+
this.lastEventAt = undefined;
|
|
5747
|
+
this.lastStreamConnectedAt = undefined;
|
|
5748
|
+
this.lastStreamActivityAt = undefined;
|
|
5749
|
+
this.lastError = undefined;
|
|
5750
|
+
this.lastErrorAt = undefined;
|
|
5751
|
+
}
|
|
5752
|
+
/** Applies a resolved account, clearing session state when the daemon ID or identity changes. */
|
|
5753
|
+
adoptAccount(account, initial = false) {
|
|
5754
|
+
if (account.id === this.account && account.ownUsername === this.selfUsername) {
|
|
5755
|
+
return;
|
|
5756
|
+
}
|
|
5757
|
+
// Pending sends belong to the old identity; settle them instead of resolving a
|
|
5758
|
+
// different account's event or leaving their callers until the full deadline.
|
|
5759
|
+
this.rejectPending('WeChat account changed before delivery was confirmed.');
|
|
5760
|
+
this.account = account.id;
|
|
5761
|
+
this.selfUsername = account.ownUsername;
|
|
5762
|
+
if (!initial) {
|
|
5763
|
+
// A switch or re-login resets the startup cutoff so the new identity does not
|
|
5764
|
+
// inherit another account's event cursor or message window.
|
|
5765
|
+
this.startedAt = Date.now();
|
|
5766
|
+
}
|
|
5767
|
+
this.names.clear();
|
|
5768
|
+
this.seen.clear();
|
|
5769
|
+
this.after = undefined;
|
|
5770
|
+
}
|
|
5771
|
+
requireActiveAccount(message) {
|
|
5772
|
+
this.requireAccount(message.metadata?.wechatAccount, message.metadata?.wechatUsername);
|
|
5773
|
+
}
|
|
5774
|
+
/**
|
|
5775
|
+
* Both the daemon account and the signed-in identity must match the active session.
|
|
5776
|
+
* There is no bypass for a missing username: an origin without a recorded WeChat
|
|
5777
|
+
* username cannot be proven to belong to the current login.
|
|
5778
|
+
*/
|
|
5779
|
+
requireAccount(account, username) {
|
|
5780
|
+
if (this.handler === undefined || this.selfUsername === undefined ||
|
|
5781
|
+
!(0, type_guards_1.isString)(account) || account !== this.account ||
|
|
5782
|
+
!(0, type_guards_1.isString)(username) || username !== this.selfUsername) {
|
|
5783
|
+
throw new Error('WeChat adapter is stopped or the originating account has changed.');
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
/** The daemon account and signed-in username currently backing this adapter. */
|
|
5787
|
+
get identity() {
|
|
5788
|
+
return { account: this.account, username: this.selfUsername };
|
|
5789
|
+
}
|
|
5790
|
+
/**
|
|
5791
|
+
* Rejects work whose captured login is no longer active. The same daemon account can be
|
|
5792
|
+
* re-logged into a different WeChat identity, so the signed-in username is compared too.
|
|
5793
|
+
*/
|
|
5794
|
+
requireIdentity(origin) {
|
|
5795
|
+
if (this.handler === undefined || this.account !== origin.account ||
|
|
5796
|
+
this.selfUsername !== origin.username) {
|
|
5797
|
+
throw new AccountChangedError();
|
|
5401
5798
|
}
|
|
5799
|
+
}
|
|
5800
|
+
/** Reads the configured account or requires exactly one authenticated account. */
|
|
5801
|
+
async readAccount() {
|
|
5802
|
+
const configured = this.options.accountId?.trim();
|
|
5803
|
+
if (configured !== undefined) {
|
|
5804
|
+
let entry;
|
|
5805
|
+
try {
|
|
5806
|
+
entry = await this.request(`/v1/accounts/${encodeURIComponent(configured)}`);
|
|
5807
|
+
}
|
|
5808
|
+
catch (error) {
|
|
5809
|
+
if (error instanceof WechatRequestError && error.status === 404) {
|
|
5810
|
+
throw new Error(`WeChat account "${configured}" is not visible to this token.`);
|
|
5811
|
+
}
|
|
5812
|
+
throw error;
|
|
5813
|
+
}
|
|
5814
|
+
const account = parseAccount(entry);
|
|
5815
|
+
this.requireUsable(account, configured);
|
|
5816
|
+
return account;
|
|
5817
|
+
}
|
|
5818
|
+
const body = await this.request('/v1/accounts');
|
|
5819
|
+
if (!(0, type_guards_1.isRecord)(body) || !Array.isArray(body.items)) {
|
|
5820
|
+
throw new Error('Unexpected WeChat accounts response.');
|
|
5821
|
+
}
|
|
5822
|
+
const authenticated = body.items.map(parseAccount).filter((item) => item.authenticated);
|
|
5823
|
+
if (authenticated.length === 0) {
|
|
5824
|
+
throw new Error('WeChat has no authenticated account. Log in using agent-wechat first.');
|
|
5825
|
+
}
|
|
5826
|
+
if (authenticated.length > 1) {
|
|
5827
|
+
throw new Error(`WeChat has ${authenticated.length} authenticated accounts ` +
|
|
5828
|
+
`(${authenticated.map((item) => item.id).join(', ')}); set messager.options.accountId.`);
|
|
5829
|
+
}
|
|
5830
|
+
this.requireUsable(authenticated[0], 'selected');
|
|
5831
|
+
return authenticated[0];
|
|
5832
|
+
}
|
|
5833
|
+
requireUsable(account, requested) {
|
|
5834
|
+
if (!account.authenticated) {
|
|
5835
|
+
throw new Error(`WeChat account "${requested}" is not authenticated. Log in using agent-wechat first.`);
|
|
5836
|
+
}
|
|
5837
|
+
if (account.ownUsername === undefined) {
|
|
5838
|
+
throw new Error(`WeChat account "${requested}" does not report a signed-in username.`);
|
|
5839
|
+
}
|
|
5840
|
+
}
|
|
5841
|
+
/**
|
|
5842
|
+
* Keeps one authenticated event stream open. Startup and an expired cursor both rebase
|
|
5843
|
+
* on one current-tail lookup; events missed after expiry are intentionally skipped.
|
|
5844
|
+
* Reconnects resume from the last checkpoint, and every failed attempt backs off.
|
|
5845
|
+
*/
|
|
5846
|
+
async streamLoop() {
|
|
5847
|
+
while (this.handler !== undefined) {
|
|
5848
|
+
try {
|
|
5849
|
+
if (this.attempted) {
|
|
5850
|
+
this.adoptAccount(await this.readAccount());
|
|
5851
|
+
}
|
|
5852
|
+
this.attempted = true;
|
|
5853
|
+
if (this.after === undefined) {
|
|
5854
|
+
// Capture the login first: a switch or shutdown during the lookup means the
|
|
5855
|
+
// tail it returns belongs to a session this loop must not use.
|
|
5856
|
+
this.after = await this.readCurrentCursor(this.identity);
|
|
5857
|
+
}
|
|
5858
|
+
if (this.handler === undefined) {
|
|
5859
|
+
return;
|
|
5860
|
+
}
|
|
5861
|
+
await this.streamOnce();
|
|
5862
|
+
if (this.handler === undefined) {
|
|
5863
|
+
return;
|
|
5864
|
+
}
|
|
5865
|
+
throw new Error('WeChat event stream closed.');
|
|
5866
|
+
}
|
|
5867
|
+
catch (error) {
|
|
5868
|
+
if (this.handler === undefined) {
|
|
5869
|
+
return;
|
|
5870
|
+
}
|
|
5871
|
+
if (error instanceof AccountChangedError) {
|
|
5872
|
+
// A switch restarts the stream promptly, still under the capped backoff so a
|
|
5873
|
+
// login that keeps failing revalidation cannot spin.
|
|
5874
|
+
await this.backoff();
|
|
5875
|
+
continue;
|
|
5876
|
+
}
|
|
5877
|
+
this.recordFailure(error);
|
|
5878
|
+
if (error instanceof EventCursorExpiredError) {
|
|
5879
|
+
// The daemon no longer retains events after this cursor. Rebase at the current
|
|
5880
|
+
// tail and intentionally skip the missed window instead of replaying history.
|
|
5881
|
+
// Repeated expiry still backs off because every expiry records a failure.
|
|
5882
|
+
this.after = undefined;
|
|
5883
|
+
console.warn('[wechat] event cursor expired; resuming at the current tail.');
|
|
5884
|
+
}
|
|
5885
|
+
if (this.handler !== undefined) {
|
|
5886
|
+
await this.backoff();
|
|
5887
|
+
}
|
|
5888
|
+
}
|
|
5889
|
+
}
|
|
5890
|
+
}
|
|
5891
|
+
/** Reads one page only for its `asOfEventCursor`, seeding or rebasing the checkpoint. */
|
|
5892
|
+
async readCurrentCursor(origin) {
|
|
5893
|
+
const page = parsePage(await this.request(`${this.accountPath}/threads?limit=1`), 'threads');
|
|
5894
|
+
// A switch or shutdown during the lookup invalidates this tail and this attempt.
|
|
5895
|
+
this.requireIdentity(origin);
|
|
5896
|
+
if (page.asOfEventCursor === undefined) {
|
|
5897
|
+
throw new Error('WeChat thread listing did not report an event cursor.');
|
|
5898
|
+
}
|
|
5899
|
+
return page.asOfEventCursor;
|
|
5900
|
+
}
|
|
5901
|
+
/** Opens the event stream and dispatches complete frames sequentially. */
|
|
5902
|
+
async streamOnce() {
|
|
5903
|
+
if (this.handler === undefined) {
|
|
5904
|
+
return;
|
|
5905
|
+
}
|
|
5906
|
+
if (this.after === undefined) {
|
|
5907
|
+
throw new Error('WeChat event stream has no checkpoint.');
|
|
5908
|
+
}
|
|
5909
|
+
const checkpoint = parseEventCursor(this.after, 'event cursor');
|
|
5402
5910
|
const controller = new AbortController();
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5911
|
+
this.operations.add(controller);
|
|
5912
|
+
let connectTimer;
|
|
5913
|
+
let idleTimer;
|
|
5914
|
+
let reason;
|
|
5915
|
+
const expire = (label) => {
|
|
5916
|
+
reason = label;
|
|
5406
5917
|
controller.abort();
|
|
5918
|
+
};
|
|
5919
|
+
connectTimer = setTimeout(() => expire('connect timed out'), this.timeout);
|
|
5920
|
+
try {
|
|
5921
|
+
const response = await fetch(`${this.baseUrl}${this.accountPath}/events/stream?after=${encodeURIComponent(checkpoint)}`, {
|
|
5922
|
+
headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
|
|
5923
|
+
signal: controller.signal
|
|
5924
|
+
});
|
|
5925
|
+
clearTimeout(connectTimer);
|
|
5926
|
+
connectTimer = undefined;
|
|
5927
|
+
if (!response.ok) {
|
|
5928
|
+
const detail = await this.streamError(response, controller.signal);
|
|
5929
|
+
if (detail.code === 'event_cursor_expired') {
|
|
5930
|
+
throw new EventCursorExpiredError();
|
|
5931
|
+
}
|
|
5932
|
+
throw new Error(`WeChat event stream returned HTTP ${response.status}` +
|
|
5933
|
+
`${detail.message === undefined ? '.' : `: ${detail.message}`}`);
|
|
5934
|
+
}
|
|
5935
|
+
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
|
5936
|
+
if (response.body === null || !contentType.includes('text/event-stream')) {
|
|
5937
|
+
throw new Error('Unexpected WeChat event stream response.');
|
|
5938
|
+
}
|
|
5939
|
+
this.lastStreamConnectedAt = new Date().toISOString();
|
|
5940
|
+
this.lastStreamActivityAt = this.lastStreamConnectedAt;
|
|
5941
|
+
// A reconnect can miss updates while detached. Read each pending operation once;
|
|
5942
|
+
// this is fire-and-forget so frame dispatch never waits on a status read.
|
|
5943
|
+
this.reconcilePending();
|
|
5944
|
+
const reader = response.body.getReader();
|
|
5945
|
+
const parser = new SseParser();
|
|
5946
|
+
const decoder = new TextDecoder();
|
|
5947
|
+
const resetIdle = () => {
|
|
5948
|
+
if (idleTimer !== undefined) {
|
|
5949
|
+
clearTimeout(idleTimer);
|
|
5950
|
+
}
|
|
5951
|
+
idleTimer = setTimeout(() => expire('idle wait timed out'), this.timeout);
|
|
5952
|
+
};
|
|
5953
|
+
resetIdle();
|
|
5954
|
+
try {
|
|
5955
|
+
while (this.handler !== undefined) {
|
|
5956
|
+
const chunk = await reader.read();
|
|
5957
|
+
if (chunk.done) {
|
|
5958
|
+
break;
|
|
5959
|
+
}
|
|
5960
|
+
resetIdle();
|
|
5961
|
+
this.noteStreamActivity();
|
|
5962
|
+
for (const frame of parser.push(decoder.decode(chunk.value, { stream: true }))) {
|
|
5963
|
+
// Reading and dispatching may outlast the idle deadline; heartbeat comments
|
|
5964
|
+
// stay queued in the socket and the next read refreshes the deadline.
|
|
5965
|
+
if (idleTimer !== undefined) {
|
|
5966
|
+
clearTimeout(idleTimer);
|
|
5967
|
+
idleTimer = undefined;
|
|
5968
|
+
}
|
|
5969
|
+
await this.commitFrame(frame);
|
|
5970
|
+
if (this.handler === undefined) {
|
|
5971
|
+
return;
|
|
5972
|
+
}
|
|
5973
|
+
resetIdle();
|
|
5974
|
+
}
|
|
5975
|
+
}
|
|
5976
|
+
if (this.handler === undefined) {
|
|
5977
|
+
return;
|
|
5978
|
+
}
|
|
5979
|
+
for (const frame of parser.push(decoder.decode())) {
|
|
5980
|
+
await this.commitFrame(frame);
|
|
5981
|
+
}
|
|
5982
|
+
}
|
|
5983
|
+
finally {
|
|
5984
|
+
// Close the connection when dispatch or protocol handling fails so the
|
|
5985
|
+
// reconnect can replay the uncheckpointed event from the prior cursor.
|
|
5986
|
+
await reader.cancel().catch(() => { });
|
|
5987
|
+
reader.releaseLock();
|
|
5988
|
+
}
|
|
5989
|
+
if (reason !== undefined) {
|
|
5990
|
+
throw new Error(`WeChat event stream ${reason}.`);
|
|
5991
|
+
}
|
|
5992
|
+
}
|
|
5993
|
+
catch (error) {
|
|
5994
|
+
if (reason !== undefined && this.handler !== undefined) {
|
|
5995
|
+
throw new Error(`WeChat event stream ${reason}.`);
|
|
5996
|
+
}
|
|
5997
|
+
throw error;
|
|
5998
|
+
}
|
|
5999
|
+
finally {
|
|
6000
|
+
clearTimeout(connectTimer);
|
|
6001
|
+
clearTimeout(idleTimer);
|
|
6002
|
+
this.operations.delete(controller);
|
|
6003
|
+
}
|
|
6004
|
+
}
|
|
6005
|
+
/** Handles one complete SSE frame, then checkpoints its cursor. */
|
|
6006
|
+
async commitFrame(frame) {
|
|
6007
|
+
if (frame.event !== undefined && frame.event !== 'message') {
|
|
6008
|
+
if (frame.event !== 'error') {
|
|
6009
|
+
// Named events other than errors carry no messages and are not checkpoints.
|
|
6010
|
+
return;
|
|
6011
|
+
}
|
|
6012
|
+
let code;
|
|
6013
|
+
try {
|
|
6014
|
+
const value = JSON.parse(frame.data);
|
|
6015
|
+
if ((0, type_guards_1.isRecord)(value) && (0, type_guards_1.isRecord)(value.error) && (0, type_guards_1.isString)(value.error.code)) {
|
|
6016
|
+
code = value.error.code;
|
|
6017
|
+
}
|
|
6018
|
+
}
|
|
6019
|
+
catch {
|
|
6020
|
+
// An unreadable error frame is still a failed stream.
|
|
6021
|
+
}
|
|
6022
|
+
if (code === 'event_cursor_expired') {
|
|
6023
|
+
throw new EventCursorExpiredError();
|
|
6024
|
+
}
|
|
6025
|
+
throw new Error(`WeChat event stream error${code === undefined ? '' : `: ${code}`}.`);
|
|
6026
|
+
}
|
|
6027
|
+
const cursor = parseEventCursor(frame.id, 'event cursor');
|
|
6028
|
+
let value;
|
|
6029
|
+
try {
|
|
6030
|
+
value = JSON.parse(frame.data);
|
|
6031
|
+
}
|
|
6032
|
+
catch {
|
|
6033
|
+
throw new Error('Unexpected WeChat event payload.');
|
|
6034
|
+
}
|
|
6035
|
+
if (!(0, type_guards_1.isRecord)(value) || !(0, type_guards_1.isString)(value.kind) || !value.kind ||
|
|
6036
|
+
!(0, type_guards_1.isRecord)(value.data) || parseEventCursor(value.cursor, 'event cursor') !== cursor) {
|
|
6037
|
+
throw new Error('Unexpected WeChat event payload.');
|
|
6038
|
+
}
|
|
6039
|
+
// A reconnect replays from the prior checkpoint; already-covered cursors are
|
|
6040
|
+
// skipped and the checkpoint never moves backwards.
|
|
6041
|
+
if (cursorCovered(cursor, this.after)) {
|
|
6042
|
+
return;
|
|
6043
|
+
}
|
|
6044
|
+
const origin = this.identity;
|
|
6045
|
+
await this.handleEvent({ cursor, kind: value.kind, data: value.data });
|
|
6046
|
+
// A switch, re-login, or shutdown while the event was handled invalidates this
|
|
6047
|
+
// reader: its cursor belongs to the previous login, and any frame still queued
|
|
6048
|
+
// behind it may describe the previous account's conversation. Drop the connection
|
|
6049
|
+
// so the loop tail-bootstraps the new login instead of checkpointing stale state.
|
|
6050
|
+
this.requireIdentity(origin);
|
|
6051
|
+
if (this.after === undefined) {
|
|
6052
|
+
throw new AccountChangedError();
|
|
6053
|
+
}
|
|
6054
|
+
this.after = cursor;
|
|
6055
|
+
// Only a successfully handled event proves the receive path is healthy.
|
|
6056
|
+
this.failures = 0;
|
|
6057
|
+
}
|
|
6058
|
+
/** Applies one validated event. Unknown or unrelated kinds are checkpointed and ignored. */
|
|
6059
|
+
async handleEvent(event) {
|
|
6060
|
+
if (event.kind === 'message.committed') {
|
|
6061
|
+
await this.handleCommittedMessage(event);
|
|
6062
|
+
}
|
|
6063
|
+
else if (event.kind === 'account.updated') {
|
|
6064
|
+
this.adoptAccount(await this.readAccount());
|
|
6065
|
+
}
|
|
6066
|
+
else if (event.kind === 'send.updated') {
|
|
6067
|
+
this.handleSendUpdated(event.data);
|
|
6068
|
+
}
|
|
6069
|
+
else if (event.kind === 'job.updated') {
|
|
6070
|
+
this.handleJobUpdated(event.data);
|
|
6071
|
+
}
|
|
6072
|
+
}
|
|
6073
|
+
/**
|
|
6074
|
+
* Confirms a text waiter from `send.updated`. Media jobs also publish this kind with a
|
|
6075
|
+
* MediaJob payload, which carries `kind`/job fields and no text `destination`, so it can
|
|
6076
|
+
* never settle a text waiter even if an ID collides.
|
|
6077
|
+
*/
|
|
6078
|
+
handleSendUpdated(data) {
|
|
6079
|
+
const id = data.id;
|
|
6080
|
+
if (!(0, type_guards_1.isString)(id) || !id || data.kind !== undefined) {
|
|
6081
|
+
return;
|
|
6082
|
+
}
|
|
6083
|
+
const pending = this.pending.get(`send\u0000${id}`);
|
|
6084
|
+
if (pending === undefined || pending.family !== 'send' ||
|
|
6085
|
+
data.accountId !== pending.account || data.destination !== pending.threadId ||
|
|
6086
|
+
this.account !== pending.account || this.selfUsername !== pending.username ||
|
|
6087
|
+
!(0, type_guards_1.isString)(data.state) || !SEND_STATES.has(data.state)) {
|
|
6088
|
+
return;
|
|
6089
|
+
}
|
|
6090
|
+
this.applyOutcome(pending, data.state, (0, type_guards_1.isString)(data.errorCode) ? data.errorCode : undefined);
|
|
6091
|
+
}
|
|
6092
|
+
/** Confirms a media waiter from `job.updated`; other job kinds, threads, and IDs are ignored. */
|
|
6093
|
+
handleJobUpdated(data) {
|
|
6094
|
+
const id = data.id;
|
|
6095
|
+
if (!(0, type_guards_1.isString)(id) || !id) {
|
|
6096
|
+
return;
|
|
6097
|
+
}
|
|
6098
|
+
const pending = this.pending.get(`job\u0000${id}`);
|
|
6099
|
+
if (pending === undefined || pending.family !== 'job' ||
|
|
6100
|
+
data.accountId !== pending.account || data.kind !== 'send' ||
|
|
6101
|
+
data.threadId !== pending.threadId ||
|
|
6102
|
+
this.account !== pending.account || this.selfUsername !== pending.username ||
|
|
6103
|
+
!(0, type_guards_1.isString)(data.state) || !JOB_STATES.has(data.state)) {
|
|
6104
|
+
return;
|
|
6105
|
+
}
|
|
6106
|
+
this.applyOutcome(pending, data.state, (0, type_guards_1.isString)(data.reason) ? data.reason : undefined);
|
|
6107
|
+
}
|
|
6108
|
+
/** Reads one committed message, filters it, and delivers it as its own live turn. */
|
|
6109
|
+
async handleCommittedMessage(event) {
|
|
6110
|
+
const messageId = event.data.messageId;
|
|
6111
|
+
const threadId = event.data.threadId;
|
|
6112
|
+
if (!(0, type_guards_1.isString)(messageId) || !messageId ||
|
|
6113
|
+
!(0, type_guards_1.isString)(threadId) || !threadId) {
|
|
6114
|
+
throw new Error('Unexpected WeChat message event.');
|
|
6115
|
+
}
|
|
6116
|
+
// Filter before any message read so ignored threads cost no requests.
|
|
6117
|
+
if (this.options.chatIds !== undefined && !this.options.chatIds.includes(threadId)) {
|
|
6118
|
+
return;
|
|
6119
|
+
}
|
|
6120
|
+
if (this.seenMessage(threadId, messageId)) {
|
|
6121
|
+
return;
|
|
6122
|
+
}
|
|
6123
|
+
const account = this.accountId;
|
|
6124
|
+
let value;
|
|
6125
|
+
try {
|
|
6126
|
+
value = await this.request(`${this.accountPath}/messages/${encodeURIComponent(messageId)}`);
|
|
6127
|
+
}
|
|
6128
|
+
catch (error) {
|
|
6129
|
+
if (error instanceof WechatRequestError && (error.status === 404 || error.status === 410)) {
|
|
6130
|
+
// The row was deleted before this event could be read. That is terminal for this
|
|
6131
|
+
// event only: remember its ID so the checkpoint advances and later events are not
|
|
6132
|
+
// stalled behind it until the daemon's retention window closes.
|
|
6133
|
+
this.rememberId(threadId, messageId);
|
|
6134
|
+
this.lastEventAt = new Date().toISOString();
|
|
6135
|
+
console.warn(`[wechat] skipping message ${messageId} in ${threadId}: ` +
|
|
6136
|
+
`the daemon no longer has the row (HTTP ${error.status}).`);
|
|
6137
|
+
return;
|
|
6138
|
+
}
|
|
6139
|
+
// Transient and authorization failures stay replayable from the same checkpoint.
|
|
6140
|
+
throw error;
|
|
6141
|
+
}
|
|
6142
|
+
if (this.handler === undefined) {
|
|
6143
|
+
return;
|
|
6144
|
+
}
|
|
6145
|
+
if (account !== this.account) {
|
|
6146
|
+
throw new AccountChangedError();
|
|
6147
|
+
}
|
|
6148
|
+
const message = parseMessage(value, account, threadId);
|
|
6149
|
+
if (message.id !== messageId) {
|
|
6150
|
+
throw new Error('Unexpected WeChat message event identity.');
|
|
6151
|
+
}
|
|
6152
|
+
const time = messageTime(message);
|
|
6153
|
+
// A live event never consults a per-thread watermark, so distinct IDs with equal or
|
|
6154
|
+
// out-of-order source times are not dropped. The startup cutoff still applies, and an
|
|
6155
|
+
// undated row cannot be ordered against it, so it is skipped.
|
|
6156
|
+
if (time !== undefined && time >= this.startedAt && this.eligible(message)) {
|
|
6157
|
+
await this.deliverMessage(threadId, message);
|
|
6158
|
+
}
|
|
6159
|
+
// Ineligible or pre-startup source messages are intentional skips, but remembering
|
|
6160
|
+
// their IDs prevents a replay from reading or delivering them again.
|
|
6161
|
+
this.rememberId(threadId, messageId);
|
|
6162
|
+
this.lastEventAt = new Date().toISOString();
|
|
6163
|
+
}
|
|
6164
|
+
/** Only text, image, and voice messages from a known direction are deliverable. */
|
|
6165
|
+
eligible(message) {
|
|
6166
|
+
const supported = (message.type === 1 && (message.body ?? '') !== '') ||
|
|
6167
|
+
message.type === 3 || message.type === 34;
|
|
6168
|
+
if (!supported) {
|
|
6169
|
+
return false;
|
|
6170
|
+
}
|
|
6171
|
+
return message.direction === 'outgoing'
|
|
6172
|
+
? this.selfUsername !== undefined
|
|
6173
|
+
: message.direction === 'incoming' && message.senderId !== null;
|
|
6174
|
+
}
|
|
6175
|
+
/** Reads a small error body under its own deadline, bounded by `maxBytes`. */
|
|
6176
|
+
async streamError(response, signal) {
|
|
6177
|
+
const text = await readBoundedText(response, limit_1.MAX_SSE_ERROR_BYTES, this.timeout, signal);
|
|
6178
|
+
if (text === undefined) {
|
|
6179
|
+
return {};
|
|
6180
|
+
}
|
|
6181
|
+
try {
|
|
6182
|
+
const value = JSON.parse(text);
|
|
6183
|
+
if (!(0, type_guards_1.isRecord)(value) || !(0, type_guards_1.isRecord)(value.error)) {
|
|
6184
|
+
return {};
|
|
6185
|
+
}
|
|
6186
|
+
return {
|
|
6187
|
+
...((0, type_guards_1.isString)(value.error.code) ? { code: value.error.code } : {}),
|
|
6188
|
+
...((0, type_guards_1.isString)(value.error.message) ? { message: value.error.message } : {})
|
|
6189
|
+
};
|
|
6190
|
+
}
|
|
6191
|
+
catch {
|
|
6192
|
+
return {};
|
|
6193
|
+
}
|
|
6194
|
+
}
|
|
6195
|
+
/**
|
|
6196
|
+
* Marks transport activity. Backoff is not reset here: a stream that connects and
|
|
6197
|
+
* receives keepalives but keeps failing to handle an event must back off further.
|
|
6198
|
+
*/
|
|
6199
|
+
noteStreamActivity() {
|
|
6200
|
+
this.lastStreamActivityAt = new Date().toISOString();
|
|
6201
|
+
}
|
|
6202
|
+
recordFailure(error) {
|
|
6203
|
+
this.failures += 1;
|
|
6204
|
+
this.lastError = error instanceof Error ? error.message : 'Unknown WeChat stream error';
|
|
6205
|
+
this.lastErrorAt = new Date().toISOString();
|
|
6206
|
+
console.error('[wechat] event stream failed:', this.lastError);
|
|
6207
|
+
}
|
|
6208
|
+
/** Sleeps with capped exponential backoff; only stop() may interrupt the wait. */
|
|
6209
|
+
async backoff() {
|
|
6210
|
+
const exponent = Math.min(Math.max(0, this.failures - 1), limit_1.WECHAT_MAX_BACKOFF_EXPONENT);
|
|
6211
|
+
const wait = Math.min(limit_1.MAX_TIMER_MS, this.reconnect * 2 ** exponent);
|
|
6212
|
+
try {
|
|
6213
|
+
await (0, promises_1.setTimeout)(wait, undefined, { signal: this.streamAbort.signal });
|
|
6214
|
+
}
|
|
6215
|
+
catch (error) {
|
|
6216
|
+
if (this.streamAbort.signal.aborted) {
|
|
6217
|
+
return;
|
|
6218
|
+
}
|
|
6219
|
+
// An unexpected timer failure must end the loop rather than spin reconnecting.
|
|
6220
|
+
throw error;
|
|
6221
|
+
}
|
|
6222
|
+
}
|
|
6223
|
+
/** Delivers one live message as its own turn, preserving the source for metadata consumers. */
|
|
6224
|
+
async deliverMessage(threadId, message) {
|
|
6225
|
+
const self = message.direction === 'outgoing';
|
|
6226
|
+
const sender = self ? this.selfUsername : message.senderId;
|
|
6227
|
+
if (sender === undefined || sender === null) {
|
|
6228
|
+
return false;
|
|
6229
|
+
}
|
|
6230
|
+
const account = this.accountId;
|
|
6231
|
+
const username = this.selfUsername;
|
|
6232
|
+
// Display names are context only: a failed lookup must not drop the message, and a
|
|
6233
|
+
// name never replaces a sender ID. In a chatroom the account's own nickname is useful
|
|
6234
|
+
// context too, so self messages resolve under the same precedence.
|
|
6235
|
+
const senderName = self && !threadId.endsWith('@chatroom')
|
|
6236
|
+
? undefined
|
|
6237
|
+
: await this.displayName(threadId, sender);
|
|
6238
|
+
if (this.handler === undefined) {
|
|
6239
|
+
return false;
|
|
6240
|
+
}
|
|
6241
|
+
const text = messageText(message);
|
|
6242
|
+
this.handler({
|
|
6243
|
+
text,
|
|
6244
|
+
senderId: sender,
|
|
6245
|
+
senderName,
|
|
6246
|
+
isSelf: self,
|
|
6247
|
+
timestamp: message.createdAt ?? undefined,
|
|
6248
|
+
channel: threadId,
|
|
6249
|
+
messages: [{
|
|
6250
|
+
id: `message:${message.id}`,
|
|
6251
|
+
text,
|
|
6252
|
+
senderId: sender,
|
|
6253
|
+
senderName,
|
|
6254
|
+
isSelf: self,
|
|
6255
|
+
timestamp: message.createdAt ?? undefined,
|
|
6256
|
+
images: message.type === 3 ? [{
|
|
6257
|
+
id: `image:${message.id}`,
|
|
6258
|
+
load: (signal) => this.loadImage(message.id, account, username, signal)
|
|
6259
|
+
}] : undefined,
|
|
6260
|
+
audio: message.type === 34 ? {
|
|
6261
|
+
id: `voice:${message.id}`,
|
|
6262
|
+
load: (signal) => this.loadAudio(message.id, account, username, signal)
|
|
6263
|
+
} : undefined
|
|
6264
|
+
}],
|
|
6265
|
+
metadata: {
|
|
6266
|
+
...message,
|
|
6267
|
+
content: text,
|
|
6268
|
+
wechatAccount: account,
|
|
6269
|
+
wechatUsername: this.selfUsername,
|
|
6270
|
+
messageIds: [message.id],
|
|
6271
|
+
firstTimestamp: message.createdAt,
|
|
6272
|
+
lastTimestamp: message.createdAt,
|
|
6273
|
+
messages: [message]
|
|
6274
|
+
}
|
|
6275
|
+
});
|
|
6276
|
+
return true;
|
|
6277
|
+
}
|
|
6278
|
+
/** Remembers one recent message ID; the SSE cursor checkpoint is the primary dedupe. */
|
|
6279
|
+
rememberId(threadId, messageId) {
|
|
6280
|
+
const key = `${threadId}\u0000${messageId}`;
|
|
6281
|
+
if (this.seen.has(key)) {
|
|
6282
|
+
return;
|
|
6283
|
+
}
|
|
6284
|
+
this.seen.add(key);
|
|
6285
|
+
while (this.seen.size > limit_1.WECHAT_RECENT_MESSAGE_LIMIT) {
|
|
6286
|
+
const oldest = this.seen.values().next().value;
|
|
6287
|
+
if (oldest === undefined) {
|
|
6288
|
+
break;
|
|
6289
|
+
}
|
|
6290
|
+
this.seen.delete(oldest);
|
|
6291
|
+
}
|
|
6292
|
+
}
|
|
6293
|
+
/** Whether a recent live message was already delivered or intentionally skipped. */
|
|
6294
|
+
seenMessage(threadId, messageId) {
|
|
6295
|
+
return this.seen.has(`${threadId}\u0000${messageId}`);
|
|
6296
|
+
}
|
|
6297
|
+
/**
|
|
6298
|
+
* Cached sender name: the sender's own chatroom nickname, else the contact label.
|
|
6299
|
+
*
|
|
6300
|
+
* A native member label already resolves friend remark over WeChat nickname, so a
|
|
6301
|
+
* chatroom nickname is the only extra level. Absent, unconfirmed, blank, or failed
|
|
6302
|
+
* member reads fall through to the contact route; no name ever drops a message.
|
|
6303
|
+
*/
|
|
6304
|
+
async displayName(threadId, senderId) {
|
|
6305
|
+
const key = `${threadId}\u0000${senderId}`;
|
|
6306
|
+
const cached = this.names.get(key);
|
|
6307
|
+
if (cached !== undefined && cached.expires > Date.now()) {
|
|
6308
|
+
return cached.name;
|
|
6309
|
+
}
|
|
6310
|
+
let name = threadId.endsWith('@chatroom')
|
|
6311
|
+
? await this.lookupName('group member name', () => this.groupMemberName(threadId, senderId), senderId)
|
|
6312
|
+
: undefined;
|
|
6313
|
+
// The chatroom nickname outranks every other name, so the contact is a fallback.
|
|
6314
|
+
name ??= await this.lookupName('contact name', () => this.contactName(senderId), senderId);
|
|
6315
|
+
if (this.names.size >= limit_1.WECHAT_NAME_CACHE_SIZE && !this.names.has(key)) {
|
|
6316
|
+
const oldest = this.names.keys().next().value;
|
|
6317
|
+
if (oldest !== undefined) {
|
|
6318
|
+
this.names.delete(oldest);
|
|
6319
|
+
}
|
|
6320
|
+
}
|
|
6321
|
+
this.names.set(key, { ...(name === undefined ? {} : { name }),
|
|
6322
|
+
expires: Date.now() + (name === undefined ? limit_1.WECHAT_NAME_MISS_TTL_MS : limit_1.WECHAT_NAME_TTL_MS) });
|
|
6323
|
+
return name;
|
|
6324
|
+
}
|
|
6325
|
+
/** Runs one cached name read; a failure is logged and yields no name. */
|
|
6326
|
+
async lookupName(label, read, senderId) {
|
|
6327
|
+
try {
|
|
6328
|
+
return await read();
|
|
6329
|
+
}
|
|
6330
|
+
catch (error) {
|
|
6331
|
+
console.warn(`[wechat] ${label} lookup failed for ${senderId}:`, error instanceof Error ? error.message : error);
|
|
6332
|
+
return undefined;
|
|
5407
6333
|
}
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
let
|
|
6334
|
+
}
|
|
6335
|
+
/** Reads a chatroom member's own nickname and its upstream-resolved label. */
|
|
6336
|
+
async groupMemberName(groupId, username) {
|
|
6337
|
+
let result;
|
|
5412
6338
|
try {
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
form.set('chatId', chatId);
|
|
5416
|
-
form.set('audio', new Blob([new Uint8Array(audio.data)], { type: audio.mimeType }), audio.filename);
|
|
5417
|
-
let job = await this.request('/api/messages/voice', form, controller.signal, undefined, { 'Idempotency-Key': (0, node_crypto_1.randomUUID)() });
|
|
5418
|
-
while (true) {
|
|
5419
|
-
if (!isRecord(job) || typeof job.jobId !== 'string' || !job.jobId ||
|
|
5420
|
-
typeof job.status !== 'string' || job.chatId !== chatId || job.accountId !== this.account ||
|
|
5421
|
-
jobId !== undefined && jobId !== job.jobId) {
|
|
5422
|
-
throw new Error('Unexpected WeChat voice job response.');
|
|
5423
|
-
}
|
|
5424
|
-
jobId = job.jobId;
|
|
5425
|
-
this.requireActiveAccount(message);
|
|
5426
|
-
controller.signal.throwIfAborted();
|
|
5427
|
-
terminal = ['completed', 'failed', 'needs_review', 'cancelled'].includes(job.status);
|
|
5428
|
-
if (job.status === 'completed') {
|
|
5429
|
-
return;
|
|
5430
|
-
}
|
|
5431
|
-
if (terminal) {
|
|
5432
|
-
throw new Error(`WeChat voice job ${jobId} ${job.status}${typeof job.error === 'string' ? `: ${job.error}` : ''}. Delivery will not be retried.`);
|
|
5433
|
-
}
|
|
5434
|
-
await (0, promises_1.setTimeout)(limit_1.WECHAT_VOICE_JOB_POLL_MS, undefined, { signal: controller.signal });
|
|
5435
|
-
this.requireActiveAccount(message);
|
|
5436
|
-
job = await this.request(`/api/messages/voice/${encodeURIComponent(jobId)}`, undefined, controller.signal);
|
|
5437
|
-
}
|
|
6339
|
+
result = await this.request(`${this.accountPath}/groups/${encodeURIComponent(groupId)}` +
|
|
6340
|
+
`/members/${encodeURIComponent(username)}`);
|
|
5438
6341
|
}
|
|
5439
6342
|
catch (error) {
|
|
5440
|
-
if (
|
|
5441
|
-
|
|
5442
|
-
await this.request(`/api/messages/voice/${encodeURIComponent(jobId)}/cancel`, {}, AbortSignal.timeout(limit_1.WECHAT_VOICE_CANCEL_TIMEOUT_MS));
|
|
5443
|
-
}
|
|
5444
|
-
catch (cancelError) {
|
|
5445
|
-
console.warn(`[wechat] unable to cancel voice job ${jobId}:`, cancelError instanceof Error ? cancelError.message : cancelError);
|
|
5446
|
-
}
|
|
5447
|
-
}
|
|
5448
|
-
if (controller.signal.aborted) {
|
|
5449
|
-
throw new Error(`WeChat voice job cancelled or exceeded ${limit_1.WECHAT_VOICE_JOB_TIMEOUT_MS} ms; delivery may be unconfirmed and will not be retried.`);
|
|
6343
|
+
if (error instanceof WechatRequestError && isTerminalMediaStatus(error.status)) {
|
|
6344
|
+
return undefined;
|
|
5450
6345
|
}
|
|
5451
6346
|
throw error;
|
|
5452
6347
|
}
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
this.operations.delete(controller);
|
|
6348
|
+
if (!(0, type_guards_1.isRecord)(result) || !(0, type_guards_1.isString)(result.state) ||
|
|
6349
|
+
!(result.member === null || (0, type_guards_1.isRecord)(result.member))) {
|
|
6350
|
+
throw new Error('Unexpected WeChat group member response.');
|
|
5457
6351
|
}
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
6352
|
+
const member = result.member;
|
|
6353
|
+
if (result.state !== 'present' || !(0, type_guards_1.isRecord)(member)) {
|
|
6354
|
+
return undefined;
|
|
6355
|
+
}
|
|
6356
|
+
// `roomDisplayName` is this chatroom's nickname for the member. `displayName` is the
|
|
6357
|
+
// label upstream already resolved from the friend remark, then the WeChat nickname.
|
|
6358
|
+
if (![member.roomDisplayName, member.displayName].every((value) => value === undefined || value === null || (0, type_guards_1.isString)(value))) {
|
|
6359
|
+
throw new Error('Unexpected WeChat group member fields.');
|
|
5465
6360
|
}
|
|
6361
|
+
return trimmedName(member.roomDisplayName) ?? trimmedName(member.displayName);
|
|
5466
6362
|
}
|
|
5467
|
-
/**
|
|
5468
|
-
async
|
|
5469
|
-
this.requireAccount(account);
|
|
6363
|
+
/** Reads a contact's friend remark and label, falling back to the raw WeChat nickname. */
|
|
6364
|
+
async contactName(username) {
|
|
5470
6365
|
let result;
|
|
5471
6366
|
try {
|
|
5472
|
-
result = await this.request(
|
|
6367
|
+
result = await this.request(`${this.accountPath}/contacts/${encodeURIComponent(username)}`);
|
|
5473
6368
|
}
|
|
5474
6369
|
catch (error) {
|
|
5475
|
-
if (error instanceof
|
|
5476
|
-
|
|
5477
|
-
}
|
|
5478
|
-
if (error instanceof WechatRequestError && error.status >= 400 && error.status < 500 &&
|
|
5479
|
-
error.status !== 408 && error.status !== 429) {
|
|
5480
|
-
throw new images_1.ImageUnavailableError('WeChat image media is unavailable.');
|
|
6370
|
+
if (error instanceof WechatRequestError && isTerminalMediaStatus(error.status)) {
|
|
6371
|
+
return undefined;
|
|
5481
6372
|
}
|
|
5482
6373
|
throw error;
|
|
5483
6374
|
}
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
throw new Error('Unexpected WeChat image media response.');
|
|
6375
|
+
if (!(0, type_guards_1.isRecord)(result) || ![result.remark, result.displayName, result.nickname].every((value) => value === undefined || value === null || (0, type_guards_1.isString)(value))) {
|
|
6376
|
+
throw new Error('Unexpected WeChat contact response.');
|
|
5487
6377
|
}
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
6378
|
+
// The label is usually the friend remark, but upstream tests its fields for emptiness
|
|
6379
|
+
// without trimming, so a whitespace-only remark can mask a real nickname. Falling back
|
|
6380
|
+
// to the raw nickname keeps the WeChat-wide name available for those contacts.
|
|
6381
|
+
return trimmedName(result.remark) ?? trimmedName(result.displayName) ??
|
|
6382
|
+
trimmedName(result.nickname);
|
|
6383
|
+
}
|
|
6384
|
+
/** Account-scoped route built from a captured account ID, never a mutable current one. */
|
|
6385
|
+
pathFor(account) {
|
|
6386
|
+
return `/v1/accounts/${encodeURIComponent(account)}`;
|
|
6387
|
+
}
|
|
6388
|
+
/** Requests image bytes only when the server processes its conversation. */
|
|
6389
|
+
async loadImage(messageId, account, username, signal) {
|
|
6390
|
+
this.requireAccount(account, username);
|
|
6391
|
+
const origin = { account: account, path: this.pathFor(account) };
|
|
6392
|
+
const selection = await this.selectMedia(origin, messageId, 'image', signal);
|
|
6393
|
+
if (selection.status !== 'ready') {
|
|
6394
|
+
return selection;
|
|
5496
6395
|
}
|
|
6396
|
+
this.requireAccount(account, username);
|
|
5497
6397
|
try {
|
|
5498
|
-
|
|
6398
|
+
const content = await this.mediaContent(origin.path, selection.asset.id, limit_1.MAX_IMAGE_BYTES, signal);
|
|
6399
|
+
this.requireAccount(account, username);
|
|
6400
|
+
try {
|
|
6401
|
+
return { status: 'ready', dataUrl: (0, images_1.imageDataUrl)(content.bytes.toString('base64')) };
|
|
6402
|
+
}
|
|
6403
|
+
catch (error) {
|
|
6404
|
+
throw new images_1.ImageUnavailableError(error instanceof Error ? error.message : 'Invalid WeChat image data.');
|
|
6405
|
+
}
|
|
5499
6406
|
}
|
|
5500
|
-
catch {
|
|
5501
|
-
|
|
6407
|
+
catch (error) {
|
|
6408
|
+
if (error instanceof media_1.MediaUnavailableError) {
|
|
6409
|
+
throw error instanceof images_1.ImageUnavailableError ? error : new images_1.ImageUnavailableError(error.message);
|
|
6410
|
+
}
|
|
6411
|
+
if (error instanceof WechatRequestError && isTerminalMediaStatus(error.status)) {
|
|
6412
|
+
throw new images_1.ImageUnavailableError('WeChat image media is unavailable.');
|
|
6413
|
+
}
|
|
6414
|
+
throw error;
|
|
5502
6415
|
}
|
|
5503
6416
|
}
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
6417
|
+
/** Requests voice bytes lazily and converts the daemon's WAV copy for STT. */
|
|
6418
|
+
async loadAudio(messageId, account, username, signal) {
|
|
6419
|
+
this.requireAccount(account, username);
|
|
6420
|
+
const origin = { account: account, path: this.pathFor(account) };
|
|
5507
6421
|
try {
|
|
5508
|
-
|
|
6422
|
+
const selection = await this.selectMedia(origin, messageId, 'voice', signal);
|
|
6423
|
+
if (selection.status !== 'ready') {
|
|
6424
|
+
return selection;
|
|
6425
|
+
}
|
|
6426
|
+
this.requireAccount(account, username);
|
|
6427
|
+
const content = await this.mediaContent(origin.path, selection.asset.id, limit_1.MAX_AUDIO_BYTES, signal);
|
|
6428
|
+
this.requireAccount(account, username);
|
|
6429
|
+
const mimeType = (content.contentType ?? selection.asset.contentType ?? '').toLowerCase();
|
|
6430
|
+
const format = (0, audio_1.audioExtension)(mimeType);
|
|
6431
|
+
return { status: 'ready', audio: (0, audio_1.audioFile)(content.bytes.toString('base64'), format, messageId) };
|
|
5509
6432
|
}
|
|
5510
6433
|
catch (error) {
|
|
5511
|
-
if (error instanceof media_1.MediaUnavailableError
|
|
5512
|
-
|
|
6434
|
+
if (error instanceof media_1.MediaUnavailableError) {
|
|
6435
|
+
return { status: 'unavailable', reason: error.message };
|
|
6436
|
+
}
|
|
6437
|
+
if (error instanceof WechatRequestError && isTerminalMediaStatus(error.status)) {
|
|
5513
6438
|
return { status: 'unavailable', reason: error.message };
|
|
5514
6439
|
}
|
|
5515
6440
|
throw error;
|
|
5516
6441
|
}
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
6442
|
+
}
|
|
6443
|
+
/** Lists attachment copies, fetching the best cached one or starting one download. */
|
|
6444
|
+
async selectMedia(origin, messageId, kind, signal) {
|
|
6445
|
+
let assets;
|
|
6446
|
+
try {
|
|
6447
|
+
const page = await this.request(`${origin.path}/messages/${encodeURIComponent(messageId)}/media`, undefined, signal);
|
|
6448
|
+
if (!(0, type_guards_1.isRecord)(page) || !Array.isArray(page.items)) {
|
|
6449
|
+
throw new Error('Unexpected WeChat message media response.');
|
|
6450
|
+
}
|
|
6451
|
+
// Ownership is checked against the captured account and this exact message.
|
|
6452
|
+
assets = page.items.map((item) => parseMediaAsset(item, origin.account, messageId))
|
|
6453
|
+
.filter((asset) => asset !== undefined);
|
|
5520
6454
|
}
|
|
5521
|
-
|
|
5522
|
-
if (
|
|
5523
|
-
return { status: '
|
|
6455
|
+
catch (error) {
|
|
6456
|
+
if (error instanceof WechatRequestError && isTerminalMediaStatus(error.status)) {
|
|
6457
|
+
return { status: 'unavailable' };
|
|
5524
6458
|
}
|
|
5525
|
-
|
|
6459
|
+
throw error;
|
|
5526
6460
|
}
|
|
5527
|
-
|
|
5528
|
-
|
|
6461
|
+
// Voice exposes `normal` (decoded WAV) and `encoded` (raw SILK) copies. Only the decoded
|
|
6462
|
+
// copy can feed STT here, so a raw copy is never selected even when it is already cached.
|
|
6463
|
+
const matching = assets.filter((asset) => asset.kind === kind &&
|
|
6464
|
+
(kind !== 'voice' || (asset.actualVariant ?? asset.variant) !== 'encoded'));
|
|
6465
|
+
const available = matching.filter((asset) => asset.state === 'available')
|
|
6466
|
+
.sort((left, right) => variantRank(kind, left) - variantRank(kind, right));
|
|
6467
|
+
if (available.length > 0) {
|
|
6468
|
+
return { status: 'ready', asset: available[0] };
|
|
6469
|
+
}
|
|
6470
|
+
const pending = matching.filter((asset) => asset.state === 'pending' && asset.retryable)
|
|
6471
|
+
.sort((left, right) => variantRank(kind, left) - variantRank(kind, right));
|
|
6472
|
+
if (pending.length === 0) {
|
|
6473
|
+
return { status: 'unavailable' };
|
|
5529
6474
|
}
|
|
6475
|
+
// MediaTransferRequest accepts only messageId, assetId, and quality; use the exact copy.
|
|
5530
6476
|
try {
|
|
5531
|
-
|
|
6477
|
+
await this.request(`${origin.path}/messages/${encodeURIComponent(messageId)}/media-transfers`, { messageId, assetId: pending[0].id }, signal);
|
|
5532
6478
|
}
|
|
5533
6479
|
catch (error) {
|
|
5534
|
-
|
|
6480
|
+
if (error instanceof WechatRequestError && isTerminalMediaStatus(error.status)) {
|
|
6481
|
+
return { status: 'unavailable' };
|
|
6482
|
+
}
|
|
6483
|
+
throw error;
|
|
5535
6484
|
}
|
|
6485
|
+
return { status: 'pending' };
|
|
5536
6486
|
}
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
lastSuccessfulPollAt: this.lastSuccessfulPollAt,
|
|
5541
|
-
consecutivePollFailures: this.failures,
|
|
5542
|
-
failedChats: this.chatFailures.size,
|
|
5543
|
-
lastPollError: this.lastPollError,
|
|
5544
|
-
lastPollErrorAt: this.lastPollErrorAt
|
|
5545
|
-
};
|
|
6487
|
+
/** Streams cached bytes from the account-scoped route, never an advertised URL. */
|
|
6488
|
+
async mediaContent(accountPath, assetId, maxBytes, signal) {
|
|
6489
|
+
return this.requestBytes(`${accountPath}/media/${encodeURIComponent(assetId)}/content`, signal, maxBytes);
|
|
5546
6490
|
}
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
6491
|
+
validateImage(image) {
|
|
6492
|
+
const data = Buffer.from(image.data);
|
|
6493
|
+
if (data.byteLength === 0) {
|
|
6494
|
+
throw new Error('WeChat image attachments must not be empty.');
|
|
5550
6495
|
}
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
6496
|
+
const dataUrl = (0, images_1.imageDataUrl)(data.toString('base64'));
|
|
6497
|
+
const mimeType = dataUrl.slice(5, dataUrl.indexOf(';'));
|
|
6498
|
+
if (mimeType !== image.mimeType) {
|
|
6499
|
+
throw new Error('WeChat image MIME type does not match its bytes.');
|
|
6500
|
+
}
|
|
6501
|
+
return { data, filename: image.filename.trim() || 'image.png', mimeType };
|
|
6502
|
+
}
|
|
6503
|
+
validateVoice(audio) {
|
|
6504
|
+
if (audio === undefined) {
|
|
6505
|
+
throw new Error('Voice reply requires synthesized audio.');
|
|
6506
|
+
}
|
|
6507
|
+
if (!audio.data.byteLength || audio.data.byteLength > limit_1.MAX_AUDIO_BYTES) {
|
|
6508
|
+
throw new Error('Voice reply audio is empty or exceeds the audio size limit.');
|
|
6509
|
+
}
|
|
6510
|
+
(0, audio_1.audioExtension)(audio.mimeType);
|
|
6511
|
+
return audio;
|
|
6512
|
+
}
|
|
6513
|
+
/** Stages one file and waits for the daemon to accept the media message. */
|
|
6514
|
+
async sendMedia(message, account, accountPath, destination, kind, media, signal) {
|
|
6515
|
+
await this.runSend(message, `${kind} job`, 'job', destination, async (operationSignal) => {
|
|
6516
|
+
const sourceId = await this.upload(accountPath, media, operationSignal);
|
|
6517
|
+
this.requireActiveAccount(message);
|
|
6518
|
+
operationSignal.throwIfAborted();
|
|
6519
|
+
const result = await this.request(`${accountPath}/sends`, { kind, threadId: destination, sourceId }, operationSignal, { 'Idempotency-Key': (0, node_crypto_1.randomUUID)() });
|
|
6520
|
+
const job = this.parseMediaJob(result, account, destination);
|
|
6521
|
+
return {
|
|
6522
|
+
id: job.id,
|
|
6523
|
+
state: job.state,
|
|
6524
|
+
...(job.reason === undefined ? {} : { detail: job.reason }),
|
|
6525
|
+
read: async (readSignal) => {
|
|
6526
|
+
this.requireActiveAccount(message);
|
|
6527
|
+
const next = this.parseMediaJob(await this.request(`${accountPath}/jobs/${encodeURIComponent(job.id)}`, undefined, readSignal), account, destination);
|
|
6528
|
+
this.requireActiveAccount(message);
|
|
6529
|
+
return { id: next.id, state: next.state, ...(next.reason === undefined ? {} : { detail: next.reason }) };
|
|
6530
|
+
},
|
|
6531
|
+
cancel: async () => {
|
|
6532
|
+
await this.request(`${accountPath}/jobs/${encodeURIComponent(job.id)}/cancel`, {}, AbortSignal.timeout(limit_1.WECHAT_SEND_CANCEL_TIMEOUT_MS));
|
|
5560
6533
|
}
|
|
5561
|
-
}
|
|
5562
|
-
|
|
5563
|
-
});
|
|
5564
|
-
}, delay);
|
|
6534
|
+
};
|
|
6535
|
+
}, signal);
|
|
5565
6536
|
}
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
6537
|
+
/** Sends text and waits for the daemon to accept it; uncertain sends are never repeated. */
|
|
6538
|
+
async sendText(message, account, accountPath, destination, text, signal) {
|
|
6539
|
+
await this.runSend(message, 'send', 'send', destination, async (operationSignal) => {
|
|
6540
|
+
const result = await this.request(`${accountPath}/sends`, { threadId: destination, text }, operationSignal, { 'Idempotency-Key': (0, node_crypto_1.randomUUID)() });
|
|
6541
|
+
const intent = this.parseSendIntent(result, account, destination);
|
|
6542
|
+
return {
|
|
6543
|
+
id: intent.id,
|
|
6544
|
+
state: intent.state,
|
|
6545
|
+
...(intent.errorCode === undefined ? {} : { detail: intent.errorCode }),
|
|
6546
|
+
read: async (readSignal) => {
|
|
6547
|
+
this.requireActiveAccount(message);
|
|
6548
|
+
const next = this.parseSendIntent(await this.request(`${accountPath}/sends/${encodeURIComponent(intent.id)}`, undefined, readSignal), account, destination);
|
|
6549
|
+
this.requireActiveAccount(message);
|
|
6550
|
+
return { id: next.id, state: next.state, ...(next.errorCode === undefined ? {} : { detail: next.errorCode }) };
|
|
6551
|
+
},
|
|
6552
|
+
cancel: async () => {
|
|
6553
|
+
await this.request(`${accountPath}/sends/${encodeURIComponent(intent.id)}/cancel`, {}, AbortSignal.timeout(limit_1.WECHAT_SEND_CANCEL_TIMEOUT_MS));
|
|
6554
|
+
}
|
|
6555
|
+
};
|
|
6556
|
+
}, signal);
|
|
6557
|
+
}
|
|
6558
|
+
/** Uploads bytes with the multipart field name `file` and returns the source ID. */
|
|
6559
|
+
async upload(accountPath, media, signal) {
|
|
6560
|
+
const form = new FormData();
|
|
6561
|
+
form.set('file', new Blob([new Uint8Array(media.data)], { type: media.mimeType }), media.filename);
|
|
6562
|
+
const result = await this.request(`${accountPath}/uploads`, form, signal);
|
|
6563
|
+
if (!(0, type_guards_1.isRecord)(result) || !(0, type_guards_1.isString)(result.id) || !result.id ||
|
|
6564
|
+
!(0, type_guards_1.isString)(result.mediaType) || !(0, type_guards_1.isString)(result.name)) {
|
|
6565
|
+
throw new Error('Unexpected WeChat upload response.');
|
|
6566
|
+
}
|
|
6567
|
+
return result.id;
|
|
6568
|
+
}
|
|
6569
|
+
/** Validates a response against the account that originated the send, not the current one. */
|
|
6570
|
+
parseSendIntent(value, account, destination) {
|
|
6571
|
+
if (!(0, type_guards_1.isRecord)(value) || !(0, type_guards_1.isString)(value.id) || !value.id ||
|
|
6572
|
+
value.accountId !== account || value.destination !== destination ||
|
|
6573
|
+
!(0, type_guards_1.isString)(value.state) || !SEND_STATES.has(value.state) ||
|
|
6574
|
+
!(value.errorCode === null || value.errorCode === undefined || (0, type_guards_1.isString)(value.errorCode))) {
|
|
6575
|
+
throw new Error('Unexpected WeChat send response.');
|
|
5571
6576
|
}
|
|
5572
|
-
return
|
|
6577
|
+
return {
|
|
6578
|
+
id: value.id,
|
|
6579
|
+
state: value.state,
|
|
6580
|
+
...((0, type_guards_1.isString)(value.errorCode) ? { errorCode: value.errorCode } : {})
|
|
6581
|
+
};
|
|
5573
6582
|
}
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
this.cursors.clear();
|
|
5581
|
-
this.chatFailures.clear();
|
|
5582
|
-
}
|
|
5583
|
-
const chats = [];
|
|
5584
|
-
const pageSize = limit_1.WECHAT_CHAT_PAGE_SIZE;
|
|
5585
|
-
for (let offset = 0;; offset += pageSize) {
|
|
5586
|
-
const page = await this.request(`/api/chats?limit=${pageSize}&offset=${offset}`);
|
|
5587
|
-
if (!Array.isArray(page) || !page.every((chat) => isRecord(chat) && typeof chat.id === 'string')) {
|
|
5588
|
-
throw new Error('Unexpected WeChat chats response.');
|
|
5589
|
-
}
|
|
5590
|
-
chats.push(...page);
|
|
5591
|
-
if (page.length < pageSize) {
|
|
5592
|
-
break;
|
|
5593
|
-
}
|
|
6583
|
+
parseMediaJob(value, account, destination) {
|
|
6584
|
+
if (!(0, type_guards_1.isRecord)(value) || !(0, type_guards_1.isString)(value.id) || !value.id ||
|
|
6585
|
+
value.accountId !== account || value.kind !== 'send' || value.threadId !== destination ||
|
|
6586
|
+
!(0, type_guards_1.isString)(value.state) || !JOB_STATES.has(value.state) ||
|
|
6587
|
+
!(value.reason === null || value.reason === undefined || (0, type_guards_1.isString)(value.reason))) {
|
|
6588
|
+
throw new Error('Unexpected WeChat media job response.');
|
|
5594
6589
|
}
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
6590
|
+
return {
|
|
6591
|
+
id: value.id,
|
|
6592
|
+
state: value.state,
|
|
6593
|
+
...((0, type_guards_1.isString)(value.reason) ? { reason: value.reason } : {})
|
|
6594
|
+
};
|
|
6595
|
+
}
|
|
6596
|
+
/**
|
|
6597
|
+
* Submits one outbound operation and waits for its terminal event-stream update. One
|
|
6598
|
+
* status read after registration reconciles an update that raced the POST response;
|
|
6599
|
+
* there is no interval polling. Failure or uncertainty cancels once and never resends.
|
|
6600
|
+
*/
|
|
6601
|
+
async runSend(message, label, family, threadId, submit, signal) {
|
|
6602
|
+
const controller = new AbortController();
|
|
6603
|
+
const cancel = () => controller.abort();
|
|
6604
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
6605
|
+
if (signal?.aborted) {
|
|
6606
|
+
controller.abort();
|
|
5600
6607
|
}
|
|
5601
|
-
|
|
5602
|
-
|
|
6608
|
+
this.operations.add(controller);
|
|
6609
|
+
const timer = setTimeout(cancel, limit_1.WECHAT_SEND_TIMEOUT_MS);
|
|
6610
|
+
let handle;
|
|
6611
|
+
let pending;
|
|
6612
|
+
let terminal = false;
|
|
6613
|
+
try {
|
|
6614
|
+
controller.signal.throwIfAborted();
|
|
6615
|
+
handle = await submit(controller.signal);
|
|
6616
|
+
// A submission response may already be terminal; fence it like any stream update.
|
|
6617
|
+
this.requireActiveAccount(message);
|
|
6618
|
+
controller.signal.throwIfAborted();
|
|
6619
|
+
if (handle.state === 'accepted') {
|
|
5603
6620
|
return;
|
|
5604
6621
|
}
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
}
|
|
5609
|
-
if ((this.chatFailures.get(chatId)?.retryAt ?? 0) > Date.now()) {
|
|
5610
|
-
continue;
|
|
5611
|
-
}
|
|
5612
|
-
const cursor = this.cursors.get(chatId) ?? { timestamp: this.startedAt, ids: new Set() };
|
|
5613
|
-
const signature = JSON.stringify([chat.lastMsgLocalId, chat.lastActivityAt, chat.unreadCount]);
|
|
5614
|
-
if (cursor.signature === signature) {
|
|
5615
|
-
continue;
|
|
6622
|
+
if (TERMINAL_STATES.has(handle.state)) {
|
|
6623
|
+
terminal = true;
|
|
6624
|
+
throw this.sendFailure(label, handle.id, handle.state, handle.detail);
|
|
5616
6625
|
}
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
throw new Error('WeChat returned no messages for an active chat; database data may not be ready.');
|
|
5623
|
-
}
|
|
5624
|
-
cursor.signature = hadMessages ? signature : undefined;
|
|
5625
|
-
this.cursors.set(chatId, cursor);
|
|
5626
|
-
this.chatFailures.delete(chatId);
|
|
6626
|
+
pending = this.registerPending(message, label, family, threadId, handle);
|
|
6627
|
+
const outcome = this.waitForOutcome(pending, controller.signal);
|
|
6628
|
+
// Reconcile the submission race once; the event stream remains the primary path.
|
|
6629
|
+
if (!controller.signal.aborted) {
|
|
6630
|
+
this.reconcileOne(pending);
|
|
5627
6631
|
}
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
});
|
|
6632
|
+
const result = await outcome;
|
|
6633
|
+
// A switch or deadline during the wait must not turn a stale result into confirmation.
|
|
6634
|
+
this.requireActiveAccount(message);
|
|
6635
|
+
controller.signal.throwIfAborted();
|
|
6636
|
+
if (result.state === 'accepted') {
|
|
6637
|
+
return;
|
|
5635
6638
|
}
|
|
6639
|
+
terminal = true;
|
|
6640
|
+
throw this.sendFailure(label, pending.id, result.state, result.detail);
|
|
5636
6641
|
}
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
}
|
|
5642
|
-
async pollChat(chatId, cursor) {
|
|
5643
|
-
const messages = new Map();
|
|
5644
|
-
const pageSize = limit_1.WECHAT_MESSAGE_PAGE_SIZE;
|
|
5645
|
-
let hadMessages = false;
|
|
5646
|
-
// The upstream API sorts newest first. Page back to the previous timestamp,
|
|
5647
|
-
// including its entire second so messages with equal timestamps aren't lost.
|
|
5648
|
-
for (let offset = 0;; offset += pageSize) {
|
|
5649
|
-
const page = await this.request(`/api/messages/${encodeURIComponent(chatId)}?limit=${pageSize}&offset=${offset}`);
|
|
5650
|
-
if (!Array.isArray(page)) {
|
|
5651
|
-
throw new Error('Unexpected WeChat messages response.');
|
|
5652
|
-
}
|
|
5653
|
-
hadMessages ||= page.length > 0;
|
|
5654
|
-
let reachedHistory = false;
|
|
5655
|
-
for (const item of page) {
|
|
5656
|
-
if (!isRecord(item) || !Number.isSafeInteger(item.localId) ||
|
|
5657
|
-
typeof item.timestamp !== 'string' || !Number.isFinite(Date.parse(item.timestamp)) ||
|
|
5658
|
-
typeof item.content !== 'string' || typeof item.type !== 'number' ||
|
|
5659
|
-
(item.isSelf !== undefined && typeof item.isSelf !== 'boolean') ||
|
|
5660
|
-
(item.sender !== undefined && typeof item.sender !== 'string')) {
|
|
5661
|
-
throw new Error('Unexpected WeChat message fields.');
|
|
5662
|
-
}
|
|
5663
|
-
const message = item;
|
|
5664
|
-
const timestamp = Date.parse(message.timestamp);
|
|
5665
|
-
if (timestamp < cursor.timestamp) {
|
|
5666
|
-
reachedHistory = true;
|
|
6642
|
+
catch (error) {
|
|
6643
|
+
if (handle !== undefined && !terminal) {
|
|
6644
|
+
try {
|
|
6645
|
+
await handle.cancel();
|
|
5667
6646
|
}
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
// newly discovered messages must not merge across a self/other sender.
|
|
5671
|
-
messages.set(message.localId, message);
|
|
6647
|
+
catch (cancelError) {
|
|
6648
|
+
console.warn(`[wechat] unable to cancel ${label} ${handle.id}:`, cancelError instanceof Error ? cancelError.message : cancelError);
|
|
5672
6649
|
}
|
|
5673
6650
|
}
|
|
5674
|
-
if (
|
|
5675
|
-
|
|
6651
|
+
if (controller.signal.aborted) {
|
|
6652
|
+
throw new Error(`WeChat ${label} ${handle?.id ?? ''} cancelled or exceeded ` +
|
|
6653
|
+
`${limit_1.WECHAT_SEND_TIMEOUT_MS} ms; delivery may be unconfirmed and will not be retried.`);
|
|
5676
6654
|
}
|
|
6655
|
+
throw error;
|
|
5677
6656
|
}
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
this.dispatchBundle(chatId, bundle);
|
|
5684
|
-
for (const message of bundle) {
|
|
5685
|
-
this.advanceCursor(cursor, message);
|
|
6657
|
+
finally {
|
|
6658
|
+
clearTimeout(timer);
|
|
6659
|
+
signal?.removeEventListener('abort', cancel);
|
|
6660
|
+
if (pending !== undefined) {
|
|
6661
|
+
this.removePending(pending);
|
|
5686
6662
|
}
|
|
5687
|
-
|
|
6663
|
+
this.operations.delete(controller);
|
|
6664
|
+
}
|
|
6665
|
+
}
|
|
6666
|
+
/** One terminal delivery failure; uncertainty is never retried or resent. */
|
|
6667
|
+
sendFailure(label, id, state, detail) {
|
|
6668
|
+
return new Error(`WeChat ${label} ${id} ${state}` +
|
|
6669
|
+
`${detail === undefined ? '' : `: ${detail}`}. Delivery will not be retried.`);
|
|
6670
|
+
}
|
|
6671
|
+
/** Registers one nonterminal submission for SSE confirmation under its origin fence. */
|
|
6672
|
+
registerPending(message, label, family, threadId, handle) {
|
|
6673
|
+
this.requireActiveAccount(message);
|
|
6674
|
+
const pending = {
|
|
6675
|
+
key: `${family}\u0000${handle.id}`,
|
|
6676
|
+
id: handle.id,
|
|
6677
|
+
family,
|
|
6678
|
+
label,
|
|
6679
|
+
account: this.accountId,
|
|
6680
|
+
username: this.selfUsername,
|
|
6681
|
+
threadId,
|
|
6682
|
+
state: handle.state,
|
|
6683
|
+
...(handle.detail === undefined ? {} : { detail: handle.detail }),
|
|
6684
|
+
settled: false,
|
|
6685
|
+
read: handle.read,
|
|
6686
|
+
resolve: () => { },
|
|
6687
|
+
reject: () => { }
|
|
5688
6688
|
};
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
const eligible = message.isSelf === false && message.sender &&
|
|
5704
|
-
((message.type === 1 && message.content) || message.type === 3 || message.type === 34);
|
|
5705
|
-
if (alreadySeen || !eligible) {
|
|
5706
|
-
// Even messages we don't deliver interrupt conversational adjacency.
|
|
5707
|
-
flush();
|
|
5708
|
-
if (!alreadySeen) {
|
|
5709
|
-
this.advanceCursor(cursor, message);
|
|
5710
|
-
}
|
|
5711
|
-
continue;
|
|
5712
|
-
}
|
|
5713
|
-
const previous = bundle.at(-1);
|
|
5714
|
-
if (previous !== undefined && (this.bundleGap === 0 ||
|
|
5715
|
-
message.sender !== previous.sender ||
|
|
5716
|
-
timestamp - Date.parse(previous.timestamp) > this.bundleGap)) {
|
|
5717
|
-
flush();
|
|
6689
|
+
this.pending.set(pending.key, pending);
|
|
6690
|
+
return pending;
|
|
6691
|
+
}
|
|
6692
|
+
/** Resolves when the waiter's terminal outcome arrives or its deadline/caller aborts. */
|
|
6693
|
+
waitForOutcome(pending, signal) {
|
|
6694
|
+
return new Promise((resolve, reject) => {
|
|
6695
|
+
pending.resolve = resolve;
|
|
6696
|
+
pending.reject = reject;
|
|
6697
|
+
const onAbort = () => {
|
|
6698
|
+
reject(new Error(`WeChat ${pending.label} ${pending.id} cancelled.`));
|
|
6699
|
+
};
|
|
6700
|
+
if (signal.aborted) {
|
|
6701
|
+
onAbort();
|
|
6702
|
+
return;
|
|
5718
6703
|
}
|
|
5719
|
-
|
|
6704
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
6705
|
+
pending.cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
6706
|
+
});
|
|
6707
|
+
}
|
|
6708
|
+
/**
|
|
6709
|
+
* Applies one status read or event. Accepted and terminal states latch, so a stale
|
|
6710
|
+
* status read that finishes after a newer SSE update can never override it.
|
|
6711
|
+
*/
|
|
6712
|
+
applyOutcome(pending, state, detail) {
|
|
6713
|
+
if (pending.settled) {
|
|
6714
|
+
return;
|
|
6715
|
+
}
|
|
6716
|
+
if (state === 'accepted' || TERMINAL_STATES.has(state)) {
|
|
6717
|
+
this.settlePending(pending, { state, ...(detail === undefined ? {} : { detail }) });
|
|
6718
|
+
return;
|
|
6719
|
+
}
|
|
6720
|
+
pending.state = state;
|
|
6721
|
+
if (detail !== undefined) {
|
|
6722
|
+
pending.detail = detail;
|
|
5720
6723
|
}
|
|
5721
|
-
flush();
|
|
5722
|
-
return hadMessages;
|
|
5723
6724
|
}
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
const first = messages[0];
|
|
5727
|
-
const last = messages[messages.length - 1];
|
|
5728
|
-
const sender = first.isSelf ? this.account : first.sender;
|
|
5729
|
-
if (sender === undefined) {
|
|
6725
|
+
settlePending(pending, outcome) {
|
|
6726
|
+
if (pending.settled) {
|
|
5730
6727
|
return;
|
|
5731
6728
|
}
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
senderId: message.isSelf ? account : message.sender,
|
|
5745
|
-
senderName: displayName(message),
|
|
5746
|
-
isSelf: message.isSelf === true,
|
|
5747
|
-
timestamp: message.timestamp,
|
|
5748
|
-
images: message.type === 3 ? [{
|
|
5749
|
-
id: `image:${message.localId}`,
|
|
5750
|
-
load: (signal) => this.loadImage(chatId, message.localId, account, signal)
|
|
5751
|
-
}] : undefined,
|
|
5752
|
-
audio: message.type === 34 ? {
|
|
5753
|
-
id: `voice:${message.localId}`,
|
|
5754
|
-
load: (signal) => this.loadAudio(chatId, message.localId, account, signal)
|
|
5755
|
-
} : undefined
|
|
5756
|
-
})),
|
|
5757
|
-
metadata: {
|
|
5758
|
-
...last,
|
|
5759
|
-
content: text,
|
|
5760
|
-
wechatAccount: this.account,
|
|
5761
|
-
localIds: messages.map((message) => message.localId),
|
|
5762
|
-
firstTimestamp: first.timestamp,
|
|
5763
|
-
lastTimestamp: last.timestamp,
|
|
5764
|
-
messages: [...messages]
|
|
6729
|
+
pending.settled = true;
|
|
6730
|
+
this.pending.delete(pending.key);
|
|
6731
|
+
pending.reconcile?.abort();
|
|
6732
|
+
pending.cleanup?.();
|
|
6733
|
+
pending.cleanup = undefined;
|
|
6734
|
+
pending.resolve(outcome);
|
|
6735
|
+
}
|
|
6736
|
+
/** Rejects every outstanding waiter, e.g. on stop or an identity change. */
|
|
6737
|
+
rejectPending(reason) {
|
|
6738
|
+
for (const pending of [...this.pending.values()]) {
|
|
6739
|
+
if (pending.settled) {
|
|
6740
|
+
continue;
|
|
5765
6741
|
}
|
|
5766
|
-
|
|
6742
|
+
pending.settled = true;
|
|
6743
|
+
this.pending.delete(pending.key);
|
|
6744
|
+
pending.reconcile?.abort();
|
|
6745
|
+
pending.cleanup?.();
|
|
6746
|
+
pending.cleanup = undefined;
|
|
6747
|
+
pending.reject(new Error(`${reason} (${pending.label} ${pending.id})`));
|
|
6748
|
+
}
|
|
5767
6749
|
}
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
if (
|
|
5771
|
-
|
|
5772
|
-
cursor.ids.clear();
|
|
6750
|
+
/** Releases one waiter after its send settles, aborting any status read still in flight. */
|
|
6751
|
+
removePending(pending) {
|
|
6752
|
+
if (this.pending.get(pending.key) === pending) {
|
|
6753
|
+
this.pending.delete(pending.key);
|
|
5773
6754
|
}
|
|
5774
|
-
|
|
6755
|
+
pending.reconcile?.abort();
|
|
6756
|
+
pending.cleanup?.();
|
|
6757
|
+
pending.cleanup = undefined;
|
|
5775
6758
|
}
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
6759
|
+
/** Starts one status read per pending operation after a (re)connect. */
|
|
6760
|
+
reconcilePending() {
|
|
6761
|
+
for (const pending of [...this.pending.values()]) {
|
|
6762
|
+
this.reconcileOne(pending);
|
|
5779
6763
|
}
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
6764
|
+
}
|
|
6765
|
+
/**
|
|
6766
|
+
* Reads one pending operation's status once. The read never blocks the event stream,
|
|
6767
|
+
* never resends, and stops when the waiter settles or a terminal update latches. A
|
|
6768
|
+
* transient failure leaves the waiter to the stream and its existing deadline.
|
|
6769
|
+
*
|
|
6770
|
+
* A reconnect supersedes rather than queues behind a read still in flight from before
|
|
6771
|
+
* the disconnect: that read may predate a confirmation lost with the old connection,
|
|
6772
|
+
* and waiting for it could strand the send until its full deadline.
|
|
6773
|
+
*/
|
|
6774
|
+
reconcileOne(pending) {
|
|
6775
|
+
if (pending.settled) {
|
|
6776
|
+
return;
|
|
5785
6777
|
}
|
|
5786
|
-
|
|
5787
|
-
const
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
6778
|
+
pending.reconcile?.abort();
|
|
6779
|
+
const controller = new AbortController();
|
|
6780
|
+
pending.reconcile = controller;
|
|
6781
|
+
void (async () => {
|
|
6782
|
+
try {
|
|
6783
|
+
const next = await pending.read(controller.signal);
|
|
6784
|
+
if (pending.settled || pending.reconcile !== controller ||
|
|
6785
|
+
controller.signal.aborted || next.id !== pending.id) {
|
|
6786
|
+
return;
|
|
6787
|
+
}
|
|
6788
|
+
this.applyOutcome(pending, next.state, next.detail);
|
|
6789
|
+
}
|
|
6790
|
+
catch (error) {
|
|
6791
|
+
if (!pending.settled && pending.reconcile === controller &&
|
|
6792
|
+
!controller.signal.aborted) {
|
|
6793
|
+
console.warn(`[wechat] unable to reconcile ${pending.label} ${pending.id}:`, error instanceof Error ? error.message : error);
|
|
6794
|
+
}
|
|
5798
6795
|
}
|
|
5799
|
-
|
|
5800
|
-
|
|
6796
|
+
finally {
|
|
6797
|
+
if (pending.reconcile === controller) {
|
|
6798
|
+
pending.reconcile = undefined;
|
|
6799
|
+
}
|
|
5801
6800
|
}
|
|
6801
|
+
})();
|
|
6802
|
+
}
|
|
6803
|
+
async request(endpoint, body, signal, headers = {}) {
|
|
6804
|
+
return this.perform(endpoint, body, signal, headers, async (response) => await response.json());
|
|
6805
|
+
}
|
|
6806
|
+
/** Reads bounded raw bytes for media content routes. */
|
|
6807
|
+
async requestBytes(endpoint, signal, maxBytes) {
|
|
6808
|
+
return this.perform(endpoint, undefined, signal, {}, async (response, abort) => {
|
|
6809
|
+
const contentType = response.headers.get('content-type') ?? undefined;
|
|
5802
6810
|
if (Number(response.headers.get('content-length')) > maxBytes) {
|
|
5803
|
-
|
|
6811
|
+
abort();
|
|
5804
6812
|
throw new media_1.MediaUnavailableError('WeChat media response exceeds the size limit.');
|
|
5805
6813
|
}
|
|
5806
6814
|
if (response.body === null) {
|
|
@@ -5817,7 +6825,7 @@ class WechatMessagerAdapter {
|
|
|
5817
6825
|
}
|
|
5818
6826
|
size += chunk.value.byteLength;
|
|
5819
6827
|
if (size > maxBytes) {
|
|
5820
|
-
|
|
6828
|
+
abort();
|
|
5821
6829
|
throw new media_1.MediaUnavailableError('WeChat media response exceeds the size limit.');
|
|
5822
6830
|
}
|
|
5823
6831
|
chunks.push(chunk.value);
|
|
@@ -5826,7 +6834,34 @@ class WechatMessagerAdapter {
|
|
|
5826
6834
|
finally {
|
|
5827
6835
|
reader.releaseLock();
|
|
5828
6836
|
}
|
|
5829
|
-
return
|
|
6837
|
+
return { bytes: Buffer.concat(chunks), ...(contentType === undefined ? {} : { contentType }) };
|
|
6838
|
+
});
|
|
6839
|
+
}
|
|
6840
|
+
/** Issues one authenticated request with caller, deadline, and shutdown cancellation. */
|
|
6841
|
+
async perform(endpoint, body, signal, headers, consume) {
|
|
6842
|
+
if (this.handler === undefined) {
|
|
6843
|
+
throw new Error('WeChat adapter is stopped.');
|
|
6844
|
+
}
|
|
6845
|
+
const controller = new AbortController();
|
|
6846
|
+
const cancel = () => controller.abort();
|
|
6847
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
6848
|
+
if (signal?.aborted) {
|
|
6849
|
+
controller.abort();
|
|
6850
|
+
}
|
|
6851
|
+
this.operations.add(controller);
|
|
6852
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
6853
|
+
try {
|
|
6854
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
6855
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
6856
|
+
headers: { Authorization: `Bearer ${this.token}`,
|
|
6857
|
+
...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers },
|
|
6858
|
+
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
|
|
6859
|
+
signal: controller.signal
|
|
6860
|
+
});
|
|
6861
|
+
if (!response.ok) {
|
|
6862
|
+
throw new WechatRequestError(response.status, endpoint);
|
|
6863
|
+
}
|
|
6864
|
+
return await consume(response, cancel);
|
|
5830
6865
|
}
|
|
5831
6866
|
finally {
|
|
5832
6867
|
clearTimeout(timer);
|
|
@@ -5979,6 +7014,7 @@ const images_1 = __webpack_require__(454);
|
|
|
5979
7014
|
const input_budget_1 = __webpack_require__(6280);
|
|
5980
7015
|
const tokens_1 = __webpack_require__(4956);
|
|
5981
7016
|
const pending_media_1 = __webpack_require__(4078);
|
|
7017
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
5982
7018
|
/** Defers this snapshot before any model request or delivery. */
|
|
5983
7019
|
class PendingImagesError extends pending_media_1.PendingMediaError {
|
|
5984
7020
|
constructor() {
|
|
@@ -6074,7 +7110,7 @@ async function loadImage(image, signal) {
|
|
|
6074
7110
|
attempts.set(image, attempt);
|
|
6075
7111
|
try {
|
|
6076
7112
|
const result = await image.load(signal);
|
|
6077
|
-
if (
|
|
7113
|
+
if ((0, type_guards_1.isString)(result)) {
|
|
6078
7114
|
attempts.delete(image);
|
|
6079
7115
|
return result;
|
|
6080
7116
|
}
|
|
@@ -6511,16 +7547,17 @@ exports.generatedImages = generatedImages;
|
|
|
6511
7547
|
const limit_1 = __webpack_require__(9258);
|
|
6512
7548
|
const images_1 = __webpack_require__(454);
|
|
6513
7549
|
const api_1 = __webpack_require__(2981);
|
|
7550
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
6514
7551
|
/** Validates a numeric width or height limit in pixels. */
|
|
6515
7552
|
function parseImageDimension(value) {
|
|
6516
|
-
if (
|
|
7553
|
+
if (!(0, type_guards_1.isNumber)(value) || !Number.isInteger(value) || value < 1 || value > 16384) {
|
|
6517
7554
|
throw new Error('Image dimension must be an integer between 1 and 16384.');
|
|
6518
7555
|
}
|
|
6519
7556
|
return value;
|
|
6520
7557
|
}
|
|
6521
7558
|
/** Validates a concrete dimension limit; provider-specific supported sizes are checked remotely. */
|
|
6522
7559
|
function parseImageSize(value) {
|
|
6523
|
-
if (
|
|
7560
|
+
if (!(0, type_guards_1.isString)(value) || !/^[1-9]\d{0,4}x[1-9]\d{0,4}$/.test(value)) {
|
|
6524
7561
|
throw new Error('Expected WIDTHxHEIGHT.');
|
|
6525
7562
|
}
|
|
6526
7563
|
const [width, height] = value.split('x').map(Number);
|
|
@@ -6604,7 +7641,7 @@ exports.ImageGenerationPolicy = ImageGenerationPolicy;
|
|
|
6604
7641
|
function generatedImages(output, size = `${limit_1.DEFAULT_IMAGE_WIDTH_LIMIT}x${limit_1.DEFAULT_IMAGE_HEIGHT_LIMIT}`) {
|
|
6605
7642
|
const limit = parseImageSize(size);
|
|
6606
7643
|
return output.filter((item) => item.type === 'image_generation_call').map((item) => {
|
|
6607
|
-
if (item.status !== 'completed' ||
|
|
7644
|
+
if (item.status !== 'completed' || !(0, type_guards_1.isString)(item.result)) {
|
|
6608
7645
|
throw new Error('Provider returned an incomplete image generation result.');
|
|
6609
7646
|
}
|
|
6610
7647
|
const url = (0, images_1.imageDataUrl)(item.result);
|
|
@@ -6636,6 +7673,7 @@ exports.modelInputTokens = modelInputTokens;
|
|
|
6636
7673
|
exports.assertInputBudget = assertInputBudget;
|
|
6637
7674
|
const limit_1 = __webpack_require__(9258);
|
|
6638
7675
|
const tokens_1 = __webpack_require__(4956);
|
|
7676
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
6639
7677
|
/** Truncates text without breaking Unicode and explicitly marks lost context. */
|
|
6640
7678
|
function boundedText(text, tokens) {
|
|
6641
7679
|
return (0, tokens_1.countTokens)(text) <= tokens ? text : `${(0, tokens_1.limitTextTokens)(text, Math.max(1, tokens - limit_1.TRUNCATION_MARKER_TOKENS))}\n[truncated]`;
|
|
@@ -6644,8 +7682,8 @@ function boundedText(text, tokens) {
|
|
|
6644
7682
|
function modelInputTokens(input, instructions, tools = []) {
|
|
6645
7683
|
let images = 0;
|
|
6646
7684
|
const json = JSON.stringify({ input, instructions, tools }, (key, value) => {
|
|
6647
|
-
if (
|
|
6648
|
-
value.type === 'image_generation_call' && 'result' in value &&
|
|
7685
|
+
if ((0, type_guards_1.isObject)(value) && 'type' in value &&
|
|
7686
|
+
value.type === 'image_generation_call' && 'result' in value && (0, type_guards_1.isString)(value.result)) {
|
|
6649
7687
|
images += 1;
|
|
6650
7688
|
return { ...value, result: '[generated image]' };
|
|
6651
7689
|
}
|
|
@@ -6690,6 +7728,7 @@ const limit_1 = __webpack_require__(9258);
|
|
|
6690
7728
|
const node_fs_1 = __webpack_require__(3024);
|
|
6691
7729
|
const node_path_1 = __importDefault(__webpack_require__(6760));
|
|
6692
7730
|
const responses_client_1 = __webpack_require__(102);
|
|
7731
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
6693
7732
|
/**
|
|
6694
7733
|
* Name of the file that holds model configurations inside the config folder.
|
|
6695
7734
|
*/
|
|
@@ -6710,28 +7749,22 @@ exports.ModelStoreError = ModelStoreError;
|
|
|
6710
7749
|
function getModelsPath(configDir) {
|
|
6711
7750
|
return node_path_1.default.join(configDir, exports.MODELS_FILE_NAME);
|
|
6712
7751
|
}
|
|
6713
|
-
/**
|
|
6714
|
-
* Returns true for plain JSON objects (not null, arrays, or primitives).
|
|
6715
|
-
*/
|
|
6716
|
-
function isPlainObject(value) {
|
|
6717
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6718
|
-
}
|
|
6719
7752
|
/**
|
|
6720
7753
|
* Validates one model entry.
|
|
6721
7754
|
*/
|
|
6722
7755
|
function parseModelEntry(value, where, seen) {
|
|
6723
|
-
if (!
|
|
7756
|
+
if (!(0, type_guards_1.isRecord)(value)) {
|
|
6724
7757
|
throw new ModelStoreError(`${where} must be an object.`);
|
|
6725
7758
|
}
|
|
6726
|
-
if (
|
|
7759
|
+
if (!(0, type_guards_1.isString)(value.id) || value.id.trim() === '') {
|
|
6727
7760
|
throw new ModelStoreError(`${where}: "id" must be a non-empty string.`);
|
|
6728
7761
|
}
|
|
6729
7762
|
const id = value.id.trim();
|
|
6730
7763
|
const name = value.name ?? id;
|
|
6731
|
-
if (
|
|
7764
|
+
if (!(0, type_guards_1.isString)(name) || name.trim() === '') {
|
|
6732
7765
|
throw new ModelStoreError(`${where}: "name" must be a non-empty string.`);
|
|
6733
7766
|
}
|
|
6734
|
-
if (value.supportsImageToolResults !== undefined &&
|
|
7767
|
+
if (value.supportsImageToolResults !== undefined && !(0, type_guards_1.isBoolean)(value.supportsImageToolResults)) {
|
|
6735
7768
|
throw new ModelStoreError(`${where}: "supportsImageToolResults" must be a boolean.`);
|
|
6736
7769
|
}
|
|
6737
7770
|
const entry = {
|
|
@@ -6749,14 +7782,14 @@ function parseModelEntry(value, where, seen) {
|
|
|
6749
7782
|
*/
|
|
6750
7783
|
function parseProvider(value, index, modelsPath) {
|
|
6751
7784
|
const where = `${modelsPath}: providers[${index}]`;
|
|
6752
|
-
if (!
|
|
7785
|
+
if (!(0, type_guards_1.isRecord)(value)) {
|
|
6753
7786
|
throw new ModelStoreError(`${where} must be an object.`);
|
|
6754
7787
|
}
|
|
6755
|
-
if (
|
|
7788
|
+
if (!(0, type_guards_1.isString)(value.provider) || value.provider.trim() === '') {
|
|
6756
7789
|
throw new ModelStoreError(`${where}: "provider" must be a non-empty string.`);
|
|
6757
7790
|
}
|
|
6758
7791
|
const baseUrl = value.baseUrl;
|
|
6759
|
-
if (
|
|
7792
|
+
if (!(0, type_guards_1.isString)(baseUrl) || baseUrl.trim() === '') {
|
|
6760
7793
|
throw new ModelStoreError(`${where}: "baseUrl" must be a non-empty string.`);
|
|
6761
7794
|
}
|
|
6762
7795
|
if (!Array.isArray(value.models)) {
|
|
@@ -6799,10 +7832,10 @@ async function loadModels(configDir) {
|
|
|
6799
7832
|
catch (error) {
|
|
6800
7833
|
throw new ModelStoreError(`Invalid JSON in ${modelsPath}: ${error.message}`);
|
|
6801
7834
|
}
|
|
6802
|
-
if (!
|
|
7835
|
+
if (!(0, type_guards_1.isRecord)(parsed) || !Array.isArray(parsed.providers)) {
|
|
6803
7836
|
throw new ModelStoreError(`${modelsPath} must contain an object with a "providers" array.`);
|
|
6804
7837
|
}
|
|
6805
|
-
if (parsed.$schema !== undefined &&
|
|
7838
|
+
if (parsed.$schema !== undefined && !(0, type_guards_1.isString)(parsed.$schema)) {
|
|
6806
7839
|
throw new ModelStoreError(`${modelsPath}: "$schema" must be a string.`);
|
|
6807
7840
|
}
|
|
6808
7841
|
const seen = new Set();
|
|
@@ -6953,9 +7986,10 @@ __webpack_unused_export__ = extractOutputText;
|
|
|
6953
7986
|
__webpack_unused_export__ = extractCitations;
|
|
6954
7987
|
exports.createResponse = createResponse;
|
|
6955
7988
|
const limit_1 = __webpack_require__(9258);
|
|
7989
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
6956
7990
|
/** Validates durations against Node's timer range, including programmatic callers. */
|
|
6957
7991
|
function resolveModelTimeoutMs(value = limit_1.DEFAULT_MODEL_TIMEOUT_MS) {
|
|
6958
|
-
if (
|
|
7992
|
+
if (!(0, type_guards_1.isNumber)(value) || !Number.isSafeInteger(value) || value < 1 || value > limit_1.MAX_TIMER_MS) {
|
|
6959
7993
|
throw new Error(`Model timeout must be an integer from 1 to ${limit_1.MAX_TIMER_MS} milliseconds.`);
|
|
6960
7994
|
}
|
|
6961
7995
|
return value;
|
|
@@ -6974,12 +8008,6 @@ class ResponsesApiError extends Error {
|
|
|
6974
8008
|
}
|
|
6975
8009
|
}
|
|
6976
8010
|
exports.ResponsesApiError = ResponsesApiError;
|
|
6977
|
-
/**
|
|
6978
|
-
* Returns true for plain JSON objects (not null, arrays, or primitives).
|
|
6979
|
-
*/
|
|
6980
|
-
function isRecord(value) {
|
|
6981
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6982
|
-
}
|
|
6983
8011
|
/**
|
|
6984
8012
|
* Builds the endpoint used for Responses API calls.
|
|
6985
8013
|
*/
|
|
@@ -6993,10 +8021,10 @@ function responsesEndpoint(baseUrl) {
|
|
|
6993
8021
|
* back to a top-level `output_text` string for compatible servers that use it.
|
|
6994
8022
|
*/
|
|
6995
8023
|
function extractOutputText(payload) {
|
|
6996
|
-
if (!isRecord(payload)) {
|
|
8024
|
+
if (!(0, type_guards_1.isRecord)(payload)) {
|
|
6997
8025
|
return '';
|
|
6998
8026
|
}
|
|
6999
|
-
if (
|
|
8027
|
+
if ((0, type_guards_1.isString)(payload.output_text)) {
|
|
7000
8028
|
return payload.output_text.trim();
|
|
7001
8029
|
}
|
|
7002
8030
|
if (!Array.isArray(payload.output)) {
|
|
@@ -7004,11 +8032,11 @@ function extractOutputText(payload) {
|
|
|
7004
8032
|
}
|
|
7005
8033
|
const parts = [];
|
|
7006
8034
|
for (const item of payload.output) {
|
|
7007
|
-
if (!isRecord(item) || !Array.isArray(item.content)) {
|
|
8035
|
+
if (!(0, type_guards_1.isRecord)(item) || !Array.isArray(item.content)) {
|
|
7008
8036
|
continue;
|
|
7009
8037
|
}
|
|
7010
8038
|
for (const content of item.content) {
|
|
7011
|
-
if (isRecord(content) && content.type === 'output_text' &&
|
|
8039
|
+
if ((0, type_guards_1.isRecord)(content) && content.type === 'output_text' && (0, type_guards_1.isString)(content.text)) {
|
|
7012
8040
|
parts.push(content.text);
|
|
7013
8041
|
}
|
|
7014
8042
|
}
|
|
@@ -7017,20 +8045,20 @@ function extractOutputText(payload) {
|
|
|
7017
8045
|
}
|
|
7018
8046
|
/** Retains URL citations even when generated text uses provider-specific markers. */
|
|
7019
8047
|
function extractCitations(payload) {
|
|
7020
|
-
if (!isRecord(payload) || !Array.isArray(payload.output)) {
|
|
8048
|
+
if (!(0, type_guards_1.isRecord)(payload) || !Array.isArray(payload.output)) {
|
|
7021
8049
|
return [];
|
|
7022
8050
|
}
|
|
7023
8051
|
const urls = new Set();
|
|
7024
8052
|
for (const item of payload.output) {
|
|
7025
|
-
if (!isRecord(item) || item.type !== 'message' || !Array.isArray(item.content)) {
|
|
8053
|
+
if (!(0, type_guards_1.isRecord)(item) || item.type !== 'message' || !Array.isArray(item.content)) {
|
|
7026
8054
|
continue;
|
|
7027
8055
|
}
|
|
7028
8056
|
for (const content of item.content) {
|
|
7029
|
-
if (!isRecord(content) || content.type !== 'output_text' || !Array.isArray(content.annotations)) {
|
|
8057
|
+
if (!(0, type_guards_1.isRecord)(content) || content.type !== 'output_text' || !Array.isArray(content.annotations)) {
|
|
7030
8058
|
continue;
|
|
7031
8059
|
}
|
|
7032
8060
|
for (const annotation of content.annotations) {
|
|
7033
|
-
if (!isRecord(annotation) || annotation.type !== 'url_citation' ||
|
|
8061
|
+
if (!(0, type_guards_1.isRecord)(annotation) || annotation.type !== 'url_citation' || !(0, type_guards_1.isString)(annotation.url)) {
|
|
7034
8062
|
continue;
|
|
7035
8063
|
}
|
|
7036
8064
|
try {
|
|
@@ -7105,11 +8133,11 @@ async function createResponse(request) {
|
|
|
7105
8133
|
catch (error) {
|
|
7106
8134
|
throw new ResponsesApiError(`Response from ${url} was not valid JSON: ${error.message}`, response.status, text);
|
|
7107
8135
|
}
|
|
7108
|
-
if (isRecord(parsed) && ((parsed.error !== undefined && parsed.error !== null) || parsed.status === 'failed' || parsed.status === 'incomplete')) {
|
|
8136
|
+
if ((0, type_guards_1.isRecord)(parsed) && ((parsed.error !== undefined && parsed.error !== null) || parsed.status === 'failed' || parsed.status === 'incomplete')) {
|
|
7109
8137
|
throw new ResponsesApiError(`Response from ${url} did not complete successfully.`, response.status);
|
|
7110
8138
|
}
|
|
7111
8139
|
const result = { outputText: extractOutputText(parsed), citations: extractCitations(parsed), raw: parsed };
|
|
7112
|
-
if (isRecord(parsed) &&
|
|
8140
|
+
if ((0, type_guards_1.isRecord)(parsed) && (0, type_guards_1.isString)(parsed.id)) {
|
|
7113
8141
|
result.id = parsed.id;
|
|
7114
8142
|
}
|
|
7115
8143
|
return result;
|
|
@@ -7188,6 +8216,7 @@ const retry_1 = __webpack_require__(7718);
|
|
|
7188
8216
|
const transcriptions_client_1 = __webpack_require__(6763);
|
|
7189
8217
|
const speech_client_1 = __webpack_require__(5314);
|
|
7190
8218
|
const image_generation_1 = __webpack_require__(3376);
|
|
8219
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
7191
8220
|
/** A model bound to its provider endpoint and credential. */
|
|
7192
8221
|
class ProviderModel {
|
|
7193
8222
|
baseUrl;
|
|
@@ -7267,7 +8296,7 @@ class ProviderModel {
|
|
|
7267
8296
|
async generateWithTools(input, instructions, localTools, apiTools, signal, reasoningEffort, imageGeneration, generated, onGeneratedImages) {
|
|
7268
8297
|
const runner = new runner_1.ToolRunner(localTools);
|
|
7269
8298
|
const initial = [
|
|
7270
|
-
...(
|
|
8299
|
+
...((0, type_guards_1.isString)(input) ? [{ role: 'user', content: input }] : input),
|
|
7271
8300
|
...generated.output
|
|
7272
8301
|
];
|
|
7273
8302
|
if (generated.images.length > 0) {
|
|
@@ -7335,8 +8364,8 @@ class ProviderModel {
|
|
|
7335
8364
|
throw error;
|
|
7336
8365
|
}
|
|
7337
8366
|
const raw = result.raw;
|
|
7338
|
-
if (
|
|
7339
|
-
!raw.output.every((item) =>
|
|
8367
|
+
if (!(0, type_guards_1.isObject)(raw) || !('output' in raw) || !Array.isArray(raw.output) ||
|
|
8368
|
+
!raw.output.every((item) => (0, type_guards_1.isRecord)(item))) {
|
|
7340
8369
|
throw new Error('Tool-capable models must return a Responses API output array.');
|
|
7341
8370
|
}
|
|
7342
8371
|
const output = raw.output;
|
|
@@ -7345,7 +8374,7 @@ class ProviderModel {
|
|
|
7345
8374
|
if (imageCalls.length > 0 && reservation === undefined) {
|
|
7346
8375
|
throw new Error('Provider generated images without an available image generation quota.');
|
|
7347
8376
|
}
|
|
7348
|
-
if (reservation !== undefined && output.filter((item) =>
|
|
8377
|
+
if (reservation !== undefined && output.filter((item) => (0, type_guards_1.isString)(item.type) &&
|
|
7349
8378
|
item.type.endsWith('_call') && item.type !== 'function_call').length > 1) {
|
|
7350
8379
|
throw new Error('Provider exceeded the reserved image generation tool call budget.');
|
|
7351
8380
|
}
|
|
@@ -7365,7 +8394,7 @@ class ProviderModel {
|
|
|
7365
8394
|
throw new Error(`Provider tool output "${String(needsClient.type)}" requires unsupported client execution or approval.`);
|
|
7366
8395
|
}
|
|
7367
8396
|
// Hosted calls execute at the provider; retain their items but never run them locally.
|
|
7368
|
-
usedApiCalls += output.filter((item) =>
|
|
8397
|
+
usedApiCalls += output.filter((item) => (0, type_guards_1.isString)(item.type) &&
|
|
7369
8398
|
item.type.endsWith('_call') && item.type !== 'function_call').length;
|
|
7370
8399
|
if (usedApiCalls > limit_1.MAX_MODEL_TOOL_CALLS) {
|
|
7371
8400
|
throw new Error('Model exceeded the provider-hosted tool call budget.');
|
|
@@ -7383,8 +8412,8 @@ class ProviderModel {
|
|
|
7383
8412
|
const turn = [...output];
|
|
7384
8413
|
const imageParts = [];
|
|
7385
8414
|
for (const call of calls) {
|
|
7386
|
-
if (
|
|
7387
|
-
|
|
8415
|
+
if (!(0, type_guards_1.isString)(call.call_id) || !call.call_id || !(0, type_guards_1.isString)(call.name) ||
|
|
8416
|
+
!(0, type_guards_1.isString)(call.arguments) || callIds.has(call.call_id)) {
|
|
7388
8417
|
throw new Error('Model returned a malformed or duplicate function call.');
|
|
7389
8418
|
}
|
|
7390
8419
|
callIds.add(call.call_id);
|
|
@@ -7611,6 +8640,7 @@ __webpack_unused_export__ = ({ value: true });
|
|
|
7611
8640
|
exports.createTranscription = createTranscription;
|
|
7612
8641
|
const limit_1 = __webpack_require__(9258);
|
|
7613
8642
|
const responses_client_1 = __webpack_require__(102);
|
|
8643
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
7614
8644
|
/** Calls an OpenAI-compatible audio/transcriptions endpoint using native multipart upload. */
|
|
7615
8645
|
async function createTranscription(request) {
|
|
7616
8646
|
request.signal?.throwIfAborted();
|
|
@@ -7648,8 +8678,8 @@ async function createTranscription(request) {
|
|
|
7648
8678
|
}
|
|
7649
8679
|
throw new responses_client_1.ResponsesApiError('Transcription model returned invalid JSON.', response.status);
|
|
7650
8680
|
}
|
|
7651
|
-
if (
|
|
7652
|
-
!('text' in result) ||
|
|
8681
|
+
if (!(0, type_guards_1.isRecord)(result) ||
|
|
8682
|
+
!('text' in result) || !(0, type_guards_1.isString)(result.text) || result.text.length > limit_1.MAX_TRANSCRIPT_CHARS) {
|
|
7653
8683
|
throw new responses_client_1.ResponsesApiError(`Transcription model must return a text string of at most ${limit_1.MAX_TRANSCRIPT_CHARS} characters.`, response.status);
|
|
7654
8684
|
}
|
|
7655
8685
|
return result.text.trim();
|
|
@@ -7958,6 +8988,7 @@ const limits_1 = __webpack_require__(6276);
|
|
|
7958
8988
|
const conversation_input_1 = __webpack_require__(9644);
|
|
7959
8989
|
const read_image_1 = __webpack_require__(1069);
|
|
7960
8990
|
const image_generation_1 = __webpack_require__(3376);
|
|
8991
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
7961
8992
|
/** Shared output contract for generation and formatting-only repair. */
|
|
7962
8993
|
const REPLY_FORMAT_INSTRUCTIONS = [
|
|
7963
8994
|
'Return only JSON, without fences: {"reply":true,"text":"Hello!","memoryUpdates":{"notes":[],"forget":[]}}. reply must be boolean; text must be the nonempty outgoing message when true, or "" when false.',
|
|
@@ -8098,13 +9129,13 @@ class ModelReplier {
|
|
|
8098
9129
|
catch {
|
|
8099
9130
|
throw new Error('Reply model returned invalid JSON.');
|
|
8100
9131
|
}
|
|
8101
|
-
if (
|
|
8102
|
-
!('reply' in result) ||
|
|
8103
|
-
!('text' in result) ||
|
|
9132
|
+
if (!(0, type_guards_1.isRecord)(result) ||
|
|
9133
|
+
!('reply' in result) || !(0, type_guards_1.isBoolean)(result.reply) ||
|
|
9134
|
+
!('text' in result) || !(0, type_guards_1.isString)(result.text)) {
|
|
8104
9135
|
throw new Error('Reply model must return reply and text.');
|
|
8105
9136
|
}
|
|
8106
9137
|
const memoryUpdates = (0, interface_2.parseMemoryUpdates)('memoryUpdates' in result ? result.memoryUpdates : undefined);
|
|
8107
|
-
if ('voice' in result &&
|
|
9138
|
+
if ('voice' in result && !(0, type_guards_1.isBoolean)(result.voice)) {
|
|
8108
9139
|
throw new Error('Reply model voice must be a boolean.');
|
|
8109
9140
|
}
|
|
8110
9141
|
if (!result.reply) {
|
|
@@ -8162,6 +9193,7 @@ const voice_transcriber_1 = __webpack_require__(5276);
|
|
|
8162
9193
|
const context_file_1 = __webpack_require__(3384);
|
|
8163
9194
|
const voice_synthesizer_1 = __webpack_require__(7393);
|
|
8164
9195
|
const image_generation_1 = __webpack_require__(3376);
|
|
9196
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
8165
9197
|
__exportStar(__webpack_require__(2590), exports);
|
|
8166
9198
|
__exportStar(__webpack_require__(5230), exports);
|
|
8167
9199
|
__exportStar(__webpack_require__(2977), exports);
|
|
@@ -8178,6 +9210,26 @@ __exportStar(__webpack_require__(6549), exports);
|
|
|
8178
9210
|
/** A manual/self reply arrived after the snapshot was taken. */
|
|
8179
9211
|
class SupersededTurnError extends Error {
|
|
8180
9212
|
}
|
|
9213
|
+
/** Keeps one opaque identity field verbatim; absent, blank, and non-string values mean none. */
|
|
9214
|
+
function identityField(value) {
|
|
9215
|
+
// The adapter compares both identity fields by exact string equality; trimming or case
|
|
9216
|
+
// folding here could merge two distinct accounts into one scheduling and storage scope.
|
|
9217
|
+
const exact = (0, type_guards_1.isString)(value) ? value : '';
|
|
9218
|
+
return exact === '' ? null : exact;
|
|
9219
|
+
}
|
|
9220
|
+
/**
|
|
9221
|
+
* Reads the signed-in identity that scopes one message's scheduling and stored context.
|
|
9222
|
+
*
|
|
9223
|
+
* A daemon account is a profile, not a user: the same account ID can sign in as a new
|
|
9224
|
+
* username, so context must be scoped by both fields, used exactly as reported. Messages
|
|
9225
|
+
* from transports without WeChat identity return undefined and keep their transport-neutral
|
|
9226
|
+
* keys, while a message without a username never shares a namespace with a named login.
|
|
9227
|
+
*/
|
|
9228
|
+
function wechatIdentity(message) {
|
|
9229
|
+
const account = identityField(message.metadata?.wechatAccount);
|
|
9230
|
+
const username = identityField(message.metadata?.wechatUsername);
|
|
9231
|
+
return account === null && username === null ? undefined : { account, username };
|
|
9232
|
+
}
|
|
8181
9233
|
/** Owns chat pipelines, message handling, and adapter startup/shutdown. */
|
|
8182
9234
|
class ChatServer {
|
|
8183
9235
|
options;
|
|
@@ -8382,18 +9434,18 @@ class ChatServer {
|
|
|
8382
9434
|
if (statusRequest === undefined && regularMessages.length === 0) {
|
|
8383
9435
|
return;
|
|
8384
9436
|
}
|
|
8385
|
-
const
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
9437
|
+
const identity = wechatIdentity(message);
|
|
9438
|
+
const thread = message.channel ?? message.senderId;
|
|
9439
|
+
// The signed-in username scopes scheduling, so a re-login never inherits old context.
|
|
9440
|
+
const key = JSON.stringify(identity === undefined
|
|
9441
|
+
? [chat.name, null, thread]
|
|
9442
|
+
: [chat.name, identity.account, identity.username, thread]);
|
|
8390
9443
|
let conversation = this.conversations.get(key);
|
|
8391
9444
|
if (conversation === undefined) {
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
]);
|
|
9445
|
+
// Stored memory and images use the same scope, so a re-login never loads them.
|
|
9446
|
+
const memoryKey = JSON.stringify(identity === undefined
|
|
9447
|
+
? [chat.messager.name, chat.name, thread]
|
|
9448
|
+
: [chat.messager.name, identity.account, identity.username, thread]);
|
|
8397
9449
|
conversation = {
|
|
8398
9450
|
chat, memoryKey, latest: message, messages: [], revision: 0,
|
|
8399
9451
|
processing: false, hasPendingMessages: false, selfRevision: 0, retryAttempts: 0
|
|
@@ -8725,8 +9777,8 @@ class ChatServer {
|
|
|
8725
9777
|
`Processing errors: ${this.processingFailures} (filters ${this.filterFailures})`,
|
|
8726
9778
|
`Last processing error: ${this.lastProcessingError ?? 'none'} (${age(this.lastProcessingErrorAt)})`,
|
|
8727
9779
|
`Deliveries: ${this.deliveries}; failures ${this.deliveryFailures}; last ${age(this.lastDeliveryAt)}`,
|
|
8728
|
-
`Adapter: ${chat.messager.name}${health === undefined ? '' : health.active ? ' active' : ' stopped'};
|
|
8729
|
-
`Last
|
|
9780
|
+
`Adapter: ${chat.messager.name}${health === undefined ? '' : health.active ? ' active' : ' stopped'}; stream failures ${health?.consecutiveFailures ?? 0}`,
|
|
9781
|
+
`Last event: ${age(health?.lastEventAt)}; stream activity: ${age(health?.lastStreamActivityAt)}; last error: ${health?.lastError ?? 'none'} (${age(health?.lastErrorAt)})`,
|
|
8730
9782
|
`Delay: ${limit_1.MIN_REPLY_DELAY_MS / 60_000}–${limit_1.MAX_REPLY_DELAY_MS / 60_000}m; memory: ${limit_1.MEMORY_RETENTION_MS / (24 * 60 * 60 * 1000)} days/${chat.memoryTokenLimit} tokens`
|
|
8731
9783
|
].join('\n');
|
|
8732
9784
|
await this.deliverReply(chat, message, { text: (0, limits_1.limitReplyText)(text), direct: true });
|
|
@@ -8758,22 +9810,23 @@ exports.ChatServer = ChatServer;
|
|
|
8758
9810
|
/***/ },
|
|
8759
9811
|
|
|
8760
9812
|
/***/ 2981
|
|
8761
|
-
(__unused_webpack_module, exports) {
|
|
9813
|
+
(__unused_webpack_module, exports, __webpack_require__) {
|
|
8762
9814
|
|
|
8763
9815
|
"use strict";
|
|
8764
9816
|
var __webpack_unused_export__;
|
|
8765
9817
|
|
|
8766
9818
|
__webpack_unused_export__ = ({ value: true });
|
|
8767
9819
|
exports.parseApiTools = parseApiTools;
|
|
9820
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
8768
9821
|
/** Validates the shared shape; the selected provider validates tool-specific options. */
|
|
8769
9822
|
function parseApiTools(value) {
|
|
8770
9823
|
if (!Array.isArray(value)) {
|
|
8771
9824
|
throw new Error('apiTools must be an array of tool type strings or provider-hosted tool objects.');
|
|
8772
9825
|
}
|
|
8773
9826
|
return value.map((entry, index) => {
|
|
8774
|
-
const tool =
|
|
8775
|
-
if (
|
|
8776
|
-
!('type' in tool) ||
|
|
9827
|
+
const tool = (0, type_guards_1.isString)(entry) ? { type: entry } : entry;
|
|
9828
|
+
if (!(0, type_guards_1.isRecord)(tool) ||
|
|
9829
|
+
!('type' in tool) || !(0, type_guards_1.isString)(tool.type) || !/^\S+$/.test(tool.type)) {
|
|
8777
9830
|
throw new Error(`apiTools[${index}] must be a nonempty tool type string or an object with a type, without whitespace in the type.`);
|
|
8778
9831
|
}
|
|
8779
9832
|
if (tool.type === 'function' || tool.type === 'custom') {
|
|
@@ -8974,6 +10027,7 @@ __webpack_unused_export__ = ({ value: true });
|
|
|
8974
10027
|
exports.CurrentTimeTool = void 0;
|
|
8975
10028
|
const limit_1 = __webpack_require__(9258);
|
|
8976
10029
|
const interface_1 = __webpack_require__(6598);
|
|
10030
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
8977
10031
|
class CurrentTimeTool {
|
|
8978
10032
|
definition = {
|
|
8979
10033
|
type: 'function',
|
|
@@ -8990,7 +10044,7 @@ class CurrentTimeTool {
|
|
|
8990
10044
|
execute(args) {
|
|
8991
10045
|
const values = (0, interface_1.toolArguments)(args, ['timezone']);
|
|
8992
10046
|
const timezone = values.timezone ?? 'UTC';
|
|
8993
|
-
if (
|
|
10047
|
+
if (!(0, type_guards_1.isString)(timezone) || !timezone.trim() || timezone.length > limit_1.MAX_TIMEZONE_CHARS) {
|
|
8994
10048
|
throw new Error('timezone must be an IANA timezone string or null.');
|
|
8995
10049
|
}
|
|
8996
10050
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
@@ -9282,7 +10336,7 @@ async function readPublicPage(value, signal) {
|
|
|
9282
10336
|
/***/ },
|
|
9283
10337
|
|
|
9284
10338
|
/***/ 6598
|
|
9285
|
-
(__unused_webpack_module, exports) {
|
|
10339
|
+
(__unused_webpack_module, exports, __webpack_require__) {
|
|
9286
10340
|
|
|
9287
10341
|
"use strict";
|
|
9288
10342
|
|
|
@@ -9290,6 +10344,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
|
9290
10344
|
exports.ToolImageResult = void 0;
|
|
9291
10345
|
exports.toolArguments = toolArguments;
|
|
9292
10346
|
exports.stringArgument = stringArgument;
|
|
10347
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
9293
10348
|
/** Native image attachments are kept separate from JSON tool output. */
|
|
9294
10349
|
class ToolImageResult {
|
|
9295
10350
|
images;
|
|
@@ -9300,7 +10355,7 @@ class ToolImageResult {
|
|
|
9300
10355
|
exports.ToolImageResult = ToolImageResult;
|
|
9301
10356
|
/** Validates an argument object before a tool accesses any fields. */
|
|
9302
10357
|
function toolArguments(args, keys) {
|
|
9303
|
-
if (
|
|
10358
|
+
if (!(0, type_guards_1.isRecord)(args)) {
|
|
9304
10359
|
throw new Error('Tool arguments must be a JSON object.');
|
|
9305
10360
|
}
|
|
9306
10361
|
const record = args;
|
|
@@ -9312,7 +10367,7 @@ function toolArguments(args, keys) {
|
|
|
9312
10367
|
/** Reads a bounded, nonempty string argument. */
|
|
9313
10368
|
function stringArgument(args, name, maxLength) {
|
|
9314
10369
|
const value = args[name];
|
|
9315
|
-
if (
|
|
10370
|
+
if (!(0, type_guards_1.isString)(value) || !value.trim() || value.length > maxLength) {
|
|
9316
10371
|
throw new Error(`${name} must be a nonempty string of at most ${maxLength} characters.`);
|
|
9317
10372
|
}
|
|
9318
10373
|
return value.trim();
|
|
@@ -9428,6 +10483,7 @@ const current_time_1 = __webpack_require__(4212);
|
|
|
9428
10483
|
const read_url_1 = __webpack_require__(8289);
|
|
9429
10484
|
const web_search_1 = __webpack_require__(9728);
|
|
9430
10485
|
const read_image_1 = __webpack_require__(1069);
|
|
10486
|
+
const type_guards_1 = __webpack_require__(3012);
|
|
9431
10487
|
exports.DEFAULT_TOOL_NAMES = ['web_search', 'read_url', 'calculate', 'current_time', 'read_image'];
|
|
9432
10488
|
const factories = new Map([
|
|
9433
10489
|
['web_search', () => new web_search_1.WebSearchTool()],
|
|
@@ -9453,13 +10509,13 @@ function parseLocalTools(value) {
|
|
|
9453
10509
|
}
|
|
9454
10510
|
const names = value.map((entry, index) => {
|
|
9455
10511
|
let name = entry;
|
|
9456
|
-
if (
|
|
10512
|
+
if ((0, type_guards_1.isRecord)(entry)) {
|
|
9457
10513
|
if (Object.keys(entry).some((key) => key !== 'type')) {
|
|
9458
10514
|
throw new Error(`localTools[${index}] supports only the type field.`);
|
|
9459
10515
|
}
|
|
9460
10516
|
name = 'type' in entry ? entry.type : undefined;
|
|
9461
10517
|
}
|
|
9462
|
-
if (
|
|
10518
|
+
if (!(0, type_guards_1.isString)(name) || !hasTool(name)) {
|
|
9463
10519
|
throw new Error(`localTools[${index}] must name a registered tool. Available: ${listTools().join(', ')}.`);
|
|
9464
10520
|
}
|
|
9465
10521
|
return name;
|
|
@@ -9630,6 +10686,51 @@ class WebSearchTool {
|
|
|
9630
10686
|
exports.WebSearchTool = WebSearchTool;
|
|
9631
10687
|
|
|
9632
10688
|
|
|
10689
|
+
/***/ },
|
|
10690
|
+
|
|
10691
|
+
/***/ 3012
|
|
10692
|
+
(__unused_webpack_module, exports) {
|
|
10693
|
+
|
|
10694
|
+
"use strict";
|
|
10695
|
+
var __webpack_unused_export__;
|
|
10696
|
+
|
|
10697
|
+
/**
|
|
10698
|
+
* Shared runtime type guards for values that arrive as untrusted data, such as
|
|
10699
|
+
* parsed JSON, provider responses, and configuration files.
|
|
10700
|
+
*/
|
|
10701
|
+
__webpack_unused_export__ = ({ value: true });
|
|
10702
|
+
exports.isString = isString;
|
|
10703
|
+
exports.isNumber = isNumber;
|
|
10704
|
+
exports.isBoolean = isBoolean;
|
|
10705
|
+
exports.isObject = isObject;
|
|
10706
|
+
exports.isRecord = isRecord;
|
|
10707
|
+
exports.isFunction = isFunction;
|
|
10708
|
+
/** Returns true when `value` is a string. */
|
|
10709
|
+
function isString(value) {
|
|
10710
|
+
return typeof value === 'string';
|
|
10711
|
+
}
|
|
10712
|
+
/** Returns true when `value` is a number, including `NaN` and infinities. */
|
|
10713
|
+
function isNumber(value) {
|
|
10714
|
+
return typeof value === 'number';
|
|
10715
|
+
}
|
|
10716
|
+
/** Returns true when `value` is a boolean. */
|
|
10717
|
+
function isBoolean(value) {
|
|
10718
|
+
return typeof value === 'boolean';
|
|
10719
|
+
}
|
|
10720
|
+
/** Returns true for any non-null object, including arrays, dates, and maps. */
|
|
10721
|
+
function isObject(value) {
|
|
10722
|
+
return typeof value === 'object' && value !== null;
|
|
10723
|
+
}
|
|
10724
|
+
/** Returns true for non-null objects that are not arrays; functions are rejected. */
|
|
10725
|
+
function isRecord(value) {
|
|
10726
|
+
return isObject(value) && !Array.isArray(value);
|
|
10727
|
+
}
|
|
10728
|
+
/** Returns true for functions, typed as callable so callers need no assertions. */
|
|
10729
|
+
function isFunction(value) {
|
|
10730
|
+
return typeof value === 'function';
|
|
10731
|
+
}
|
|
10732
|
+
|
|
10733
|
+
|
|
9633
10734
|
/***/ },
|
|
9634
10735
|
|
|
9635
10736
|
/***/ 1421
|