openzca 0.1.52 → 0.1.53

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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/dist/cli.js +304 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -127,6 +127,7 @@ Media commands accept local files, `file://` paths, and repeatable `--url` optio
127
127
  `openzca msg video` attempts native video send for a single `.mp4` input by uploading the video and thumbnail to Zalo first. If `ffmpeg` is unavailable, the input is not a single `.mp4`, or native send fails, it falls back to the normal attachment send path. Use `--thumbnail <path-or-url>` to supply the preview image explicitly.
128
128
  Local paths using `~` are expanded automatically (for positional file args, `--url`, and `OPENZCA_LISTEN_MEDIA_DIR`).
129
129
  Group text sends via `openzca msg send --group` resolve unique `@Name` or `@userId` mentions against the current group member list using member ids, display names, and usernames. Mention offsets are computed after formatting markers are parsed, so messages like `**@Alice Nguyen** hello` work. If multiple members share the same label, the command fails instead of guessing.
130
+ When formatted text would produce an oversized outbound payload, `openzca msg send` automatically splits it into multiple sequential text messages using the final outbound text and rebased style/mention offsets. The split happens after formatting is parsed, using both rendered text length and estimated request payload size rather than the raw input string.
130
131
  Use `openzca msg analyze-text ... --json` when you need to predict whether a formatted reply will expand into a large `textProperties` payload before attempting delivery.
131
132
  Reply flows:
132
133
 
@@ -391,6 +392,10 @@ Listener resilience override:
391
392
  - `OPENZCA_RECENT_GROUP_MAX_PAGES`: max websocket history pages to scan for `msg recent -g` when direct group-history path fails.
392
393
  - Default: `20`.
393
394
  - Increase if a group thread is old and not found quickly.
395
+ - `OPENZCA_TEXT_MESSAGE_MAX_LENGTH`: max rendered characters allowed per outbound text chunk before `msg send` splits it.
396
+ - Default: `2000`.
397
+ - `OPENZCA_TEXT_REQUEST_PARAMS_MAX_ESTIMATE`: max estimated serialized request size allowed per outbound text chunk before `msg send` splits it.
398
+ - Default: `4000`.
394
399
  - `OPENZCA_LISTEN_ENFORCE_SINGLE_OWNER`: enforce one `listen` owner process per profile.
395
400
  - Default: enabled.
396
401
  - Set to `0` to allow multiple listeners on the same profile (not recommended).
package/dist/cli.js CHANGED
@@ -2333,6 +2333,8 @@ function normalizeCodeBlockLeadingWhitespace(line) {
2333
2333
  }
2334
2334
 
2335
2335
  // src/lib/text-send.ts
2336
+ var ZALO_TEXT_MESSAGE_MAX_LENGTH = 2e3;
2337
+ var ZALO_TEXT_REQUEST_PARAMS_MAX_ESTIMATE = 4e3;
2336
2338
  async function buildTextSendPayload(params) {
2337
2339
  if (params.raw) {
2338
2340
  const mentions2 = await resolveGroupMentionsIfNeeded(params, params.message);
@@ -2368,15 +2370,103 @@ async function analyzeTextSendPayload(params) {
2368
2370
  });
2369
2371
  return {
2370
2372
  payload,
2371
- payloadObject,
2372
- rawInputLength: params.message.length,
2373
- renderedTextLength: payloadObject.msg.length,
2374
- styleCount: payloadObject.styles?.length ?? 0,
2375
- mentionCount: payloadObject.mentions?.length ?? 0,
2376
- textPropertiesLength: textProperties?.length ?? 0,
2377
- mentionInfoLength: mentionInfo?.length ?? 0,
2378
- requestParamsLengthEstimate: JSON.stringify(requestParams).length,
2379
- sendPath: params.threadType === ThreadType.Group ? mentionInfo ? "mention" : "sendmsg" : "sms"
2373
+ ...buildTextSendPayloadAnalysis({
2374
+ payloadObject,
2375
+ rawInputLength: params.message.length,
2376
+ textProperties,
2377
+ mentionInfo,
2378
+ requestParamsLengthEstimate: JSON.stringify(requestParams).length,
2379
+ threadType: params.threadType
2380
+ })
2381
+ };
2382
+ }
2383
+ function planTextSendPayloadsForDelivery(params) {
2384
+ const maxMessageLength = resolvePositiveLimit(
2385
+ params.maxMessageLength,
2386
+ ZALO_TEXT_MESSAGE_MAX_LENGTH
2387
+ );
2388
+ const maxRequestParamsLengthEstimate = resolvePositiveLimit(
2389
+ params.maxRequestParamsLengthEstimate,
2390
+ ZALO_TEXT_REQUEST_PARAMS_MAX_ESTIMATE
2391
+ );
2392
+ const chunks = [];
2393
+ const analyses = [];
2394
+ const pending = [params.payload];
2395
+ while (pending.length > 0) {
2396
+ const currentPayload = pending.shift();
2397
+ const analysis = analyzePreparedTextSendPayload({
2398
+ payload: currentPayload,
2399
+ threadType: params.threadType,
2400
+ threadId: params.threadId
2401
+ });
2402
+ if (isTextSendPayloadWithinDeliveryLimits(analysis, {
2403
+ maxMessageLength,
2404
+ maxRequestParamsLengthEstimate
2405
+ })) {
2406
+ chunks.push(currentPayload);
2407
+ analyses.push(analysis);
2408
+ continue;
2409
+ }
2410
+ const targetLength = computeNextChunkLength(analysis, {
2411
+ maxMessageLength,
2412
+ maxRequestParamsLengthEstimate
2413
+ });
2414
+ const splitChunks = splitTextSendPayload(currentPayload, targetLength);
2415
+ if (splitChunks.length <= 1) {
2416
+ throw new Error(
2417
+ `Unable to split formatted text payload into deliverable chunks within ${targetLength} characters.`
2418
+ );
2419
+ }
2420
+ pending.unshift(...splitChunks);
2421
+ }
2422
+ return { chunks, analyses };
2423
+ }
2424
+ function splitTextSendPayload(payload, maxLength = ZALO_TEXT_MESSAGE_MAX_LENGTH) {
2425
+ if (!Number.isInteger(maxLength) || maxLength <= 0) {
2426
+ throw new Error("Text chunk size must be a positive integer");
2427
+ }
2428
+ const payloadObject = normalizeTextSendPayload(payload);
2429
+ if (payloadObject.msg.length <= maxLength) {
2430
+ return [payload];
2431
+ }
2432
+ const chunks = [];
2433
+ let start = 0;
2434
+ while (start < payloadObject.msg.length) {
2435
+ const end = findChunkEnd(payloadObject, start, maxLength);
2436
+ chunks.push(sliceTextSendPayload(payloadObject, start, end));
2437
+ start = end;
2438
+ }
2439
+ return chunks;
2440
+ }
2441
+ function analyzePreparedTextSendPayload(params) {
2442
+ const payloadObject = normalizeTextSendPayload(params.payload);
2443
+ const textProperties = buildTextProperties(payloadObject.styles);
2444
+ const mentionInfo = buildMentionInfo(
2445
+ params.threadType,
2446
+ payloadObject.msg,
2447
+ payloadObject.mentions
2448
+ );
2449
+ const requestParams = omitUndefined({
2450
+ message: payloadObject.msg,
2451
+ clientId: 17e11,
2452
+ mentionInfo,
2453
+ imei: params.threadType === ThreadType.Group ? void 0 : "000000000000000",
2454
+ ttl: 0,
2455
+ visibility: params.threadType === ThreadType.Group ? 0 : void 0,
2456
+ toid: params.threadType === ThreadType.Group ? void 0 : params.threadId,
2457
+ grid: params.threadType === ThreadType.Group ? params.threadId : void 0,
2458
+ textProperties
2459
+ });
2460
+ return {
2461
+ payload: params.payload,
2462
+ ...buildTextSendPayloadAnalysis({
2463
+ payloadObject,
2464
+ rawInputLength: payloadObject.msg.length,
2465
+ textProperties,
2466
+ mentionInfo,
2467
+ requestParamsLengthEstimate: JSON.stringify(requestParams).length,
2468
+ threadType: params.threadType
2469
+ })
2380
2470
  };
2381
2471
  }
2382
2472
  async function resolveGroupMentionsIfNeeded(params, text) {
@@ -2399,6 +2489,153 @@ function normalizeTextSendPayload(payload) {
2399
2489
  }
2400
2490
  return payload;
2401
2491
  }
2492
+ function buildTextSendPayloadAnalysis(params) {
2493
+ return {
2494
+ payloadObject: params.payloadObject,
2495
+ rawInputLength: params.rawInputLength,
2496
+ renderedTextLength: params.payloadObject.msg.length,
2497
+ styleCount: params.payloadObject.styles?.length ?? 0,
2498
+ mentionCount: params.payloadObject.mentions?.length ?? 0,
2499
+ textPropertiesLength: params.textProperties?.length ?? 0,
2500
+ mentionInfoLength: params.mentionInfo?.length ?? 0,
2501
+ requestParamsLengthEstimate: params.requestParamsLengthEstimate,
2502
+ sendPath: params.threadType === ThreadType.Group ? params.mentionInfo ? "mention" : "sendmsg" : "sms"
2503
+ };
2504
+ }
2505
+ function isTextSendPayloadWithinDeliveryLimits(analysis, limits) {
2506
+ return analysis.renderedTextLength <= limits.maxMessageLength && analysis.requestParamsLengthEstimate <= limits.maxRequestParamsLengthEstimate;
2507
+ }
2508
+ function computeNextChunkLength(analysis, limits) {
2509
+ const currentLength = analysis.renderedTextLength;
2510
+ const targetLengths = [limits.maxMessageLength, currentLength - 1].filter((value) => value > 0);
2511
+ if (analysis.requestParamsLengthEstimate > limits.maxRequestParamsLengthEstimate) {
2512
+ targetLengths.push(
2513
+ Math.floor(
2514
+ currentLength * limits.maxRequestParamsLengthEstimate / analysis.requestParamsLengthEstimate
2515
+ )
2516
+ );
2517
+ }
2518
+ const targetLength = Math.max(
2519
+ 1,
2520
+ Math.min(...targetLengths.filter((value) => Number.isFinite(value) && value > 0))
2521
+ );
2522
+ return Math.min(targetLength, currentLength - 1);
2523
+ }
2524
+ function resolvePositiveLimit(value, fallback) {
2525
+ if (!value || !Number.isInteger(value) || value <= 0) {
2526
+ return fallback;
2527
+ }
2528
+ return value;
2529
+ }
2530
+ function sliceTextSendPayload(payloadObject, start, end) {
2531
+ const msg2 = payloadObject.msg.slice(start, end);
2532
+ const styles = sliceStyles(payloadObject.styles, start, end);
2533
+ const mentions = sliceMentions(payloadObject.mentions, start, end);
2534
+ if (!styles && !mentions) {
2535
+ return msg2;
2536
+ }
2537
+ return omitUndefined({
2538
+ msg: msg2,
2539
+ styles,
2540
+ mentions
2541
+ });
2542
+ }
2543
+ function sliceStyles(styles, start, end) {
2544
+ if (!styles || styles.length === 0) {
2545
+ return void 0;
2546
+ }
2547
+ const sliced = [];
2548
+ for (const style of styles) {
2549
+ const styleStart = style.start;
2550
+ const styleEnd = style.start + style.len;
2551
+ const overlapStart = Math.max(styleStart, start);
2552
+ const overlapEnd = Math.min(styleEnd, end);
2553
+ if (overlapStart >= overlapEnd) {
2554
+ continue;
2555
+ }
2556
+ if (style.st === "ind_$") {
2557
+ sliced.push({
2558
+ start: overlapStart - start,
2559
+ len: overlapEnd - overlapStart,
2560
+ st: style.st,
2561
+ indentSize: style.indentSize
2562
+ });
2563
+ continue;
2564
+ }
2565
+ sliced.push({
2566
+ start: overlapStart - start,
2567
+ len: overlapEnd - overlapStart,
2568
+ st: style.st
2569
+ });
2570
+ }
2571
+ return sliced.length > 0 ? sliced : void 0;
2572
+ }
2573
+ function sliceMentions(mentions, start, end) {
2574
+ if (!mentions || mentions.length === 0) {
2575
+ return void 0;
2576
+ }
2577
+ const sliced = mentions.filter((mention) => mention.pos >= start && mention.pos + mention.len <= end).map((mention) => ({
2578
+ pos: mention.pos - start,
2579
+ uid: mention.uid,
2580
+ len: mention.len
2581
+ }));
2582
+ return sliced.length > 0 ? sliced : void 0;
2583
+ }
2584
+ function findChunkEnd(payloadObject, start, maxLength) {
2585
+ const remaining = payloadObject.msg.length - start;
2586
+ if (remaining <= maxLength) {
2587
+ return payloadObject.msg.length;
2588
+ }
2589
+ const maxEnd = start + maxLength;
2590
+ const newlineBreak = findPreferredBreak(payloadObject, start, maxEnd, "\n");
2591
+ if (newlineBreak > start) {
2592
+ return newlineBreak;
2593
+ }
2594
+ const whitespaceBreak = findWhitespaceBreak(payloadObject, start, maxEnd);
2595
+ if (whitespaceBreak > start) {
2596
+ return whitespaceBreak;
2597
+ }
2598
+ for (let cursor = maxEnd; cursor > start; cursor -= 1) {
2599
+ if (isSafeSplitPosition(payloadObject.mentions, cursor)) {
2600
+ return cursor;
2601
+ }
2602
+ }
2603
+ throw new Error(
2604
+ `Unable to split text payload safely within ${maxLength} characters.`
2605
+ );
2606
+ }
2607
+ function findPreferredBreak(payloadObject, start, maxEnd, marker) {
2608
+ for (let cursor = maxEnd; cursor > start; cursor -= 1) {
2609
+ if (!isSafeSplitPosition(payloadObject.mentions, cursor)) {
2610
+ continue;
2611
+ }
2612
+ if (payloadObject.msg[cursor - 1] === marker) {
2613
+ return cursor;
2614
+ }
2615
+ }
2616
+ return start;
2617
+ }
2618
+ function findWhitespaceBreak(payloadObject, start, maxEnd) {
2619
+ for (let cursor = maxEnd; cursor > start; cursor -= 1) {
2620
+ if (!isSafeSplitPosition(payloadObject.mentions, cursor)) {
2621
+ continue;
2622
+ }
2623
+ const previousChar = payloadObject.msg[cursor - 1];
2624
+ if (previousChar === " " || previousChar === " ") {
2625
+ return cursor;
2626
+ }
2627
+ }
2628
+ return start;
2629
+ }
2630
+ function isSafeSplitPosition(mentions, position) {
2631
+ if (!mentions || mentions.length === 0) {
2632
+ return true;
2633
+ }
2634
+ return mentions.every((mention) => {
2635
+ const mentionEnd = mention.pos + mention.len;
2636
+ return position <= mention.pos || position >= mentionEnd;
2637
+ });
2638
+ }
2402
2639
  function buildTextProperties(styles) {
2403
2640
  if (!styles || styles.length === 0) {
2404
2641
  return void 0;
@@ -6523,20 +6760,67 @@ msg.command("send <threadId> <message>").option("-g, --group", "Send to group").
6523
6760
  ...typeof textPayload === "string" ? { msg: textPayload } : textPayload,
6524
6761
  ...quote ? { quote } : {}
6525
6762
  } : textPayload;
6526
- const response = await api.sendMessage(payload, threadId, threadType);
6763
+ const deliveryPlan = planTextSendPayloadsForDelivery({
6764
+ payload: textPayload,
6765
+ threadType,
6766
+ threadId,
6767
+ maxMessageLength: parsePositiveIntFromEnv(
6768
+ "OPENZCA_TEXT_MESSAGE_MAX_LENGTH",
6769
+ ZALO_TEXT_MESSAGE_MAX_LENGTH
6770
+ ),
6771
+ maxRequestParamsLengthEstimate: parsePositiveIntFromEnv(
6772
+ "OPENZCA_TEXT_REQUEST_PARAMS_MAX_ESTIMATE",
6773
+ ZALO_TEXT_REQUEST_PARAMS_MAX_ESTIMATE
6774
+ )
6775
+ });
6776
+ const payloadChunks = deliveryPlan.chunks;
6777
+ const responses = [];
6778
+ const sentPayloads = [];
6779
+ for (let index = 0; index < payloadChunks.length; index += 1) {
6780
+ const chunk = payloadChunks[index];
6781
+ const chunkPayload = quote && index === 0 ? {
6782
+ ...typeof chunk === "string" ? { msg: chunk } : chunk,
6783
+ quote
6784
+ } : chunk;
6785
+ sentPayloads.push(chunkPayload);
6786
+ responses.push(await api.sendMessage(chunkPayload, threadId, threadType));
6787
+ }
6788
+ const response = responses.length === 1 ? responses[0] : {
6789
+ chunked: true,
6790
+ chunkCount: responses.length,
6791
+ msgId: responses.at(-1)?.message?.msgId?.toString(),
6792
+ response: responses
6793
+ };
6527
6794
  output(response, false);
6528
6795
  if (await shouldWriteToDb(profile)) {
6529
6796
  scheduleDbWrite(profile, command, "msg.send.db.persist_error", async () => {
6530
- await persistOutgoingMessageBestEffort({
6531
- profile,
6532
- api,
6533
- threadId,
6534
- group: opts.group,
6535
- text: message,
6536
- msgType: "text",
6537
- response,
6538
- rawPayload: payload
6539
- });
6797
+ if (payloadChunks.length === 1) {
6798
+ await persistOutgoingMessageBestEffort({
6799
+ profile,
6800
+ api,
6801
+ threadId,
6802
+ group: opts.group,
6803
+ text: message,
6804
+ msgType: "text",
6805
+ response,
6806
+ rawPayload: payload
6807
+ });
6808
+ return;
6809
+ }
6810
+ for (let index = 0; index < payloadChunks.length; index += 1) {
6811
+ const chunk = sentPayloads[index];
6812
+ const chunkText = typeof chunk === "string" ? chunk : chunk.msg;
6813
+ await persistOutgoingMessageBestEffort({
6814
+ profile,
6815
+ api,
6816
+ threadId,
6817
+ group: opts.group,
6818
+ text: chunkText,
6819
+ msgType: "text",
6820
+ response: responses[index],
6821
+ rawPayload: chunk
6822
+ });
6823
+ }
6540
6824
  });
6541
6825
  }
6542
6826
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzca",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "description": "Open-source zca-compatible CLI to integrate Zalo with OpenClaw",
5
5
  "type": "module",
6
6
  "bin": {