@soimy/dingtalk 3.6.2-beta.1 → 3.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,9 +2,6 @@
2
2
  import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
3
3
  import { readStringParam as readStringParam2 } from "openclaw/plugin-sdk/param-readers";
4
4
 
5
- // src/channel.ts
6
- import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
7
-
8
5
  // src/secret-input.ts
9
6
  import { readFile } from "node:fs/promises";
10
7
  import { z } from "zod";
@@ -410,1240 +407,1273 @@ function resolveDingTalkAccount(cfg, accountId) {
410
407
  };
411
408
  }
412
409
 
413
- // src/config-schema.ts
414
- import { z as z2 } from "zod";
410
+ // src/http-client.ts
411
+ import axios from "axios";
412
+ var DEFAULT_HTTP_TIMEOUT_MS = 1e4;
413
+ var httpClient = typeof axios?.create === "function" ? axios.create({ timeout: DEFAULT_HTTP_TIMEOUT_MS }) : axios;
414
+ httpClient.isAxiosError = axios.isAxiosError;
415
+ var http_client_default = httpClient;
415
416
 
416
- // src/persistence-store.ts
417
+ // src/utils.ts
418
+ import * as dns from "node:dns";
417
419
  import * as fs from "node:fs";
420
+ import * as net from "node:net";
421
+ import * as os2 from "node:os";
418
422
  import * as path2 from "node:path";
419
- var NAMESPACE_ROOT_DIR = "dingtalk-state";
420
- function toErrorMessage(err) {
421
- if (err instanceof Error) {
422
- return err.message;
423
- }
424
- try {
425
- return JSON.stringify(err);
426
- } catch {
427
- return String(err);
428
- }
429
- }
430
- function sanitizeSegment(value) {
431
- return value.replace(/[^a-zA-Z0-9._-]/g, "_");
423
+ var pluginDebugWriters = /* @__PURE__ */ new Map();
424
+ var closedPluginDebugScopes = /* @__PURE__ */ new Set();
425
+ function padNumber(value, width = 2) {
426
+ return String(value).padStart(width, "0");
432
427
  }
433
- function encodeScopeValue(value) {
434
- return Buffer.from(value, "utf8").toString("base64url");
428
+ function formatTimezoneOffset(date) {
429
+ const offsetMinutes = -date.getTimezoneOffset();
430
+ const sign = offsetMinutes >= 0 ? "+" : "-";
431
+ const absoluteMinutes = Math.abs(offsetMinutes);
432
+ const hours = Math.floor(absoluteMinutes / 60);
433
+ const minutes = absoluteMinutes % 60;
434
+ return `${sign}${padNumber(hours)}:${padNumber(minutes)}`;
435
435
  }
436
- function buildScopeSuffix(scope) {
437
- if (!scope) {
438
- return "";
439
- }
440
- const ordered = [
441
- ["accountId", scope.accountId],
442
- ["agentId", scope.agentId],
443
- ["conversationId", scope.conversationId],
444
- ["groupId", scope.groupId],
445
- ["targetId", scope.targetId]
446
- ];
447
- const segments = ordered.filter(([, value]) => Boolean(value && value.trim())).map(([key, value]) => `${key.replace(/Id$/, "")}-${encodeScopeValue((value || "").trim())}`);
448
- if (segments.length === 0) {
449
- return "";
450
- }
451
- return `.${segments.join(".")}`;
436
+ function formatPluginDebugTimestamp(date) {
437
+ return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())} ${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}.${padNumber(date.getMilliseconds(), 3)}${formatTimezoneOffset(date)}`;
452
438
  }
453
- function resolveNamespacePath(namespace, options) {
454
- const format = options.format || "json";
455
- const baseDir = path2.join(path2.dirname(options.storePath), NAMESPACE_ROOT_DIR);
456
- const safeNamespace = sanitizeSegment(namespace.trim());
457
- const suffix = buildScopeSuffix(options.scope);
458
- return path2.join(baseDir, `${safeNamespace}${suffix}.${format}`);
439
+ function formatPluginDebugDate(date) {
440
+ return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`;
459
441
  }
460
- function readNamespaceJson(namespace, options) {
461
- const filePath = resolveNamespacePath(namespace, options);
462
- try {
463
- if (!fs.existsSync(filePath)) {
464
- return options.fallback;
465
- }
466
- const raw = fs.readFileSync(filePath, "utf-8");
467
- if (!raw.trim()) {
468
- return options.fallback;
469
- }
470
- return JSON.parse(raw);
471
- } catch (err) {
472
- options.log?.warn?.(
473
- `[DingTalk][Persistence] Failed to read namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`
474
- );
475
- return options.fallback;
476
- }
442
+ function resolvePluginDebugLogFilePath(params) {
443
+ return path2.join(
444
+ path2.dirname(params.storePath),
445
+ "logs",
446
+ "dingtalk",
447
+ params.accountId,
448
+ `debug-${formatPluginDebugDate(params.date)}.log`
449
+ );
477
450
  }
478
- function writeNamespaceJsonAtomic(namespace, options) {
479
- const filePath = resolveNamespacePath(namespace, options);
480
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
481
- try {
482
- fs.mkdirSync(path2.dirname(filePath), { recursive: true });
483
- fs.writeFileSync(tempPath, JSON.stringify(options.data, null, 2));
484
- try {
485
- fs.renameSync(tempPath, filePath);
486
- } catch (err) {
487
- if (fs.existsSync(filePath)) {
488
- fs.rmSync(filePath, { force: true });
489
- fs.renameSync(tempPath, filePath);
490
- } else {
491
- throw err;
492
- }
493
- }
494
- } catch (err) {
495
- options.log?.warn?.(
496
- `[DingTalk][Persistence] Failed to write namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`
497
- );
498
- if (fs.existsSync(tempPath)) {
499
- fs.rmSync(tempPath, { force: true });
500
- }
501
- }
451
+ function formatPluginDebugLine(params) {
452
+ return `[${formatPluginDebugTimestamp(params.date)}] [debug] [dingtalk] [account:${params.accountId}] ${params.message}`;
502
453
  }
503
-
504
- // src/message-context-store.ts
505
- var MESSAGE_CONTEXT_NAMESPACE = "messages.context";
506
- var MESSAGE_CONTEXT_VERSION = 1;
507
- var DEFAULT_MESSAGE_CONTEXT_TTL_DAYS = 7;
508
- var DEFAULT_CARD_CONTENT_TTL_MS = 24 * 60 * 60 * 1e3;
509
- var DEFAULT_MEDIA_CONTEXT_TTL_MS = 24 * 60 * 60 * 1e3;
510
- var DEFAULT_CREATED_AT_MATCH_WINDOW_MS = 2e3;
511
- var MAX_RECORDS_PER_SCOPE = 1e3;
512
- var DEFAULT_OUTBOUND_SENDER = {
513
- senderId: "bot",
514
- senderName: "OpenClaw"
515
- };
516
- function inferConversationChatType(conversationId) {
517
- return conversationId.startsWith("cid") ? "group" : "direct";
454
+ function buildPluginDebugWriterKey(params) {
455
+ return JSON.stringify([params.storePath, params.accountId, formatPluginDebugDate(params.date)]);
518
456
  }
519
- var stateCache = /* @__PURE__ */ new Map();
520
- function getScopeKey(params) {
521
- return JSON.stringify([
522
- params.storePath || "__memory__",
523
- params.accountId,
524
- params.conversationId || null
525
- ]);
457
+ function buildPluginDebugScopeKey(params) {
458
+ return JSON.stringify([params.storePath, params.accountId]);
526
459
  }
527
- function fallbackState() {
528
- return {
529
- version: MESSAGE_CONTEXT_VERSION,
530
- updatedAt: Date.now(),
531
- records: {},
532
- byAlias: {},
533
- recentByCreatedAt: []
460
+ function resolvePluginDebugWriter(params) {
461
+ const key = buildPluginDebugWriterKey(params);
462
+ const existing = pluginDebugWriters.get(key);
463
+ if (existing) {
464
+ return existing;
465
+ }
466
+ const created = {
467
+ filePath: resolvePluginDebugLogFilePath(params),
468
+ warned: false,
469
+ directoryReady: false
534
470
  };
471
+ pluginDebugWriters.set(key, created);
472
+ return created;
535
473
  }
536
- function asRecord(value) {
537
- if (!value || typeof value !== "object" || Array.isArray(value)) {
538
- return null;
474
+ function resolvePluginDebugLog(params) {
475
+ const baseLog = params.baseLog;
476
+ const fsImpl = params.fsImpl ?? fs;
477
+ const scopeKey = params.storePath ? buildPluginDebugScopeKey({ storePath: params.storePath, accountId: params.accountId }) : void 0;
478
+ if (scopeKey) {
479
+ closedPluginDebugScopes.delete(scopeKey);
539
480
  }
540
- return value;
481
+ return {
482
+ debug: (message) => {
483
+ if (!params.debug) {
484
+ baseLog?.debug?.(message);
485
+ return;
486
+ }
487
+ const date = params.now ? params.now() : /* @__PURE__ */ new Date();
488
+ const line = formatPluginDebugLine({
489
+ accountId: params.accountId,
490
+ date,
491
+ message
492
+ });
493
+ try {
494
+ process.stdout.write(`${line}
495
+ `);
496
+ } catch {
497
+ }
498
+ if (params.storePath && scopeKey && !closedPluginDebugScopes.has(scopeKey)) {
499
+ const writer = resolvePluginDebugWriter({
500
+ storePath: params.storePath,
501
+ accountId: params.accountId,
502
+ date
503
+ });
504
+ try {
505
+ if (!writer.directoryReady) {
506
+ fsImpl.mkdirSync(path2.dirname(writer.filePath), { recursive: true });
507
+ writer.directoryReady = true;
508
+ }
509
+ fsImpl.appendFileSync(writer.filePath, `${line}
510
+ `, "utf8");
511
+ } catch (err) {
512
+ if (!writer.warned) {
513
+ writer.warned = true;
514
+ baseLog?.warn?.(
515
+ `[DingTalk] Plugin debug log file unavailable: accountId=${params.accountId} path=${writer.filePath} error=${getErrorMessage(err)}`
516
+ );
517
+ }
518
+ }
519
+ }
520
+ try {
521
+ baseLog?.debug?.(message);
522
+ } catch {
523
+ }
524
+ },
525
+ info: (message) => baseLog?.info?.(message),
526
+ warn: (message) => baseLog?.warn?.(message),
527
+ error: (message) => baseLog?.error?.(message)
528
+ };
541
529
  }
542
- function normalizeMedia(value) {
543
- const candidate = asRecord(value);
544
- if (!candidate) {
545
- return void 0;
530
+ function closePluginDebugLog(params) {
531
+ if (!params.storePath) {
532
+ return;
546
533
  }
547
- const downloadCode = typeof candidate.downloadCode === "string" && candidate.downloadCode.trim() ? candidate.downloadCode.trim() : void 0;
548
- const downloadCodes = Array.isArray(candidate.downloadCodes) && candidate.downloadCodes.length > 0 ? candidate.downloadCodes.filter((c) => typeof c === "string" && c.trim()) : void 0;
549
- const spaceId = typeof candidate.spaceId === "string" && candidate.spaceId.trim() ? candidate.spaceId.trim() : void 0;
550
- const fileId = typeof candidate.fileId === "string" && candidate.fileId.trim() ? candidate.fileId.trim() : void 0;
551
- if (!downloadCode && (!downloadCodes || downloadCodes.length === 0) && !spaceId && !fileId) {
552
- return void 0;
534
+ const scopeKey = buildPluginDebugScopeKey({
535
+ storePath: params.storePath,
536
+ accountId: params.accountId
537
+ });
538
+ closedPluginDebugScopes.add(scopeKey);
539
+ for (const key of pluginDebugWriters.keys()) {
540
+ const [writerStorePath, writerAccountId] = JSON.parse(key);
541
+ if (writerStorePath === params.storePath && writerAccountId === params.accountId) {
542
+ pluginDebugWriters.delete(key);
543
+ }
553
544
  }
554
- return { downloadCode, downloadCodes, spaceId, fileId };
555
545
  }
556
- function normalizeQuotedRef(value) {
557
- const candidate = asRecord(value);
558
- if (!candidate) {
559
- return void 0;
560
- }
561
- const targetDirection = candidate.targetDirection === "outbound" ? "outbound" : candidate.targetDirection === "inbound" ? "inbound" : void 0;
562
- const key = candidate.key === "msgId" || candidate.key === "messageId" || candidate.key === "processQueryKey" || candidate.key === "outTrackId" || candidate.key === "cardInstanceId" ? candidate.key : void 0;
563
- const valueString = typeof candidate.value === "string" && candidate.value.trim() ? candidate.value.trim() : void 0;
564
- const fallbackCreatedAt = typeof candidate.fallbackCreatedAt === "number" && Number.isFinite(candidate.fallbackCreatedAt) ? candidate.fallbackCreatedAt : void 0;
565
- if (!targetDirection) {
566
- return void 0;
567
- }
568
- if (!key && !fallbackCreatedAt) {
569
- return void 0;
546
+ function maskSensitiveData(data) {
547
+ if (data === null || data === void 0) {
548
+ return data;
570
549
  }
571
- if (key && !valueString) {
572
- return void 0;
550
+ if (typeof data !== "object") {
551
+ return data;
573
552
  }
574
- return {
575
- targetDirection,
576
- key,
577
- value: valueString,
578
- fallbackCreatedAt
579
- };
580
- }
581
- function normalizeAttachmentTextSource(value) {
582
- return value === "text" || value === "html" || value === "pdf" || value === "docx" ? value : void 0;
583
- }
584
- function normalizeDelivery(value) {
585
- const candidate = asRecord(value);
586
- if (!candidate) {
587
- return void 0;
588
- }
589
- const messageId = typeof candidate.messageId === "string" && candidate.messageId.trim() ? candidate.messageId.trim() : void 0;
590
- const processQueryKey = typeof candidate.processQueryKey === "string" && candidate.processQueryKey.trim() ? candidate.processQueryKey.trim() : void 0;
591
- const outTrackId = typeof candidate.outTrackId === "string" && candidate.outTrackId.trim() ? candidate.outTrackId.trim() : void 0;
592
- const cardInstanceId = typeof candidate.cardInstanceId === "string" && candidate.cardInstanceId.trim() ? candidate.cardInstanceId.trim() : void 0;
593
- const kind = typeof candidate.kind === "string" && candidate.kind.trim() ? candidate.kind.trim() : void 0;
594
- if (!messageId && !processQueryKey && !outTrackId && !cardInstanceId && !kind) {
595
- return void 0;
553
+ const masked = JSON.parse(JSON.stringify(data));
554
+ const sensitiveFields = /* @__PURE__ */ new Set(["token", "accessToken"]);
555
+ function maskObj(obj) {
556
+ for (const key in obj) {
557
+ if (sensitiveFields.has(key)) {
558
+ const val = obj[key];
559
+ if (typeof val === "string" && val.length > 6) {
560
+ obj[key] = val.slice(0, 3) + "*".repeat(val.length - 6) + val.slice(-3);
561
+ } else if (typeof val === "string") {
562
+ obj[key] = "*".repeat(val.length);
563
+ }
564
+ } else if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
565
+ maskObj(obj[key]);
566
+ }
567
+ }
596
568
  }
597
- return { messageId, processQueryKey, outTrackId, cardInstanceId, kind };
569
+ maskObj(masked);
570
+ return masked;
598
571
  }
599
- function normalizeMentions(value) {
600
- if (!Array.isArray(value)) {
601
- return void 0;
572
+ function stringifyUnknown(value) {
573
+ if (typeof value === "string") {
574
+ return value;
602
575
  }
603
- const normalized = [...new Set(value.map((item) => String(item || "").trim()).filter(Boolean))];
604
- return normalized.length > 0 ? normalized : void 0;
605
- }
606
- function normalizeMessageRecord(value) {
607
- const candidate = asRecord(value);
608
- if (!candidate) {
609
- return null;
576
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
577
+ return String(value);
610
578
  }
611
- const msgId = typeof candidate.msgId === "string" && candidate.msgId.trim() ? candidate.msgId.trim() : "";
612
- const direction = candidate.direction === "outbound" ? "outbound" : candidate.direction === "inbound" ? "inbound" : null;
613
- const accountId = typeof candidate.accountId === "string" && candidate.accountId.trim() ? candidate.accountId.trim() : "";
614
- const conversationId = typeof candidate.conversationId === "string" && candidate.conversationId.trim() ? candidate.conversationId.trim() : null;
615
- const createdAt = typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : NaN;
616
- const updatedAt = typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt) ? candidate.updatedAt : Date.now();
617
- if (!msgId || !direction || !accountId || !Number.isFinite(createdAt)) {
618
- return null;
579
+ try {
580
+ const serialized = JSON.stringify(value);
581
+ return serialized ?? String(value);
582
+ } catch {
583
+ return "[unserializable]";
619
584
  }
620
- const expiresAt = typeof candidate.expiresAt === "number" && Number.isFinite(candidate.expiresAt) ? candidate.expiresAt : void 0;
621
- return {
622
- msgId,
623
- direction,
624
- topic: candidate.topic === null ? null : typeof candidate.topic === "string" ? candidate.topic : null,
625
- accountId,
626
- conversationId,
627
- createdAt,
628
- updatedAt,
629
- expiresAt,
630
- messageType: typeof candidate.messageType === "string" ? candidate.messageType : void 0,
631
- text: typeof candidate.text === "string" ? candidate.text : void 0,
632
- attachmentText: typeof candidate.attachmentText === "string" ? candidate.attachmentText : void 0,
633
- attachmentTextSource: normalizeAttachmentTextSource(candidate.attachmentTextSource),
634
- attachmentTextTruncated: candidate.attachmentTextTruncated === true ? true : void 0,
635
- attachmentFileName: typeof candidate.attachmentFileName === "string" ? candidate.attachmentFileName : void 0,
636
- quotedRef: normalizeQuotedRef(candidate.quotedRef),
637
- senderId: typeof candidate.senderId === "string" && candidate.senderId.trim() ? candidate.senderId.trim() : void 0,
638
- senderName: typeof candidate.senderName === "string" && candidate.senderName.trim() ? candidate.senderName.trim() : void 0,
639
- mentions: normalizeMentions(candidate.mentions),
640
- chatType: candidate.chatType === "direct" || candidate.chatType === "group" ? candidate.chatType : void 0,
641
- quotedMessageId: typeof candidate.quotedMessageId === "string" && candidate.quotedMessageId.trim() ? candidate.quotedMessageId.trim() : void 0,
642
- media: normalizeMedia(candidate.media),
643
- delivery: normalizeDelivery(candidate.delivery)
644
- };
645
585
  }
646
- function buildAliasKey(kind, value) {
647
- return `${kind}:${value.trim()}`;
648
- }
649
- function buildAliasEntries(record) {
650
- const aliases = [];
651
- if (record.direction === "inbound" && record.msgId.trim()) {
652
- aliases.push([buildAliasKey("inboundMsgId", record.msgId), record.msgId]);
653
- }
654
- const delivery = record.delivery;
655
- if (!delivery) {
656
- return aliases;
586
+ function parseBooleanLike(value) {
587
+ if (typeof value === "boolean") {
588
+ return value;
657
589
  }
658
- if (delivery.messageId) {
659
- aliases.push([buildAliasKey("messageId", delivery.messageId), record.msgId]);
590
+ if (typeof value === "number") {
591
+ if (value === 1) {
592
+ return true;
593
+ }
594
+ if (value === 0) {
595
+ return false;
596
+ }
597
+ return void 0;
660
598
  }
661
- if (delivery.processQueryKey) {
662
- aliases.push([buildAliasKey("processQueryKey", delivery.processQueryKey), record.msgId]);
599
+ if (typeof value === "string") {
600
+ const normalized = value.trim().toLowerCase();
601
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) {
602
+ return true;
603
+ }
604
+ if (["0", "false", "no", "n", "off"].includes(normalized)) {
605
+ return false;
606
+ }
663
607
  }
664
- if (delivery.outTrackId) {
665
- aliases.push([buildAliasKey("outTrackId", delivery.outTrackId), record.msgId]);
608
+ return void 0;
609
+ }
610
+ function getErrorMessage(err) {
611
+ if (err instanceof Error && err.message) {
612
+ return err.message;
666
613
  }
667
- if (delivery.cardInstanceId) {
668
- aliases.push([buildAliasKey("cardInstanceId", delivery.cardInstanceId), record.msgId]);
614
+ if (err && typeof err === "object") {
615
+ const record = err;
616
+ if (typeof record.message === "string" && record.message.trim()) {
617
+ return record.message;
618
+ }
669
619
  }
670
- return aliases;
620
+ return stringifyUnknown(err);
671
621
  }
672
- function isRecordExpired(record, nowMs) {
673
- return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt) && nowMs >= record.expiresAt;
622
+ function getErrorResponseData(err) {
623
+ if (!err || typeof err !== "object") {
624
+ return void 0;
625
+ }
626
+ return err.response?.data;
674
627
  }
675
- function normalizeState(state, nowMs) {
676
- const normalizedRecords = {};
677
- const sortedRecords = Object.values(state.records).map((record) => normalizeMessageRecord(record)).filter((record) => record !== null).filter((record) => !isRecordExpired(record, nowMs)).toSorted((left, right) => left.createdAt - right.createdAt);
678
- const keptRecords = sortedRecords.slice(-MAX_RECORDS_PER_SCOPE);
679
- for (const record of keptRecords) {
680
- normalizedRecords[record.msgId] = record;
628
+ function formatDingTalkErrorPayload(payload) {
629
+ if (payload === null || payload === void 0) {
630
+ return "payload=unknown";
681
631
  }
682
- const byAlias = {};
683
- for (const record of keptRecords) {
684
- for (const [key, value] of buildAliasEntries(record)) {
685
- byAlias[key] = value;
632
+ let code;
633
+ let message;
634
+ if (typeof payload === "object" && !Array.isArray(payload)) {
635
+ const obj = payload;
636
+ if (typeof obj.code === "string" || typeof obj.code === "number") {
637
+ code = String(obj.code);
686
638
  }
687
- }
688
- return {
689
- removed: Object.keys(state.records).length - Object.keys(normalizedRecords).length,
690
- state: {
691
- version: MESSAGE_CONTEXT_VERSION,
692
- updatedAt: state.updatedAt,
693
- records: normalizedRecords,
694
- byAlias,
695
- recentByCreatedAt: keptRecords.map((record) => record.msgId)
639
+ if (typeof obj.message === "string") {
640
+ message = obj.message;
696
641
  }
697
- };
698
- }
699
- function hydrateState(params, nowMs) {
700
- if (!params.storePath) {
701
- return fallbackState();
702
642
  }
703
- const persisted = readNamespaceJson(
704
- MESSAGE_CONTEXT_NAMESPACE,
705
- {
706
- storePath: params.storePath,
707
- scope: { accountId: params.accountId, conversationId: params.conversationId || void 0 },
708
- format: "json",
709
- fallback: { version: MESSAGE_CONTEXT_VERSION, updatedAt: Date.now(), records: {} }
710
- }
711
- );
712
- const parsedRecords = asRecord(persisted.records) || {};
713
- const hydrated = fallbackState();
714
- hydrated.updatedAt = typeof persisted.updatedAt === "number" ? persisted.updatedAt : Date.now();
715
- for (const [key, value] of Object.entries(parsedRecords)) {
716
- const normalized = normalizeMessageRecord(value);
717
- if (normalized) {
718
- hydrated.records[key] = normalized;
643
+ let serialized;
644
+ try {
645
+ serialized = JSON.stringify(maskSensitiveData(payload));
646
+ } catch {
647
+ if (typeof payload === "string") {
648
+ serialized = payload;
649
+ } else if (typeof payload === "number" || typeof payload === "boolean" || typeof payload === "bigint") {
650
+ serialized = `${payload}`;
651
+ } else {
652
+ serialized = "[unserializable-payload]";
719
653
  }
720
654
  }
721
- return normalizeState(hydrated, nowMs).state;
722
- }
723
- function loadState(params, nowMs = Date.now()) {
724
- const scopeKey = getScopeKey(params);
725
- const cached = stateCache.get(scopeKey);
726
- if (cached) {
727
- return cached;
655
+ const parts = [];
656
+ if (code) {
657
+ parts.push(`code=${code}`);
728
658
  }
729
- const hydrated = params.storePath ? hydrateState(params, nowMs) : fallbackState();
730
- stateCache.set(scopeKey, hydrated);
731
- return hydrated;
732
- }
733
- function writeState(params, state) {
734
- stateCache.set(getScopeKey(params), state);
735
- if (!params.storePath) {
736
- return;
659
+ if (message) {
660
+ parts.push(`message=${message}`);
737
661
  }
738
- writeNamespaceJsonAtomic(MESSAGE_CONTEXT_NAMESPACE, {
739
- storePath: params.storePath,
740
- scope: { accountId: params.accountId, conversationId: params.conversationId || void 0 },
741
- format: "json",
742
- data: {
743
- version: MESSAGE_CONTEXT_VERSION,
744
- updatedAt: state.updatedAt,
745
- records: state.records
746
- }
747
- });
662
+ parts.push(`payload=${serialized}`);
663
+ return parts.join(" ");
748
664
  }
749
- function cloneStateForMutation(state) {
750
- return {
751
- version: state.version,
752
- updatedAt: state.updatedAt,
753
- records: { ...state.records },
754
- byAlias: state.byAlias,
755
- recentByCreatedAt: state.recentByCreatedAt
756
- };
665
+ function formatDingTalkErrorPayloadLog(scope, payload, prefix = "[DingTalk]") {
666
+ return `${prefix}[ErrorPayload][${scope}] ${formatDingTalkErrorPayload(payload)}`;
757
667
  }
758
- function mergeText(existing, next) {
759
- if (typeof next !== "string") {
760
- return existing;
761
- }
762
- return next;
668
+ function getProxyBypassOption(config) {
669
+ return config?.bypassProxyForSend ? { proxy: false } : {};
763
670
  }
764
- function mergeAttachmentText(existing, next) {
765
- if (typeof next !== "string") {
766
- return existing;
767
- }
768
- return next;
671
+ function createResolve4FallbackLookup(log, accountId) {
672
+ return createResolve4FallbackLookupWithDeps(log, accountId, dns, net);
769
673
  }
770
- function mergeQuotedRef(existing, next) {
771
- if (!existing) {
772
- return next;
773
- }
774
- if (!next) {
775
- return existing;
776
- }
777
- return {
778
- targetDirection: next.targetDirection,
779
- key: next.key || existing.key,
780
- value: next.value || existing.value,
781
- fallbackCreatedAt: next.fallbackCreatedAt ?? existing.fallbackCreatedAt
674
+ function createResolve4FallbackLookupWithDeps(log, accountId, dnsImpl, netImpl) {
675
+ let fallbackLogged = false;
676
+ return (hostname, options, callback) => {
677
+ const ipFamily = netImpl.isIP(hostname);
678
+ if (ipFamily !== 0) {
679
+ if (options.all) {
680
+ callback(null, [{ address: hostname, family: ipFamily }], ipFamily);
681
+ return;
682
+ }
683
+ callback(null, hostname, ipFamily);
684
+ return;
685
+ }
686
+ dnsImpl.lookup(hostname, options, (lookupErr, address, family) => {
687
+ if (!lookupErr) {
688
+ callback(null, address, family);
689
+ return;
690
+ }
691
+ if (lookupErr.code !== "ENOTFOUND") {
692
+ callback(lookupErr, address, family);
693
+ return;
694
+ }
695
+ dnsImpl.resolve4(hostname, (resolveErr, addresses) => {
696
+ if (resolveErr || !addresses || addresses.length === 0) {
697
+ callback(lookupErr, address, family);
698
+ return;
699
+ }
700
+ if (!fallbackLogged) {
701
+ fallbackLogged = true;
702
+ log?.warn?.(
703
+ `[${accountId ?? "default"}] System DNS lookup failed for ${hostname} (ENOTFOUND); using resolve4 fallback ${addresses[0]}`
704
+ );
705
+ }
706
+ if (options.all) {
707
+ callback(
708
+ null,
709
+ addresses.map((item) => ({ address: item, family: 4 })),
710
+ 4
711
+ );
712
+ return;
713
+ }
714
+ callback(null, addresses[0], 4);
715
+ });
716
+ });
782
717
  };
783
718
  }
784
- function mergeStringField(existing, next) {
785
- if (typeof next !== "string" || !next.trim()) {
786
- return existing;
719
+ function getHeaderCaseInsensitive(headers, key) {
720
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
721
+ return void 0;
787
722
  }
788
- return next.trim();
789
- }
790
- function mergeMentions(existing, next) {
791
- if (!next) {
792
- return existing;
723
+ const entries = Object.entries(headers);
724
+ const matched = entries.find(([name]) => name.toLowerCase() === key.toLowerCase());
725
+ if (!matched) {
726
+ return void 0;
793
727
  }
794
- return normalizeMentions(next) || existing;
795
- }
796
- function mergeMedia(existing, next) {
797
- if (!existing) {
798
- return next;
728
+ const value = matched[1];
729
+ if (Array.isArray(value)) {
730
+ return value.length > 0 ? String(value[0]) : void 0;
799
731
  }
800
- if (!next) {
801
- return existing;
732
+ if (value === null || value === void 0) {
733
+ return void 0;
802
734
  }
803
- return {
804
- downloadCode: next.downloadCode || existing.downloadCode,
805
- downloadCodes: next.downloadCodes || existing.downloadCodes,
806
- spaceId: next.spaceId || existing.spaceId,
807
- fileId: next.fileId || existing.fileId
808
- };
809
- }
810
- function mergeDelivery(existing, next) {
811
- if (!existing) {
812
- return next;
735
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
736
+ return String(value);
813
737
  }
814
- if (!next) {
815
- return existing;
738
+ try {
739
+ return JSON.stringify(value);
740
+ } catch {
741
+ return void 0;
816
742
  }
817
- return {
818
- messageId: next.messageId || existing.messageId,
819
- processQueryKey: next.processQueryKey || existing.processQueryKey,
820
- outTrackId: next.outTrackId || existing.outTrackId,
821
- cardInstanceId: next.cardInstanceId || existing.cardInstanceId,
822
- kind: next.kind || existing.kind
823
- };
824
- }
825
- function mergeAttachmentTextSource(existing, next) {
826
- return next ?? existing;
827
743
  }
828
- function mergeAttachmentTextTruncated(existing, next) {
829
- return next === void 0 ? existing : next;
830
- }
831
- function mergeAttachmentFileName(existing, next) {
832
- if (typeof next !== "string") {
833
- return existing;
744
+ function formatDingTalkConnectionErrorLog(scope, err, baseMessage) {
745
+ if (!err || typeof err !== "object") {
746
+ return null;
834
747
  }
835
- return next;
836
- }
837
- function resolveExistingMsgId(state, params, nowMs) {
838
- if (params.direction === "inbound" && params.msgId) {
839
- const direct = state.records[params.msgId];
840
- if (direct && !isRecordExpired(direct, nowMs)) {
841
- return params.msgId;
842
- }
748
+ const errRecord = err;
749
+ const stage = typeof errRecord.dingtalkConnectionStage === "string" ? errRecord.dingtalkConnectionStage : scope;
750
+ const endpoint = typeof errRecord.dingtalkConnectionEndpoint === "string" ? errRecord.dingtalkConnectionEndpoint : void 0;
751
+ const hasResponse = "response" in errRecord && errRecord.response !== null && errRecord.response !== void 0;
752
+ if (!hasResponse && !endpoint && stage === scope) {
753
+ return null;
843
754
  }
844
- const delivery = params.delivery;
845
- if (!delivery) {
846
- return void 0;
755
+ const parts = [`${baseMessage} [DingTalk][ConnectionError][${stage}]`];
756
+ if (endpoint) {
757
+ parts.push(`endpoint=${endpoint}`);
847
758
  }
848
- const candidates = [
849
- ["messageId", delivery.messageId],
850
- ["processQueryKey", delivery.processQueryKey],
851
- ["outTrackId", delivery.outTrackId],
852
- ["cardInstanceId", delivery.cardInstanceId]
853
- ];
854
- for (const [kind, value] of candidates) {
855
- if (!value) {
856
- continue;
759
+ const response = err.response;
760
+ if (response) {
761
+ if (response.status !== void 0 && response.status !== null) {
762
+ const statusText = typeof response.status === "string" || typeof response.status === "number" || typeof response.status === "boolean" || typeof response.status === "bigint" ? String(response.status) : JSON.stringify(response.status);
763
+ parts.push(`status=${statusText}`);
857
764
  }
858
- const aliasHit = state.byAlias[buildAliasKey(kind, value)];
859
- if (!aliasHit) {
860
- continue;
765
+ let requestId = getHeaderCaseInsensitive(response.headers, "x-acs-dingtalk-request-id");
766
+ if (!requestId && response.data && typeof response.data === "object" && !Array.isArray(response.data)) {
767
+ const data = response.data;
768
+ if (typeof data.requestId === "string") {
769
+ requestId = data.requestId;
770
+ } else if (typeof data.requestid === "string") {
771
+ requestId = data.requestid;
772
+ }
861
773
  }
862
- const record = state.records[aliasHit];
863
- if (record && !isRecordExpired(record, nowMs)) {
864
- return aliasHit;
774
+ if (requestId) {
775
+ parts.push(`requestId=${requestId}`);
776
+ }
777
+ if (response.data !== void 0) {
778
+ parts.push(formatDingTalkErrorPayload(response.data));
865
779
  }
866
780
  }
867
- return void 0;
868
- }
869
- function computeExpiresAt(nowMs, ttlMs, ttlReferenceMs) {
870
- if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs <= 0) {
871
- return void 0;
781
+ if (stage === "connect.websocket") {
782
+ parts.push("Likely websocket/proxy/WSS issue after connections/open succeeded");
872
783
  }
873
- return (typeof ttlReferenceMs === "number" && Number.isFinite(ttlReferenceMs) ? ttlReferenceMs : nowMs) + ttlMs;
784
+ parts.push("See docs/connection-troubleshooting.md or run scripts/dingtalk-connection-check.*");
785
+ return parts.join(" ");
874
786
  }
875
- function pruneStateByCreatedAt(state, ttlDays, nowMs) {
876
- if (!ttlDays || ttlDays <= 0) {
877
- return { state, removed: 0 };
878
- }
879
- const cutoff = nowMs - ttlDays * 24 * 60 * 60 * 1e3;
880
- const nextRecords = {};
881
- for (const [msgId, record] of Object.entries(state.records)) {
882
- if (record.createdAt >= cutoff) {
883
- nextRecords[msgId] = record;
787
+ function cleanupOrphanedTempFiles(log) {
788
+ const tempDir = os2.tmpdir();
789
+ const dingtalkPattern = /^dingtalk_\d+\..+$/;
790
+ let cleaned = 0;
791
+ try {
792
+ const files = fs.readdirSync(tempDir);
793
+ const now = Date.now();
794
+ const maxAge = 24 * 60 * 60 * 1e3;
795
+ for (const file of files) {
796
+ if (!dingtalkPattern.test(file)) {
797
+ continue;
798
+ }
799
+ const filePath = path2.join(tempDir, file);
800
+ try {
801
+ const stats = fs.statSync(filePath);
802
+ if (now - stats.mtime.getTime() > maxAge) {
803
+ fs.unlinkSync(filePath);
804
+ cleaned++;
805
+ log?.debug?.(`[DingTalk] Cleaned up orphaned temp file: ${file}`);
806
+ }
807
+ } catch (err) {
808
+ log?.debug?.(`[DingTalk] Failed to cleanup temp file ${file}: ${getErrorMessage(err)}`);
809
+ }
884
810
  }
811
+ if (cleaned > 0) {
812
+ log?.info?.(`[DingTalk] Cleaned up ${cleaned} orphaned temp files`);
813
+ }
814
+ } catch (err) {
815
+ log?.debug?.(`[DingTalk] Failed to cleanup temp directory: ${getErrorMessage(err)}`);
885
816
  }
886
- const removed = Object.keys(state.records).length - Object.keys(nextRecords).length;
887
- if (removed === 0) {
888
- return { state, removed: 0 };
889
- }
890
- return {
891
- removed,
892
- state: {
893
- ...state,
894
- records: nextRecords
817
+ return cleaned;
818
+ }
819
+ async function retryWithBackoff(fn, options = {}) {
820
+ const { maxRetries = 3, baseDelayMs = 100, log } = options;
821
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
822
+ try {
823
+ return await fn();
824
+ } catch (err) {
825
+ const statusCode = err.response?.status;
826
+ const isRetryable = statusCode === 401 || statusCode === 429 || statusCode && statusCode >= 500;
827
+ const responseData = getErrorResponseData(err);
828
+ if (responseData !== void 0) {
829
+ log?.debug?.(formatDingTalkErrorPayloadLog("retry.beforeDecision", responseData));
830
+ }
831
+ if (!isRetryable || attempt === maxRetries) {
832
+ throw err;
833
+ }
834
+ const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
835
+ log?.debug?.(`[DingTalk] Retry attempt ${attempt}/${maxRetries} after ${delayMs}ms`);
836
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
895
837
  }
896
- };
838
+ }
839
+ throw new Error("Retry exhausted without returning");
897
840
  }
898
- function upsertRecord(params) {
899
- const nowMs = params.updatedAt ?? Date.now();
900
- let state = cloneStateForMutation(loadState(params, nowMs));
901
- if (params.cleanupCreatedAtTtlDays && params.cleanupCreatedAtTtlDays > 0) {
902
- state = pruneStateByCreatedAt(state, params.cleanupCreatedAtTtlDays, nowMs).state;
841
+ function getCurrentTimestamp() {
842
+ return Date.now();
843
+ }
844
+
845
+ // src/auth.ts
846
+ var accessTokenCache = /* @__PURE__ */ new Map();
847
+ async function getAccessToken(config, log) {
848
+ const cacheKey = config.clientId;
849
+ const now = Date.now();
850
+ const cached = accessTokenCache.get(cacheKey);
851
+ if (cached && cached.expiry > now + 6e4) {
852
+ return cached.accessToken;
903
853
  }
904
- const existingMsgId = resolveExistingMsgId(
905
- state,
906
- {
907
- direction: params.direction,
908
- msgId: params.msgId,
909
- delivery: params.delivery
854
+ const runtimeConfig = await resolveRuntimeConfig(config, log);
855
+ const token = await retryWithBackoff(
856
+ async () => {
857
+ const response = await http_client_default.post(
858
+ "https://api.dingtalk.com/v1.0/oauth2/accessToken",
859
+ {
860
+ appKey: runtimeConfig.clientId,
861
+ appSecret: runtimeConfig.clientSecret
862
+ }
863
+ );
864
+ accessTokenCache.set(cacheKey, {
865
+ accessToken: response.data.accessToken,
866
+ expiry: now + response.data.expireIn * 1e3
867
+ });
868
+ return response.data.accessToken;
910
869
  },
911
- nowMs
870
+ { maxRetries: 3, log }
912
871
  );
913
- const canonicalMsgId = existingMsgId || params.msgId || params.delivery?.messageId || params.delivery?.processQueryKey || params.delivery?.outTrackId;
914
- if (!canonicalMsgId || !canonicalMsgId.trim()) {
915
- return void 0;
872
+ return token;
873
+ }
874
+
875
+ // src/channel.ts
876
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
877
+
878
+ // src/config-schema.ts
879
+ import { z as z2 } from "zod";
880
+
881
+ // src/persistence-store.ts
882
+ import * as fs2 from "node:fs";
883
+ import * as path3 from "node:path";
884
+ var NAMESPACE_ROOT_DIR = "dingtalk-state";
885
+ function toErrorMessage(err) {
886
+ if (err instanceof Error) {
887
+ return err.message;
888
+ }
889
+ try {
890
+ return JSON.stringify(err);
891
+ } catch {
892
+ return String(err);
916
893
  }
917
- const existing = state.records[canonicalMsgId];
918
- const expiresAt = computeExpiresAt(nowMs, params.ttlMs, params.ttlReferenceMs);
919
- const normalizedQuotedRef = normalizeQuotedRef(params.quotedRef);
920
- state.records[canonicalMsgId] = {
921
- msgId: canonicalMsgId,
922
- direction: params.direction,
923
- topic: params.topic ?? existing?.topic ?? null,
924
- accountId: params.accountId,
925
- conversationId: params.conversationId,
926
- createdAt: existing?.createdAt ?? params.createdAt,
927
- updatedAt: nowMs,
928
- expiresAt: expiresAt === void 0 ? existing?.expiresAt : Math.max(expiresAt, existing?.expiresAt ?? 0),
929
- messageType: params.messageType || existing?.messageType,
930
- text: mergeText(existing?.text, params.text),
931
- attachmentText: mergeAttachmentText(existing?.attachmentText, params.attachmentText),
932
- attachmentTextSource: mergeAttachmentTextSource(
933
- existing?.attachmentTextSource,
934
- params.attachmentTextSource
935
- ),
936
- attachmentTextTruncated: mergeAttachmentTextTruncated(
937
- existing?.attachmentTextTruncated,
938
- params.attachmentTextTruncated
939
- ),
940
- attachmentFileName: mergeAttachmentFileName(
941
- existing?.attachmentFileName,
942
- params.attachmentFileName
943
- ),
944
- quotedRef: mergeQuotedRef(existing?.quotedRef, normalizedQuotedRef),
945
- senderId: mergeStringField(existing?.senderId, params.senderId),
946
- senderName: mergeStringField(existing?.senderName, params.senderName),
947
- mentions: mergeMentions(existing?.mentions, params.mentions),
948
- chatType: params.chatType || existing?.chatType,
949
- quotedMessageId: mergeStringField(existing?.quotedMessageId, params.quotedMessageId),
950
- media: mergeMedia(existing?.media, params.media),
951
- delivery: mergeDelivery(existing?.delivery, params.delivery)
952
- };
953
- state.updatedAt = nowMs;
954
- writeState(params, normalizeState(state, nowMs).state);
955
- return canonicalMsgId;
956
894
  }
957
- function upsertInboundMessageContext(params) {
958
- return upsertRecord({
959
- ...params,
960
- direction: "inbound",
961
- topic: params.topic ?? null,
962
- msgId: params.msgId,
963
- cleanupCreatedAtTtlDays: params.cleanupCreatedAtTtlDays
964
- }) || params.msgId;
895
+ function sanitizeSegment(value) {
896
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
965
897
  }
966
- function upsertOutboundMessageContext(params) {
967
- return upsertRecord({
968
- ...params,
969
- direction: "outbound",
970
- topic: params.topic ?? null
971
- });
898
+ function encodeScopeValue(value) {
899
+ return Buffer.from(value, "utf8").toString("base64url");
972
900
  }
973
- function resolveByMsgId(params) {
974
- const nowMs = params.nowMs ?? Date.now();
975
- const state = loadState(params, nowMs);
976
- const direct = state.records[params.msgId];
977
- if (direct && !isRecordExpired(direct, nowMs)) {
978
- return direct;
901
+ function buildScopeSuffix(scope) {
902
+ if (!scope) {
903
+ return "";
979
904
  }
980
- const aliasTarget = state.byAlias[buildAliasKey("inboundMsgId", params.msgId)];
981
- if (!aliasTarget) {
982
- return null;
905
+ const ordered = [
906
+ ["accountId", scope.accountId],
907
+ ["agentId", scope.agentId],
908
+ ["conversationId", scope.conversationId],
909
+ ["groupId", scope.groupId],
910
+ ["targetId", scope.targetId]
911
+ ];
912
+ const segments = ordered.filter(([, value]) => Boolean(value && value.trim())).map(([key, value]) => `${key.replace(/Id$/, "")}-${encodeScopeValue((value || "").trim())}`);
913
+ if (segments.length === 0) {
914
+ return "";
983
915
  }
984
- const record = state.records[aliasTarget];
985
- return record && !isRecordExpired(record, nowMs) ? record : null;
916
+ return `.${segments.join(".")}`;
986
917
  }
987
- function resolveByAlias(params) {
988
- const nowMs = params.nowMs ?? Date.now();
989
- const state = loadState(params, nowMs);
990
- const msgId = state.byAlias[buildAliasKey(params.kind, params.value)];
991
- if (!msgId) {
992
- return null;
993
- }
994
- const record = state.records[msgId];
995
- return record && !isRecordExpired(record, nowMs) ? record : null;
918
+ function resolveNamespacePath(namespace, options) {
919
+ const format = options.format || "json";
920
+ const baseDir = path3.join(path3.dirname(options.storePath), NAMESPACE_ROOT_DIR);
921
+ const safeNamespace = sanitizeSegment(namespace.trim());
922
+ const suffix = buildScopeSuffix(options.scope);
923
+ return path3.join(baseDir, `${safeNamespace}${suffix}.${format}`);
996
924
  }
997
- function resolveByQuotedRef(params) {
998
- const nowMs = params.nowMs ?? Date.now();
999
- const quotedRef = normalizeQuotedRef(params.quotedRef);
1000
- const log = params.log;
1001
- if (!quotedRef) {
1002
- log?.debug?.(
1003
- `[DingTalk][QuotedRef] Resolve skipped: invalid quotedRef accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1004
- );
1005
- return null;
1006
- }
1007
- if (quotedRef.targetDirection === "inbound") {
1008
- if (quotedRef.key !== "msgId" || !quotedRef.value) {
1009
- log?.debug?.(
1010
- `[DingTalk][QuotedRef] Resolve skipped: inbound quotedRef missing msgId accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1011
- );
1012
- return null;
925
+ function readNamespaceJson(namespace, options) {
926
+ const filePath = resolveNamespacePath(namespace, options);
927
+ try {
928
+ if (!fs2.existsSync(filePath)) {
929
+ return options.fallback;
1013
930
  }
1014
- const record = resolveByMsgId({
1015
- ...params,
1016
- msgId: quotedRef.value,
1017
- nowMs
1018
- });
1019
- log?.debug?.(
1020
- `[DingTalk][QuotedRef] Resolve inbound by msgId=${quotedRef.value} hit=${record ? "yes" : "no"} accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1021
- );
1022
- return record;
1023
- }
1024
- if (quotedRef.key && quotedRef.value && quotedRef.key !== "msgId") {
1025
- const aliasRecord = resolveByAlias({
1026
- ...params,
1027
- kind: quotedRef.key,
1028
- value: quotedRef.value,
1029
- nowMs
1030
- });
1031
- if (aliasRecord) {
1032
- log?.debug?.(
1033
- `[DingTalk][QuotedRef] Resolve outbound by ${quotedRef.key}=${quotedRef.value} hit=yes accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1034
- );
1035
- return aliasRecord;
931
+ const raw = fs2.readFileSync(filePath, "utf-8");
932
+ if (!raw.trim()) {
933
+ return options.fallback;
1036
934
  }
1037
- log?.debug?.(
1038
- `[DingTalk][QuotedRef] Resolve outbound by ${quotedRef.key}=${quotedRef.value} hit=no accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1039
- );
1040
- }
1041
- if (typeof quotedRef.fallbackCreatedAt === "number" && Number.isFinite(quotedRef.fallbackCreatedAt)) {
1042
- const record = resolveByCreatedAtWindow({
1043
- ...params,
1044
- createdAt: quotedRef.fallbackCreatedAt,
1045
- direction: "outbound",
1046
- nowMs
1047
- });
1048
- log?.debug?.(
1049
- `[DingTalk][QuotedRef] Resolve outbound by createdAt=${quotedRef.fallbackCreatedAt} hit=${record ? "yes" : "no"} accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
935
+ return JSON.parse(raw);
936
+ } catch (err) {
937
+ options.log?.warn?.(
938
+ `[DingTalk][Persistence] Failed to read namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`
1050
939
  );
1051
- return record;
940
+ return options.fallback;
1052
941
  }
1053
- log?.debug?.(
1054
- `[DingTalk][QuotedRef] Resolve outbound missed without createdAt fallback accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1055
- );
1056
- return null;
1057
942
  }
1058
- function resolveByCreatedAtWindow(params) {
1059
- const nowMs = params.nowMs ?? Date.now();
1060
- const windowMs = params.windowMs ?? DEFAULT_CREATED_AT_MATCH_WINDOW_MS;
1061
- const state = loadState(params, nowMs);
1062
- let bestRecord = null;
1063
- let bestDelta = Infinity;
1064
- for (const msgId of state.recentByCreatedAt) {
1065
- const record = state.records[msgId];
1066
- if (!record || isRecordExpired(record, nowMs)) {
1067
- continue;
1068
- }
1069
- if (params.direction && record.direction !== params.direction) {
1070
- continue;
943
+ function writeNamespaceJsonAtomic(namespace, options) {
944
+ const filePath = resolveNamespacePath(namespace, options);
945
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
946
+ try {
947
+ fs2.mkdirSync(path3.dirname(filePath), { recursive: true });
948
+ fs2.writeFileSync(tempPath, JSON.stringify(options.data, null, 2));
949
+ try {
950
+ fs2.renameSync(tempPath, filePath);
951
+ } catch (err) {
952
+ if (fs2.existsSync(filePath)) {
953
+ fs2.rmSync(filePath, { force: true });
954
+ fs2.renameSync(tempPath, filePath);
955
+ } else {
956
+ throw err;
957
+ }
1071
958
  }
1072
- const delta = Math.abs(record.createdAt - params.createdAt);
1073
- if (delta <= windowMs && delta < bestDelta) {
1074
- bestDelta = delta;
1075
- bestRecord = record;
959
+ } catch (err) {
960
+ options.log?.warn?.(
961
+ `[DingTalk][Persistence] Failed to write namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`
962
+ );
963
+ if (fs2.existsSync(tempPath)) {
964
+ fs2.rmSync(tempPath, { force: true });
1076
965
  }
1077
966
  }
1078
- return bestRecord;
1079
967
  }
1080
968
 
1081
- // src/config-schema.ts
1082
- var AckReactionSchema = z2.union([
1083
- z2.literal(""),
1084
- z2.enum(["off", "emoji", "kaomoji"]),
1085
- z2.string().min(1)
1086
- ]);
1087
- var CardStreamingModeSchema = z2.enum(["off", "answer", "all"]);
1088
- var ContextVisibilitySchema = z2.enum(["all", "allowlist", "allowlist_quote"]);
1089
- var DingTalkAccountConfigShape = {
1090
- /** Account name (optional display name) */
1091
- name: z2.string().optional(),
1092
- /** Enable or disable this DingTalk channel/account without deleting saved credentials. */
1093
- enabled: z2.boolean().optional().default(true),
1094
- /** DingTalk App Key (Client ID) used to authenticate API and Stream connections. */
1095
- clientId: z2.string().optional(),
1096
- /** DingTalk App Secret (Client Secret) used to obtain DingTalk access tokens. */
1097
- clientSecret: buildSecretInputSchema().optional(),
1098
- /** Direct-message access policy: open, pairing, or allowlist. */
1099
- dmPolicy: z2.enum(["open", "pairing", "allowlist"]).optional().default("open"),
1100
- /** Group-message access policy: open, allowlist, or disabled. */
1101
- groupPolicy: z2.enum(["open", "allowlist", "disabled"]).optional().default("open"),
1102
- /** User IDs allowed when `dmPolicy` is `allowlist`. */
1103
- allowFrom: z2.array(z2.string()).optional(),
1104
- /** Sender IDs allowed when `groupPolicy` is `allowlist`. */
1105
- groupAllowFrom: z2.array(z2.string()).optional(),
1106
- /** Default disabled. Enabling `all` allows learned displayName lookup but may misroute on stale or duplicate names and is available to all callers until upstream exposes requester authz context. */
1107
- displayNameResolution: z2.enum(["disabled", "all"]).optional().default("disabled"),
1108
- /** Controls how much supplemental host context remains visible to the reply runtime. `allowlist_quote` is the safest advanced mode when only explicit quotes or replies should remain visible. */
1109
- contextVisibility: ContextVisibilitySchema.optional(),
1110
- /** Allowed remote media download hosts, IPs, or CIDRs for media fetches. */
1111
- mediaUrlAllowlist: z2.array(z2.string()).optional(),
1112
- /** Native acknowledgement reaction mode: off, emoji, kaomoji, or a custom compatibility string. */
1113
- ackReaction: AckReactionSchema.optional(),
1114
- /** Retention window in days for short-lived message context used by quoting and media recovery. */
1115
- journalTTLDays: z2.number().int().min(1).optional().default(DEFAULT_MESSAGE_CONTEXT_TTL_DAYS),
1116
- /** Enable verbose DingTalk channel debug logging. */
1117
- debug: z2.boolean().optional().default(false),
1118
- /** Default reply delivery mode: markdown or card. */
1119
- messageType: z2.enum(["markdown", "card"]).optional().default("markdown"),
1120
- /** Deprecated and ignored. AI card replies now always use the built-in DingTalk template contract. Keep only for backward-compatible config parsing. */
1121
- cardTemplateId: z2.string().optional(),
1122
- /** Deprecated and ignored. The built-in AI card contract owns the streaming field mapping. Keep only for backward-compatible config parsing. */
1123
- cardTemplateKey: z2.string().optional().default("content"),
1124
- /** Per-group overrides keyed by conversationId. Supports `*` as a wildcard fallback. */
1125
- groups: z2.record(
1126
- z2.string(),
1127
- z2.object({
1128
- /** Additional system prompt appended for this group. */
1129
- systemPrompt: z2.string().optional(),
1130
- /** Require an explicit @mention before the bot answers in this group. */
1131
- requireMention: z2.boolean().optional(),
1132
- /** Optional per-group sender allowlist for tighter access control than the channel default. */
1133
- groupAllowFrom: z2.array(z2.string()).optional()
1134
- })
1135
- ).optional(),
1136
- /** Connection robustness configuration */
1137
- /** Maximum connection attempts in a single reconnect cycle before backing off or giving up. */
1138
- maxConnectionAttempts: z2.number().int().min(1).optional().default(10),
1139
- /** Initial reconnect backoff delay in milliseconds. */
1140
- initialReconnectDelay: z2.number().int().min(100).optional().default(1e3),
1141
- /** Upper bound for reconnect backoff delay in milliseconds. */
1142
- maxReconnectDelay: z2.number().int().min(1e3).optional().default(6e4),
1143
- /** Randomization factor added to reconnect backoff to avoid synchronized reconnect storms. */
1144
- reconnectJitter: z2.number().min(0).max(1).optional().default(0.3),
1145
- /** Maximum reconnect cycles before the channel stops retrying and waits for the next lifecycle restart. */
1146
- maxReconnectCycles: z2.number().int().min(1).optional().default(10),
1147
- /** Time limit in milliseconds for one reconnect cycle before starting a fresh cycle. */
1148
- reconnectDeadlineMs: z2.number().int().min(5e3).optional().default(5e4),
1149
- /** Enable the plugin connection manager. Disable only when you intentionally rely on DWClient native keepAlive plus autoReconnect behavior. */
1150
- useConnectionManager: z2.boolean().optional().default(true),
1151
- /** Maximum inbound media size in MB accepted by the plugin. When omitted, the runtime default is used. */
1152
- mediaMaxMb: z2.number().int().min(1).optional(),
1153
- /** Enable the underlying Stream client heartbeat. When omitted, runtime derives a default from `useConnectionManager`. */
1154
- keepAlive: z2.boolean().optional(),
1155
- /** Bypass global or system HTTP(S) proxy settings for DingTalk send, upload, and card APIs. */
1156
- bypassProxyForSend: z2.boolean().optional().default(false),
1157
- /** Controls the proactive-send permission reminder shown when a conversation has not granted send rights yet. */
1158
- proactivePermissionHint: z2.object({
1159
- /** Show the proactive-send permission hint when the runtime detects missing DingTalk proactive permission. */
1160
- enabled: z2.boolean().optional().default(true),
1161
- /** Minimum cooldown in hours before the same proactive permission hint can be shown again. */
1162
- cooldownHours: z2.number().int().min(1).max(24 * 30).optional().default(24)
1163
- }).optional().default({ enabled: true, cooldownHours: 24 }),
1164
- /** Deprecated compatibility flag. When true and `cardStreamingMode` is unset, runtime resolves to `cardStreamingMode: "all"`. Do not use in new configs. */
1165
- cardRealTimeStream: z2.boolean().optional(),
1166
- /** Card streaming mode:
1167
- * - off: disable incremental streaming
1168
- * - answer: stream answer text
1169
- * - all: stream answer + reasoning or thinking text */
1170
- cardStreamingMode: CardStreamingModeSchema.optional(),
1171
- /** Throttle interval in milliseconds between AI card streaming updates. */
1172
- cardStreamInterval: z2.number().int().min(200).optional().default(1e3),
1173
- /** Cooldown window in milliseconds after AI card trigger errors. Replies fall back to non-card delivery during this period. */
1174
- aicardDegradeMs: z2.number().int().min(6e4).optional().default(30 * 60 * 1e3),
1175
- /** Enable the local feedback-learning loop for notes, reflections, and command-assisted learning. */
1176
- learningEnabled: z2.boolean().optional(),
1177
- /** Automatically apply generated learning output into session notes or global rules when available. */
1178
- learningAutoApply: z2.boolean().optional(),
1179
- /** Retention window in milliseconds for temporary learning notes. */
1180
- learningNoteTtlMs: z2.number().int().min(6e4).optional(),
1181
- /** Convert markdown tables to plain text before sending when you want more consistent DingTalk rendering. */
1182
- convertMarkdownTables: z2.boolean().optional().default(true),
1183
- /** @mention the sender after card finalization in group chats.
1184
- * Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
1185
- * Leave empty or omit to disable. */
1186
- cardAtSender: z2.string().optional(),
1187
- /** Status line visibility toggles for the AI card footer. */
1188
- cardStatusLine: z2.object({
1189
- /** Show model name. */
1190
- model: z2.boolean().optional().default(true),
1191
- /** Show thinking effort level. */
1192
- effort: z2.boolean().optional().default(true),
1193
- /** Show agent display name. */
1194
- agent: z2.boolean().optional().default(true),
1195
- /** Show task elapsed time. */
1196
- taskTime: z2.boolean().optional().default(false),
1197
- /** Show token usage summary (input/output/cache). */
1198
- tokens: z2.boolean().optional().default(false),
1199
- /** Show DingTalk API call count. */
1200
- dapiUsage: z2.boolean().optional().default(false)
1201
- }).optional().default({ model: true, effort: true, agent: true, taskTime: false, tokens: false, dapiUsage: false })
969
+ // src/message-context-store.ts
970
+ var MESSAGE_CONTEXT_NAMESPACE = "messages.context";
971
+ var MESSAGE_CONTEXT_VERSION = 1;
972
+ var DEFAULT_MESSAGE_CONTEXT_TTL_DAYS = 7;
973
+ var DEFAULT_CARD_CONTENT_TTL_MS = 24 * 60 * 60 * 1e3;
974
+ var DEFAULT_MEDIA_CONTEXT_TTL_MS = 24 * 60 * 60 * 1e3;
975
+ var DEFAULT_CREATED_AT_MATCH_WINDOW_MS = 2e3;
976
+ var MAX_RECORDS_PER_SCOPE = 1e3;
977
+ var DEFAULT_OUTBOUND_SENDER = {
978
+ senderId: "bot",
979
+ senderName: "OpenClaw"
1202
980
  };
1203
- var DingTalkAccountConfigSchema = z2.object(DingTalkAccountConfigShape);
1204
- var DingTalkConfigSchema = DingTalkAccountConfigSchema.extend({
1205
- /** Multi-account configuration */
1206
- accounts: z2.record(z2.string(), DingTalkAccountConfigSchema.optional()).optional()
1207
- });
1208
-
1209
- // src/gateway/channel-gateway.ts
1210
- import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
1211
-
1212
- // src/http-client.ts
1213
- import axios from "axios";
1214
- var DEFAULT_HTTP_TIMEOUT_MS = 1e4;
1215
- var httpClient = typeof axios?.create === "function" ? axios.create({ timeout: DEFAULT_HTTP_TIMEOUT_MS }) : axios;
1216
- httpClient.isAxiosError = axios.isAxiosError;
1217
- var http_client_default = httpClient;
1218
-
1219
- // src/utils.ts
1220
- import * as dns from "node:dns";
1221
- import * as fs2 from "node:fs";
1222
- import * as net from "node:net";
1223
- import * as os2 from "node:os";
1224
- import * as path3 from "node:path";
1225
- var pluginDebugWriters = /* @__PURE__ */ new Map();
1226
- var closedPluginDebugScopes = /* @__PURE__ */ new Set();
1227
- function padNumber(value, width = 2) {
1228
- return String(value).padStart(width, "0");
981
+ function inferConversationChatType(conversationId) {
982
+ return conversationId.startsWith("cid") ? "group" : "direct";
1229
983
  }
1230
- function formatTimezoneOffset(date) {
1231
- const offsetMinutes = -date.getTimezoneOffset();
1232
- const sign = offsetMinutes >= 0 ? "+" : "-";
1233
- const absoluteMinutes = Math.abs(offsetMinutes);
1234
- const hours = Math.floor(absoluteMinutes / 60);
1235
- const minutes = absoluteMinutes % 60;
1236
- return `${sign}${padNumber(hours)}:${padNumber(minutes)}`;
984
+ var stateCache = /* @__PURE__ */ new Map();
985
+ function getScopeKey(params) {
986
+ return JSON.stringify([
987
+ params.storePath || "__memory__",
988
+ params.accountId,
989
+ params.conversationId || null
990
+ ]);
1237
991
  }
1238
- function formatPluginDebugTimestamp(date) {
1239
- return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())} ${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}.${padNumber(date.getMilliseconds(), 3)}${formatTimezoneOffset(date)}`;
992
+ function fallbackState() {
993
+ return {
994
+ version: MESSAGE_CONTEXT_VERSION,
995
+ updatedAt: Date.now(),
996
+ records: {},
997
+ byAlias: {},
998
+ recentByCreatedAt: []
999
+ };
1240
1000
  }
1241
- function formatPluginDebugDate(date) {
1242
- return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`;
1001
+ function asRecord(value) {
1002
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1003
+ return null;
1004
+ }
1005
+ return value;
1243
1006
  }
1244
- function resolvePluginDebugLogFilePath(params) {
1245
- return path3.join(
1246
- path3.dirname(params.storePath),
1247
- "logs",
1248
- "dingtalk",
1249
- params.accountId,
1250
- `debug-${formatPluginDebugDate(params.date)}.log`
1251
- );
1007
+ function normalizeMedia(value) {
1008
+ const candidate = asRecord(value);
1009
+ if (!candidate) {
1010
+ return void 0;
1011
+ }
1012
+ const downloadCode = typeof candidate.downloadCode === "string" && candidate.downloadCode.trim() ? candidate.downloadCode.trim() : void 0;
1013
+ const downloadCodes = Array.isArray(candidate.downloadCodes) && candidate.downloadCodes.length > 0 ? candidate.downloadCodes.filter((c) => typeof c === "string" && c.trim()) : void 0;
1014
+ const spaceId = typeof candidate.spaceId === "string" && candidate.spaceId.trim() ? candidate.spaceId.trim() : void 0;
1015
+ const fileId = typeof candidate.fileId === "string" && candidate.fileId.trim() ? candidate.fileId.trim() : void 0;
1016
+ if (!downloadCode && (!downloadCodes || downloadCodes.length === 0) && !spaceId && !fileId) {
1017
+ return void 0;
1018
+ }
1019
+ return { downloadCode, downloadCodes, spaceId, fileId };
1020
+ }
1021
+ function normalizeQuotedRef(value) {
1022
+ const candidate = asRecord(value);
1023
+ if (!candidate) {
1024
+ return void 0;
1025
+ }
1026
+ const targetDirection = candidate.targetDirection === "outbound" ? "outbound" : candidate.targetDirection === "inbound" ? "inbound" : void 0;
1027
+ const key = candidate.key === "msgId" || candidate.key === "messageId" || candidate.key === "processQueryKey" || candidate.key === "outTrackId" || candidate.key === "cardInstanceId" ? candidate.key : void 0;
1028
+ const valueString = typeof candidate.value === "string" && candidate.value.trim() ? candidate.value.trim() : void 0;
1029
+ const fallbackCreatedAt = typeof candidate.fallbackCreatedAt === "number" && Number.isFinite(candidate.fallbackCreatedAt) ? candidate.fallbackCreatedAt : void 0;
1030
+ if (!targetDirection) {
1031
+ return void 0;
1032
+ }
1033
+ if (!key && !fallbackCreatedAt) {
1034
+ return void 0;
1035
+ }
1036
+ if (key && !valueString) {
1037
+ return void 0;
1038
+ }
1039
+ return {
1040
+ targetDirection,
1041
+ key,
1042
+ value: valueString,
1043
+ fallbackCreatedAt
1044
+ };
1045
+ }
1046
+ function normalizeAttachmentTextSource(value) {
1047
+ return value === "text" || value === "html" || value === "pdf" || value === "docx" ? value : void 0;
1048
+ }
1049
+ function normalizeDelivery(value) {
1050
+ const candidate = asRecord(value);
1051
+ if (!candidate) {
1052
+ return void 0;
1053
+ }
1054
+ const messageId = typeof candidate.messageId === "string" && candidate.messageId.trim() ? candidate.messageId.trim() : void 0;
1055
+ const processQueryKey = typeof candidate.processQueryKey === "string" && candidate.processQueryKey.trim() ? candidate.processQueryKey.trim() : void 0;
1056
+ const outTrackId = typeof candidate.outTrackId === "string" && candidate.outTrackId.trim() ? candidate.outTrackId.trim() : void 0;
1057
+ const cardInstanceId = typeof candidate.cardInstanceId === "string" && candidate.cardInstanceId.trim() ? candidate.cardInstanceId.trim() : void 0;
1058
+ const kind = typeof candidate.kind === "string" && candidate.kind.trim() ? candidate.kind.trim() : void 0;
1059
+ if (!messageId && !processQueryKey && !outTrackId && !cardInstanceId && !kind) {
1060
+ return void 0;
1061
+ }
1062
+ return { messageId, processQueryKey, outTrackId, cardInstanceId, kind };
1063
+ }
1064
+ function normalizeMentions(value) {
1065
+ if (!Array.isArray(value)) {
1066
+ return void 0;
1067
+ }
1068
+ const normalized = [...new Set(value.map((item) => String(item || "").trim()).filter(Boolean))];
1069
+ return normalized.length > 0 ? normalized : void 0;
1070
+ }
1071
+ function normalizeMessageRecord(value) {
1072
+ const candidate = asRecord(value);
1073
+ if (!candidate) {
1074
+ return null;
1075
+ }
1076
+ const msgId = typeof candidate.msgId === "string" && candidate.msgId.trim() ? candidate.msgId.trim() : "";
1077
+ const direction = candidate.direction === "outbound" ? "outbound" : candidate.direction === "inbound" ? "inbound" : null;
1078
+ const accountId = typeof candidate.accountId === "string" && candidate.accountId.trim() ? candidate.accountId.trim() : "";
1079
+ const conversationId = typeof candidate.conversationId === "string" && candidate.conversationId.trim() ? candidate.conversationId.trim() : null;
1080
+ const createdAt = typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : NaN;
1081
+ const updatedAt = typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt) ? candidate.updatedAt : Date.now();
1082
+ if (!msgId || !direction || !accountId || !Number.isFinite(createdAt)) {
1083
+ return null;
1084
+ }
1085
+ const expiresAt = typeof candidate.expiresAt === "number" && Number.isFinite(candidate.expiresAt) ? candidate.expiresAt : void 0;
1086
+ return {
1087
+ msgId,
1088
+ direction,
1089
+ topic: candidate.topic === null ? null : typeof candidate.topic === "string" ? candidate.topic : null,
1090
+ accountId,
1091
+ conversationId,
1092
+ createdAt,
1093
+ updatedAt,
1094
+ expiresAt,
1095
+ messageType: typeof candidate.messageType === "string" ? candidate.messageType : void 0,
1096
+ text: typeof candidate.text === "string" ? candidate.text : void 0,
1097
+ attachmentText: typeof candidate.attachmentText === "string" ? candidate.attachmentText : void 0,
1098
+ attachmentTextSource: normalizeAttachmentTextSource(candidate.attachmentTextSource),
1099
+ attachmentTextTruncated: candidate.attachmentTextTruncated === true ? true : void 0,
1100
+ attachmentFileName: typeof candidate.attachmentFileName === "string" ? candidate.attachmentFileName : void 0,
1101
+ quotedRef: normalizeQuotedRef(candidate.quotedRef),
1102
+ senderId: typeof candidate.senderId === "string" && candidate.senderId.trim() ? candidate.senderId.trim() : void 0,
1103
+ senderName: typeof candidate.senderName === "string" && candidate.senderName.trim() ? candidate.senderName.trim() : void 0,
1104
+ mentions: normalizeMentions(candidate.mentions),
1105
+ chatType: candidate.chatType === "direct" || candidate.chatType === "group" ? candidate.chatType : void 0,
1106
+ quotedMessageId: typeof candidate.quotedMessageId === "string" && candidate.quotedMessageId.trim() ? candidate.quotedMessageId.trim() : void 0,
1107
+ media: normalizeMedia(candidate.media),
1108
+ delivery: normalizeDelivery(candidate.delivery)
1109
+ };
1252
1110
  }
1253
- function formatPluginDebugLine(params) {
1254
- return `[${formatPluginDebugTimestamp(params.date)}] [debug] [dingtalk] [account:${params.accountId}] ${params.message}`;
1111
+ function buildAliasKey(kind, value) {
1112
+ return `${kind}:${value.trim()}`;
1255
1113
  }
1256
- function buildPluginDebugWriterKey(params) {
1257
- return JSON.stringify([params.storePath, params.accountId, formatPluginDebugDate(params.date)]);
1114
+ function buildAliasEntries(record) {
1115
+ const aliases = [];
1116
+ if (record.direction === "inbound" && record.msgId.trim()) {
1117
+ aliases.push([buildAliasKey("inboundMsgId", record.msgId), record.msgId]);
1118
+ }
1119
+ const delivery = record.delivery;
1120
+ if (!delivery) {
1121
+ return aliases;
1122
+ }
1123
+ if (delivery.messageId) {
1124
+ aliases.push([buildAliasKey("messageId", delivery.messageId), record.msgId]);
1125
+ }
1126
+ if (delivery.processQueryKey) {
1127
+ aliases.push([buildAliasKey("processQueryKey", delivery.processQueryKey), record.msgId]);
1128
+ }
1129
+ if (delivery.outTrackId) {
1130
+ aliases.push([buildAliasKey("outTrackId", delivery.outTrackId), record.msgId]);
1131
+ }
1132
+ if (delivery.cardInstanceId) {
1133
+ aliases.push([buildAliasKey("cardInstanceId", delivery.cardInstanceId), record.msgId]);
1134
+ }
1135
+ return aliases;
1258
1136
  }
1259
- function buildPluginDebugScopeKey(params) {
1260
- return JSON.stringify([params.storePath, params.accountId]);
1137
+ function isRecordExpired(record, nowMs) {
1138
+ return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt) && nowMs >= record.expiresAt;
1261
1139
  }
1262
- function resolvePluginDebugWriter(params) {
1263
- const key = buildPluginDebugWriterKey(params);
1264
- const existing = pluginDebugWriters.get(key);
1265
- if (existing) {
1266
- return existing;
1140
+ function normalizeState(state, nowMs) {
1141
+ const normalizedRecords = {};
1142
+ const sortedRecords = Object.values(state.records).map((record) => normalizeMessageRecord(record)).filter((record) => record !== null).filter((record) => !isRecordExpired(record, nowMs)).toSorted((left, right) => left.createdAt - right.createdAt);
1143
+ const keptRecords = sortedRecords.slice(-MAX_RECORDS_PER_SCOPE);
1144
+ for (const record of keptRecords) {
1145
+ normalizedRecords[record.msgId] = record;
1267
1146
  }
1268
- const created = {
1269
- filePath: resolvePluginDebugLogFilePath(params),
1270
- warned: false,
1271
- directoryReady: false
1272
- };
1273
- pluginDebugWriters.set(key, created);
1274
- return created;
1275
- }
1276
- function resolvePluginDebugLog(params) {
1277
- const baseLog = params.baseLog;
1278
- const fsImpl = params.fsImpl ?? fs2;
1279
- const scopeKey = params.storePath ? buildPluginDebugScopeKey({ storePath: params.storePath, accountId: params.accountId }) : void 0;
1280
- if (scopeKey) {
1281
- closedPluginDebugScopes.delete(scopeKey);
1147
+ const byAlias = {};
1148
+ for (const record of keptRecords) {
1149
+ for (const [key, value] of buildAliasEntries(record)) {
1150
+ byAlias[key] = value;
1151
+ }
1282
1152
  }
1283
1153
  return {
1284
- debug: (message) => {
1285
- if (!params.debug) {
1286
- baseLog?.debug?.(message);
1287
- return;
1288
- }
1289
- const date = params.now ? params.now() : /* @__PURE__ */ new Date();
1290
- const line = formatPluginDebugLine({
1291
- accountId: params.accountId,
1292
- date,
1293
- message
1294
- });
1295
- try {
1296
- process.stdout.write(`${line}
1297
- `);
1298
- } catch {
1299
- }
1300
- if (params.storePath && scopeKey && !closedPluginDebugScopes.has(scopeKey)) {
1301
- const writer = resolvePluginDebugWriter({
1302
- storePath: params.storePath,
1303
- accountId: params.accountId,
1304
- date
1305
- });
1306
- try {
1307
- if (!writer.directoryReady) {
1308
- fsImpl.mkdirSync(path3.dirname(writer.filePath), { recursive: true });
1309
- writer.directoryReady = true;
1310
- }
1311
- fsImpl.appendFileSync(writer.filePath, `${line}
1312
- `, "utf8");
1313
- } catch (err) {
1314
- if (!writer.warned) {
1315
- writer.warned = true;
1316
- baseLog?.warn?.(
1317
- `[DingTalk] Plugin debug log file unavailable: accountId=${params.accountId} path=${writer.filePath} error=${getErrorMessage(err)}`
1318
- );
1319
- }
1320
- }
1321
- }
1322
- try {
1323
- baseLog?.debug?.(message);
1324
- } catch {
1325
- }
1326
- },
1327
- info: (message) => baseLog?.info?.(message),
1328
- warn: (message) => baseLog?.warn?.(message),
1329
- error: (message) => baseLog?.error?.(message)
1154
+ removed: Object.keys(state.records).length - Object.keys(normalizedRecords).length,
1155
+ state: {
1156
+ version: MESSAGE_CONTEXT_VERSION,
1157
+ updatedAt: state.updatedAt,
1158
+ records: normalizedRecords,
1159
+ byAlias,
1160
+ recentByCreatedAt: keptRecords.map((record) => record.msgId)
1161
+ }
1330
1162
  };
1331
1163
  }
1332
- function closePluginDebugLog(params) {
1164
+ function hydrateState(params, nowMs) {
1165
+ if (!params.storePath) {
1166
+ return fallbackState();
1167
+ }
1168
+ const persisted = readNamespaceJson(
1169
+ MESSAGE_CONTEXT_NAMESPACE,
1170
+ {
1171
+ storePath: params.storePath,
1172
+ scope: { accountId: params.accountId, conversationId: params.conversationId || void 0 },
1173
+ format: "json",
1174
+ fallback: { version: MESSAGE_CONTEXT_VERSION, updatedAt: Date.now(), records: {} }
1175
+ }
1176
+ );
1177
+ const parsedRecords = asRecord(persisted.records) || {};
1178
+ const hydrated = fallbackState();
1179
+ hydrated.updatedAt = typeof persisted.updatedAt === "number" ? persisted.updatedAt : Date.now();
1180
+ for (const [key, value] of Object.entries(parsedRecords)) {
1181
+ const normalized = normalizeMessageRecord(value);
1182
+ if (normalized) {
1183
+ hydrated.records[key] = normalized;
1184
+ }
1185
+ }
1186
+ return normalizeState(hydrated, nowMs).state;
1187
+ }
1188
+ function loadState(params, nowMs = Date.now()) {
1189
+ const scopeKey = getScopeKey(params);
1190
+ const cached = stateCache.get(scopeKey);
1191
+ if (cached) {
1192
+ return cached;
1193
+ }
1194
+ const hydrated = params.storePath ? hydrateState(params, nowMs) : fallbackState();
1195
+ stateCache.set(scopeKey, hydrated);
1196
+ return hydrated;
1197
+ }
1198
+ function writeState(params, state) {
1199
+ stateCache.set(getScopeKey(params), state);
1333
1200
  if (!params.storePath) {
1334
1201
  return;
1335
1202
  }
1336
- const scopeKey = buildPluginDebugScopeKey({
1203
+ writeNamespaceJsonAtomic(MESSAGE_CONTEXT_NAMESPACE, {
1337
1204
  storePath: params.storePath,
1338
- accountId: params.accountId
1339
- });
1340
- closedPluginDebugScopes.add(scopeKey);
1341
- for (const key of pluginDebugWriters.keys()) {
1342
- const [writerStorePath, writerAccountId] = JSON.parse(key);
1343
- if (writerStorePath === params.storePath && writerAccountId === params.accountId) {
1344
- pluginDebugWriters.delete(key);
1205
+ scope: { accountId: params.accountId, conversationId: params.conversationId || void 0 },
1206
+ format: "json",
1207
+ data: {
1208
+ version: MESSAGE_CONTEXT_VERSION,
1209
+ updatedAt: state.updatedAt,
1210
+ records: state.records
1345
1211
  }
1212
+ });
1213
+ }
1214
+ function cloneStateForMutation(state) {
1215
+ return {
1216
+ version: state.version,
1217
+ updatedAt: state.updatedAt,
1218
+ records: { ...state.records },
1219
+ byAlias: state.byAlias,
1220
+ recentByCreatedAt: state.recentByCreatedAt
1221
+ };
1222
+ }
1223
+ function mergeText(existing, next) {
1224
+ if (typeof next !== "string") {
1225
+ return existing;
1346
1226
  }
1227
+ return next;
1347
1228
  }
1348
- function maskSensitiveData(data) {
1349
- if (data === null || data === void 0) {
1350
- return data;
1229
+ function mergeAttachmentText(existing, next) {
1230
+ if (typeof next !== "string") {
1231
+ return existing;
1351
1232
  }
1352
- if (typeof data !== "object") {
1353
- return data;
1233
+ return next;
1234
+ }
1235
+ function mergeQuotedRef(existing, next) {
1236
+ if (!existing) {
1237
+ return next;
1354
1238
  }
1355
- const masked = JSON.parse(JSON.stringify(data));
1356
- const sensitiveFields = /* @__PURE__ */ new Set(["token", "accessToken"]);
1357
- function maskObj(obj) {
1358
- for (const key in obj) {
1359
- if (sensitiveFields.has(key)) {
1360
- const val = obj[key];
1361
- if (typeof val === "string" && val.length > 6) {
1362
- obj[key] = val.slice(0, 3) + "*".repeat(val.length - 6) + val.slice(-3);
1363
- } else if (typeof val === "string") {
1364
- obj[key] = "*".repeat(val.length);
1365
- }
1366
- } else if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
1367
- maskObj(obj[key]);
1368
- }
1369
- }
1239
+ if (!next) {
1240
+ return existing;
1370
1241
  }
1371
- maskObj(masked);
1372
- return masked;
1242
+ return {
1243
+ targetDirection: next.targetDirection,
1244
+ key: next.key || existing.key,
1245
+ value: next.value || existing.value,
1246
+ fallbackCreatedAt: next.fallbackCreatedAt ?? existing.fallbackCreatedAt
1247
+ };
1373
1248
  }
1374
- function stringifyUnknown(value) {
1375
- if (typeof value === "string") {
1376
- return value;
1249
+ function mergeStringField(existing, next) {
1250
+ if (typeof next !== "string" || !next.trim()) {
1251
+ return existing;
1377
1252
  }
1378
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
1379
- return String(value);
1253
+ return next.trim();
1254
+ }
1255
+ function mergeMentions(existing, next) {
1256
+ if (!next) {
1257
+ return existing;
1258
+ }
1259
+ return normalizeMentions(next) || existing;
1260
+ }
1261
+ function mergeMedia(existing, next) {
1262
+ if (!existing) {
1263
+ return next;
1264
+ }
1265
+ if (!next) {
1266
+ return existing;
1267
+ }
1268
+ return {
1269
+ downloadCode: next.downloadCode || existing.downloadCode,
1270
+ downloadCodes: next.downloadCodes || existing.downloadCodes,
1271
+ spaceId: next.spaceId || existing.spaceId,
1272
+ fileId: next.fileId || existing.fileId
1273
+ };
1274
+ }
1275
+ function mergeDelivery(existing, next) {
1276
+ if (!existing) {
1277
+ return next;
1380
1278
  }
1381
- try {
1382
- const serialized = JSON.stringify(value);
1383
- return serialized ?? String(value);
1384
- } catch {
1385
- return "[unserializable]";
1279
+ if (!next) {
1280
+ return existing;
1386
1281
  }
1282
+ return {
1283
+ messageId: next.messageId || existing.messageId,
1284
+ processQueryKey: next.processQueryKey || existing.processQueryKey,
1285
+ outTrackId: next.outTrackId || existing.outTrackId,
1286
+ cardInstanceId: next.cardInstanceId || existing.cardInstanceId,
1287
+ kind: next.kind || existing.kind
1288
+ };
1387
1289
  }
1388
- function parseBooleanLike(value) {
1389
- if (typeof value === "boolean") {
1390
- return value;
1290
+ function mergeAttachmentTextSource(existing, next) {
1291
+ return next ?? existing;
1292
+ }
1293
+ function mergeAttachmentTextTruncated(existing, next) {
1294
+ return next === void 0 ? existing : next;
1295
+ }
1296
+ function mergeAttachmentFileName(existing, next) {
1297
+ if (typeof next !== "string") {
1298
+ return existing;
1391
1299
  }
1392
- if (typeof value === "number") {
1393
- if (value === 1) {
1394
- return true;
1395
- }
1396
- if (value === 0) {
1397
- return false;
1300
+ return next;
1301
+ }
1302
+ function resolveExistingMsgId(state, params, nowMs) {
1303
+ if (params.direction === "inbound" && params.msgId) {
1304
+ const direct = state.records[params.msgId];
1305
+ if (direct && !isRecordExpired(direct, nowMs)) {
1306
+ return params.msgId;
1398
1307
  }
1308
+ }
1309
+ const delivery = params.delivery;
1310
+ if (!delivery) {
1399
1311
  return void 0;
1400
1312
  }
1401
- if (typeof value === "string") {
1402
- const normalized = value.trim().toLowerCase();
1403
- if (["1", "true", "yes", "y", "on"].includes(normalized)) {
1404
- return true;
1313
+ const candidates = [
1314
+ ["messageId", delivery.messageId],
1315
+ ["processQueryKey", delivery.processQueryKey],
1316
+ ["outTrackId", delivery.outTrackId],
1317
+ ["cardInstanceId", delivery.cardInstanceId]
1318
+ ];
1319
+ for (const [kind, value] of candidates) {
1320
+ if (!value) {
1321
+ continue;
1405
1322
  }
1406
- if (["0", "false", "no", "n", "off"].includes(normalized)) {
1407
- return false;
1323
+ const aliasHit = state.byAlias[buildAliasKey(kind, value)];
1324
+ if (!aliasHit) {
1325
+ continue;
1408
1326
  }
1409
- }
1410
- return void 0;
1411
- }
1412
- function getErrorMessage(err) {
1413
- if (err instanceof Error && err.message) {
1414
- return err.message;
1415
- }
1416
- if (err && typeof err === "object") {
1417
- const record = err;
1418
- if (typeof record.message === "string" && record.message.trim()) {
1419
- return record.message;
1327
+ const record = state.records[aliasHit];
1328
+ if (record && !isRecordExpired(record, nowMs)) {
1329
+ return aliasHit;
1420
1330
  }
1421
1331
  }
1422
- return stringifyUnknown(err);
1332
+ return void 0;
1423
1333
  }
1424
- function getErrorResponseData(err) {
1425
- if (!err || typeof err !== "object") {
1334
+ function computeExpiresAt(nowMs, ttlMs, ttlReferenceMs) {
1335
+ if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs <= 0) {
1426
1336
  return void 0;
1427
1337
  }
1428
- return err.response?.data;
1338
+ return (typeof ttlReferenceMs === "number" && Number.isFinite(ttlReferenceMs) ? ttlReferenceMs : nowMs) + ttlMs;
1429
1339
  }
1430
- function formatDingTalkErrorPayload(payload) {
1431
- if (payload === null || payload === void 0) {
1432
- return "payload=unknown";
1340
+ function pruneStateByCreatedAt(state, ttlDays, nowMs) {
1341
+ if (!ttlDays || ttlDays <= 0) {
1342
+ return { state, removed: 0 };
1433
1343
  }
1434
- let code;
1435
- let message;
1436
- if (typeof payload === "object" && !Array.isArray(payload)) {
1437
- const obj = payload;
1438
- if (typeof obj.code === "string" || typeof obj.code === "number") {
1439
- code = String(obj.code);
1440
- }
1441
- if (typeof obj.message === "string") {
1442
- message = obj.message;
1344
+ const cutoff = nowMs - ttlDays * 24 * 60 * 60 * 1e3;
1345
+ const nextRecords = {};
1346
+ for (const [msgId, record] of Object.entries(state.records)) {
1347
+ if (record.createdAt >= cutoff) {
1348
+ nextRecords[msgId] = record;
1443
1349
  }
1444
1350
  }
1445
- let serialized;
1446
- try {
1447
- serialized = JSON.stringify(maskSensitiveData(payload));
1448
- } catch {
1449
- if (typeof payload === "string") {
1450
- serialized = payload;
1451
- } else if (typeof payload === "number" || typeof payload === "boolean" || typeof payload === "bigint") {
1452
- serialized = `${payload}`;
1453
- } else {
1454
- serialized = "[unserializable-payload]";
1455
- }
1351
+ const removed = Object.keys(state.records).length - Object.keys(nextRecords).length;
1352
+ if (removed === 0) {
1353
+ return { state, removed: 0 };
1456
1354
  }
1457
- const parts = [];
1458
- if (code) {
1459
- parts.push(`code=${code}`);
1355
+ return {
1356
+ removed,
1357
+ state: {
1358
+ ...state,
1359
+ records: nextRecords
1360
+ }
1361
+ };
1362
+ }
1363
+ function upsertRecord(params) {
1364
+ const nowMs = params.updatedAt ?? Date.now();
1365
+ let state = cloneStateForMutation(loadState(params, nowMs));
1366
+ if (params.cleanupCreatedAtTtlDays && params.cleanupCreatedAtTtlDays > 0) {
1367
+ state = pruneStateByCreatedAt(state, params.cleanupCreatedAtTtlDays, nowMs).state;
1460
1368
  }
1461
- if (message) {
1462
- parts.push(`message=${message}`);
1369
+ const existingMsgId = resolveExistingMsgId(
1370
+ state,
1371
+ {
1372
+ direction: params.direction,
1373
+ msgId: params.msgId,
1374
+ delivery: params.delivery
1375
+ },
1376
+ nowMs
1377
+ );
1378
+ const canonicalMsgId = existingMsgId || params.msgId || params.delivery?.messageId || params.delivery?.processQueryKey || params.delivery?.outTrackId;
1379
+ if (!canonicalMsgId || !canonicalMsgId.trim()) {
1380
+ return void 0;
1463
1381
  }
1464
- parts.push(`payload=${serialized}`);
1465
- return parts.join(" ");
1466
- }
1467
- function formatDingTalkErrorPayloadLog(scope, payload, prefix = "[DingTalk]") {
1468
- return `${prefix}[ErrorPayload][${scope}] ${formatDingTalkErrorPayload(payload)}`;
1469
- }
1470
- function getProxyBypassOption(config) {
1471
- return config?.bypassProxyForSend ? { proxy: false } : {};
1382
+ const existing = state.records[canonicalMsgId];
1383
+ const expiresAt = computeExpiresAt(nowMs, params.ttlMs, params.ttlReferenceMs);
1384
+ const normalizedQuotedRef = normalizeQuotedRef(params.quotedRef);
1385
+ state.records[canonicalMsgId] = {
1386
+ msgId: canonicalMsgId,
1387
+ direction: params.direction,
1388
+ topic: params.topic ?? existing?.topic ?? null,
1389
+ accountId: params.accountId,
1390
+ conversationId: params.conversationId,
1391
+ createdAt: existing?.createdAt ?? params.createdAt,
1392
+ updatedAt: nowMs,
1393
+ expiresAt: expiresAt === void 0 ? existing?.expiresAt : Math.max(expiresAt, existing?.expiresAt ?? 0),
1394
+ messageType: params.messageType || existing?.messageType,
1395
+ text: mergeText(existing?.text, params.text),
1396
+ attachmentText: mergeAttachmentText(existing?.attachmentText, params.attachmentText),
1397
+ attachmentTextSource: mergeAttachmentTextSource(
1398
+ existing?.attachmentTextSource,
1399
+ params.attachmentTextSource
1400
+ ),
1401
+ attachmentTextTruncated: mergeAttachmentTextTruncated(
1402
+ existing?.attachmentTextTruncated,
1403
+ params.attachmentTextTruncated
1404
+ ),
1405
+ attachmentFileName: mergeAttachmentFileName(
1406
+ existing?.attachmentFileName,
1407
+ params.attachmentFileName
1408
+ ),
1409
+ quotedRef: mergeQuotedRef(existing?.quotedRef, normalizedQuotedRef),
1410
+ senderId: mergeStringField(existing?.senderId, params.senderId),
1411
+ senderName: mergeStringField(existing?.senderName, params.senderName),
1412
+ mentions: mergeMentions(existing?.mentions, params.mentions),
1413
+ chatType: params.chatType || existing?.chatType,
1414
+ quotedMessageId: mergeStringField(existing?.quotedMessageId, params.quotedMessageId),
1415
+ media: mergeMedia(existing?.media, params.media),
1416
+ delivery: mergeDelivery(existing?.delivery, params.delivery)
1417
+ };
1418
+ state.updatedAt = nowMs;
1419
+ writeState(params, normalizeState(state, nowMs).state);
1420
+ return canonicalMsgId;
1472
1421
  }
1473
- function createResolve4FallbackLookup(log, accountId) {
1474
- return createResolve4FallbackLookupWithDeps(log, accountId, dns, net);
1422
+ function upsertInboundMessageContext(params) {
1423
+ return upsertRecord({
1424
+ ...params,
1425
+ direction: "inbound",
1426
+ topic: params.topic ?? null,
1427
+ msgId: params.msgId,
1428
+ cleanupCreatedAtTtlDays: params.cleanupCreatedAtTtlDays
1429
+ }) || params.msgId;
1475
1430
  }
1476
- function createResolve4FallbackLookupWithDeps(log, accountId, dnsImpl, netImpl) {
1477
- let fallbackLogged = false;
1478
- return (hostname, options, callback) => {
1479
- const ipFamily = netImpl.isIP(hostname);
1480
- if (ipFamily !== 0) {
1481
- if (options.all) {
1482
- callback(null, [{ address: hostname, family: ipFamily }], ipFamily);
1483
- return;
1484
- }
1485
- callback(null, hostname, ipFamily);
1486
- return;
1487
- }
1488
- dnsImpl.lookup(hostname, options, (lookupErr, address, family) => {
1489
- if (!lookupErr) {
1490
- callback(null, address, family);
1491
- return;
1492
- }
1493
- if (lookupErr.code !== "ENOTFOUND") {
1494
- callback(lookupErr, address, family);
1495
- return;
1496
- }
1497
- dnsImpl.resolve4(hostname, (resolveErr, addresses) => {
1498
- if (resolveErr || !addresses || addresses.length === 0) {
1499
- callback(lookupErr, address, family);
1500
- return;
1501
- }
1502
- if (!fallbackLogged) {
1503
- fallbackLogged = true;
1504
- log?.warn?.(
1505
- `[${accountId ?? "default"}] System DNS lookup failed for ${hostname} (ENOTFOUND); using resolve4 fallback ${addresses[0]}`
1506
- );
1507
- }
1508
- if (options.all) {
1509
- callback(
1510
- null,
1511
- addresses.map((item) => ({ address: item, family: 4 })),
1512
- 4
1513
- );
1514
- return;
1515
- }
1516
- callback(null, addresses[0], 4);
1517
- });
1518
- });
1519
- };
1431
+ function upsertOutboundMessageContext(params) {
1432
+ return upsertRecord({
1433
+ ...params,
1434
+ direction: "outbound",
1435
+ topic: params.topic ?? null
1436
+ });
1520
1437
  }
1521
- function getHeaderCaseInsensitive(headers, key) {
1522
- if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
1523
- return void 0;
1524
- }
1525
- const entries = Object.entries(headers);
1526
- const matched = entries.find(([name]) => name.toLowerCase() === key.toLowerCase());
1527
- if (!matched) {
1528
- return void 0;
1529
- }
1530
- const value = matched[1];
1531
- if (Array.isArray(value)) {
1532
- return value.length > 0 ? String(value[0]) : void 0;
1533
- }
1534
- if (value === null || value === void 0) {
1535
- return void 0;
1536
- }
1537
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
1538
- return String(value);
1438
+ function resolveByMsgId(params) {
1439
+ const nowMs = params.nowMs ?? Date.now();
1440
+ const state = loadState(params, nowMs);
1441
+ const direct = state.records[params.msgId];
1442
+ if (direct && !isRecordExpired(direct, nowMs)) {
1443
+ return direct;
1539
1444
  }
1540
- try {
1541
- return JSON.stringify(value);
1542
- } catch {
1543
- return void 0;
1445
+ const aliasTarget = state.byAlias[buildAliasKey("inboundMsgId", params.msgId)];
1446
+ if (!aliasTarget) {
1447
+ return null;
1544
1448
  }
1449
+ const record = state.records[aliasTarget];
1450
+ return record && !isRecordExpired(record, nowMs) ? record : null;
1545
1451
  }
1546
- function formatDingTalkConnectionErrorLog(scope, err, baseMessage) {
1547
- if (!err || typeof err !== "object") {
1452
+ function resolveByAlias(params) {
1453
+ const nowMs = params.nowMs ?? Date.now();
1454
+ const state = loadState(params, nowMs);
1455
+ const msgId = state.byAlias[buildAliasKey(params.kind, params.value)];
1456
+ if (!msgId) {
1548
1457
  return null;
1549
1458
  }
1550
- const errRecord = err;
1551
- const stage = typeof errRecord.dingtalkConnectionStage === "string" ? errRecord.dingtalkConnectionStage : scope;
1552
- const endpoint = typeof errRecord.dingtalkConnectionEndpoint === "string" ? errRecord.dingtalkConnectionEndpoint : void 0;
1553
- const hasResponse = "response" in errRecord && errRecord.response !== null && errRecord.response !== void 0;
1554
- if (!hasResponse && !endpoint && stage === scope) {
1459
+ const record = state.records[msgId];
1460
+ return record && !isRecordExpired(record, nowMs) ? record : null;
1461
+ }
1462
+ function resolveByQuotedRef(params) {
1463
+ const nowMs = params.nowMs ?? Date.now();
1464
+ const quotedRef = normalizeQuotedRef(params.quotedRef);
1465
+ const log = params.log;
1466
+ if (!quotedRef) {
1467
+ log?.debug?.(
1468
+ `[DingTalk][QuotedRef] Resolve skipped: invalid quotedRef accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1469
+ );
1555
1470
  return null;
1556
1471
  }
1557
- const parts = [`${baseMessage} [DingTalk][ConnectionError][${stage}]`];
1558
- if (endpoint) {
1559
- parts.push(`endpoint=${endpoint}`);
1560
- }
1561
- const response = err.response;
1562
- if (response) {
1563
- if (response.status !== void 0 && response.status !== null) {
1564
- const statusText = typeof response.status === "string" || typeof response.status === "number" || typeof response.status === "boolean" || typeof response.status === "bigint" ? String(response.status) : JSON.stringify(response.status);
1565
- parts.push(`status=${statusText}`);
1566
- }
1567
- let requestId = getHeaderCaseInsensitive(response.headers, "x-acs-dingtalk-request-id");
1568
- if (!requestId && response.data && typeof response.data === "object" && !Array.isArray(response.data)) {
1569
- const data = response.data;
1570
- if (typeof data.requestId === "string") {
1571
- requestId = data.requestId;
1572
- } else if (typeof data.requestid === "string") {
1573
- requestId = data.requestid;
1574
- }
1575
- }
1576
- if (requestId) {
1577
- parts.push(`requestId=${requestId}`);
1472
+ if (quotedRef.targetDirection === "inbound") {
1473
+ if (quotedRef.key !== "msgId" || !quotedRef.value) {
1474
+ log?.debug?.(
1475
+ `[DingTalk][QuotedRef] Resolve skipped: inbound quotedRef missing msgId accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1476
+ );
1477
+ return null;
1578
1478
  }
1579
- if (response.data !== void 0) {
1580
- parts.push(formatDingTalkErrorPayload(response.data));
1479
+ const record = resolveByMsgId({
1480
+ ...params,
1481
+ msgId: quotedRef.value,
1482
+ nowMs
1483
+ });
1484
+ log?.debug?.(
1485
+ `[DingTalk][QuotedRef] Resolve inbound by msgId=${quotedRef.value} hit=${record ? "yes" : "no"} accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1486
+ );
1487
+ return record;
1488
+ }
1489
+ if (quotedRef.key && quotedRef.value && quotedRef.key !== "msgId") {
1490
+ const aliasRecord = resolveByAlias({
1491
+ ...params,
1492
+ kind: quotedRef.key,
1493
+ value: quotedRef.value,
1494
+ nowMs
1495
+ });
1496
+ if (aliasRecord) {
1497
+ log?.debug?.(
1498
+ `[DingTalk][QuotedRef] Resolve outbound by ${quotedRef.key}=${quotedRef.value} hit=yes accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1499
+ );
1500
+ return aliasRecord;
1581
1501
  }
1502
+ log?.debug?.(
1503
+ `[DingTalk][QuotedRef] Resolve outbound by ${quotedRef.key}=${quotedRef.value} hit=no accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1504
+ );
1582
1505
  }
1583
- if (stage === "connect.websocket") {
1584
- parts.push("Likely websocket/proxy/WSS issue after connections/open succeeded");
1506
+ if (typeof quotedRef.fallbackCreatedAt === "number" && Number.isFinite(quotedRef.fallbackCreatedAt)) {
1507
+ const record = resolveByCreatedAtWindow({
1508
+ ...params,
1509
+ createdAt: quotedRef.fallbackCreatedAt,
1510
+ direction: "outbound",
1511
+ nowMs
1512
+ });
1513
+ log?.debug?.(
1514
+ `[DingTalk][QuotedRef] Resolve outbound by createdAt=${quotedRef.fallbackCreatedAt} hit=${record ? "yes" : "no"} accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1515
+ );
1516
+ return record;
1585
1517
  }
1586
- parts.push("See docs/connection-troubleshooting.md or run scripts/dingtalk-connection-check.*");
1587
- return parts.join(" ");
1518
+ log?.debug?.(
1519
+ `[DingTalk][QuotedRef] Resolve outbound missed without createdAt fallback accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`
1520
+ );
1521
+ return null;
1588
1522
  }
1589
- function cleanupOrphanedTempFiles(log) {
1590
- const tempDir = os2.tmpdir();
1591
- const dingtalkPattern = /^dingtalk_\d+\..+$/;
1592
- let cleaned = 0;
1593
- try {
1594
- const files = fs2.readdirSync(tempDir);
1595
- const now = Date.now();
1596
- const maxAge = 24 * 60 * 60 * 1e3;
1597
- for (const file of files) {
1598
- if (!dingtalkPattern.test(file)) {
1599
- continue;
1600
- }
1601
- const filePath = path3.join(tempDir, file);
1602
- try {
1603
- const stats = fs2.statSync(filePath);
1604
- if (now - stats.mtime.getTime() > maxAge) {
1605
- fs2.unlinkSync(filePath);
1606
- cleaned++;
1607
- log?.debug?.(`[DingTalk] Cleaned up orphaned temp file: ${file}`);
1608
- }
1609
- } catch (err) {
1610
- log?.debug?.(`[DingTalk] Failed to cleanup temp file ${file}: ${getErrorMessage(err)}`);
1611
- }
1523
+ function resolveByCreatedAtWindow(params) {
1524
+ const nowMs = params.nowMs ?? Date.now();
1525
+ const windowMs = params.windowMs ?? DEFAULT_CREATED_AT_MATCH_WINDOW_MS;
1526
+ const state = loadState(params, nowMs);
1527
+ let bestRecord = null;
1528
+ let bestDelta = Infinity;
1529
+ for (const msgId of state.recentByCreatedAt) {
1530
+ const record = state.records[msgId];
1531
+ if (!record || isRecordExpired(record, nowMs)) {
1532
+ continue;
1612
1533
  }
1613
- if (cleaned > 0) {
1614
- log?.info?.(`[DingTalk] Cleaned up ${cleaned} orphaned temp files`);
1534
+ if (params.direction && record.direction !== params.direction) {
1535
+ continue;
1615
1536
  }
1616
- } catch (err) {
1617
- log?.debug?.(`[DingTalk] Failed to cleanup temp directory: ${getErrorMessage(err)}`);
1618
- }
1619
- return cleaned;
1620
- }
1621
- async function retryWithBackoff(fn, options = {}) {
1622
- const { maxRetries = 3, baseDelayMs = 100, log } = options;
1623
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
1624
- try {
1625
- return await fn();
1626
- } catch (err) {
1627
- const statusCode = err.response?.status;
1628
- const isRetryable = statusCode === 401 || statusCode === 429 || statusCode && statusCode >= 500;
1629
- const responseData = getErrorResponseData(err);
1630
- if (responseData !== void 0) {
1631
- log?.debug?.(formatDingTalkErrorPayloadLog("retry.beforeDecision", responseData));
1632
- }
1633
- if (!isRetryable || attempt === maxRetries) {
1634
- throw err;
1635
- }
1636
- const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
1637
- log?.debug?.(`[DingTalk] Retry attempt ${attempt}/${maxRetries} after ${delayMs}ms`);
1638
- await new Promise((resolve3) => setTimeout(resolve3, delayMs));
1537
+ const delta = Math.abs(record.createdAt - params.createdAt);
1538
+ if (delta <= windowMs && delta < bestDelta) {
1539
+ bestDelta = delta;
1540
+ bestRecord = record;
1639
1541
  }
1640
1542
  }
1641
- throw new Error("Retry exhausted without returning");
1642
- }
1643
- function getCurrentTimestamp() {
1644
- return Date.now();
1543
+ return bestRecord;
1645
1544
  }
1646
1545
 
1546
+ // src/config-schema.ts
1547
+ var AckReactionSchema = z2.union([
1548
+ z2.literal(""),
1549
+ z2.enum(["off", "emoji", "kaomoji"]),
1550
+ z2.string().min(1)
1551
+ ]);
1552
+ var CardStreamingModeSchema = z2.enum(["off", "answer", "all"]);
1553
+ var ContextVisibilitySchema = z2.enum(["all", "allowlist", "allowlist_quote"]);
1554
+ var DingTalkAccountConfigShape = {
1555
+ /** Account name (optional display name) */
1556
+ name: z2.string().optional(),
1557
+ /** Enable or disable this DingTalk channel/account without deleting saved credentials. */
1558
+ enabled: z2.boolean().optional().default(true),
1559
+ /** DingTalk App Key (Client ID) used to authenticate API and Stream connections. */
1560
+ clientId: z2.string().optional(),
1561
+ /** DingTalk App Secret (Client Secret) used to obtain DingTalk access tokens. */
1562
+ clientSecret: buildSecretInputSchema().optional(),
1563
+ /** Direct-message access policy: open, pairing, or allowlist. */
1564
+ dmPolicy: z2.enum(["open", "pairing", "allowlist"]).optional().default("open"),
1565
+ /** Group-message access policy: open, allowlist, or disabled. */
1566
+ groupPolicy: z2.enum(["open", "allowlist", "disabled"]).optional().default("open"),
1567
+ /** User IDs allowed when `dmPolicy` is `allowlist`. */
1568
+ allowFrom: z2.array(z2.string()).optional(),
1569
+ /** Sender IDs allowed when `groupPolicy` is `allowlist`. */
1570
+ groupAllowFrom: z2.array(z2.string()).optional(),
1571
+ /** Default disabled. Enabling `all` allows learned displayName lookup but may misroute on stale or duplicate names and is available to all callers until upstream exposes requester authz context. */
1572
+ displayNameResolution: z2.enum(["disabled", "all"]).optional().default("disabled"),
1573
+ /** Controls how much supplemental host context remains visible to the reply runtime. `allowlist_quote` is the safest advanced mode when only explicit quotes or replies should remain visible. */
1574
+ contextVisibility: ContextVisibilitySchema.optional(),
1575
+ /** Allowed remote media download hosts, IPs, or CIDRs for media fetches. */
1576
+ mediaUrlAllowlist: z2.array(z2.string()).optional(),
1577
+ /** Native acknowledgement reaction mode: off, emoji, kaomoji, or a custom compatibility string. */
1578
+ ackReaction: AckReactionSchema.optional(),
1579
+ /** Retention window in days for short-lived message context used by quoting and media recovery. */
1580
+ journalTTLDays: z2.number().int().min(1).optional().default(DEFAULT_MESSAGE_CONTEXT_TTL_DAYS),
1581
+ /** Enable verbose DingTalk channel debug logging. */
1582
+ debug: z2.boolean().optional().default(false),
1583
+ /** Default reply delivery mode: markdown or card. */
1584
+ messageType: z2.enum(["markdown", "card"]).optional().default("markdown"),
1585
+ /** Deprecated and ignored. AI card replies now always use the built-in DingTalk template contract. Keep only for backward-compatible config parsing. */
1586
+ cardTemplateId: z2.string().optional(),
1587
+ /** Deprecated and ignored. The built-in AI card contract owns the streaming field mapping. Keep only for backward-compatible config parsing. */
1588
+ cardTemplateKey: z2.string().optional().default("content"),
1589
+ /** Per-group overrides keyed by conversationId. Supports `*` as a wildcard fallback. */
1590
+ groups: z2.record(
1591
+ z2.string(),
1592
+ z2.object({
1593
+ /** Additional system prompt appended for this group. */
1594
+ systemPrompt: z2.string().optional(),
1595
+ /** Require an explicit @mention before the bot answers in this group. */
1596
+ requireMention: z2.boolean().optional(),
1597
+ /** Optional per-group sender allowlist for tighter access control than the channel default. */
1598
+ groupAllowFrom: z2.array(z2.string()).optional()
1599
+ })
1600
+ ).optional(),
1601
+ /** Connection robustness configuration */
1602
+ /** Maximum connection attempts in a single reconnect cycle before backing off or giving up. */
1603
+ maxConnectionAttempts: z2.number().int().min(1).optional().default(10),
1604
+ /** Initial reconnect backoff delay in milliseconds. */
1605
+ initialReconnectDelay: z2.number().int().min(100).optional().default(1e3),
1606
+ /** Upper bound for reconnect backoff delay in milliseconds. */
1607
+ maxReconnectDelay: z2.number().int().min(1e3).optional().default(6e4),
1608
+ /** Randomization factor added to reconnect backoff to avoid synchronized reconnect storms. */
1609
+ reconnectJitter: z2.number().min(0).max(1).optional().default(0.3),
1610
+ /** Maximum reconnect cycles before the channel stops retrying and waits for the next lifecycle restart. */
1611
+ maxReconnectCycles: z2.number().int().min(1).optional().default(10),
1612
+ /** Time limit in milliseconds for one reconnect cycle before starting a fresh cycle. */
1613
+ reconnectDeadlineMs: z2.number().int().min(5e3).optional().default(5e4),
1614
+ /** Enable the plugin connection manager. Disable only when you intentionally rely on DWClient native keepAlive plus autoReconnect behavior. */
1615
+ useConnectionManager: z2.boolean().optional().default(true),
1616
+ /** Maximum inbound media size in MB accepted by the plugin. When omitted, the runtime default is used. */
1617
+ mediaMaxMb: z2.number().int().min(1).optional(),
1618
+ /** Enable the underlying Stream client heartbeat. When omitted, runtime derives a default from `useConnectionManager`. */
1619
+ keepAlive: z2.boolean().optional(),
1620
+ /** Bypass global or system HTTP(S) proxy settings for DingTalk send, upload, and card APIs. */
1621
+ bypassProxyForSend: z2.boolean().optional().default(false),
1622
+ /** Controls the proactive-send permission reminder shown when a conversation has not granted send rights yet. */
1623
+ proactivePermissionHint: z2.object({
1624
+ /** Show the proactive-send permission hint when the runtime detects missing DingTalk proactive permission. */
1625
+ enabled: z2.boolean().optional().default(true),
1626
+ /** Minimum cooldown in hours before the same proactive permission hint can be shown again. */
1627
+ cooldownHours: z2.number().int().min(1).max(24 * 30).optional().default(24)
1628
+ }).optional().default({ enabled: true, cooldownHours: 24 }),
1629
+ /** Deprecated compatibility flag. When true and `cardStreamingMode` is unset, runtime resolves to `cardStreamingMode: "all"`. Do not use in new configs. */
1630
+ cardRealTimeStream: z2.boolean().optional(),
1631
+ /** Card streaming mode:
1632
+ * - off: disable incremental streaming
1633
+ * - answer: stream answer text
1634
+ * - all: stream answer + reasoning or thinking text */
1635
+ cardStreamingMode: CardStreamingModeSchema.optional(),
1636
+ /** Throttle interval in milliseconds between AI card streaming updates. */
1637
+ cardStreamInterval: z2.number().int().min(200).optional().default(1e3),
1638
+ /** Cooldown window in milliseconds after AI card trigger errors. Replies fall back to non-card delivery during this period. */
1639
+ aicardDegradeMs: z2.number().int().min(6e4).optional().default(30 * 60 * 1e3),
1640
+ /** Enable the local feedback-learning loop for notes, reflections, and command-assisted learning. */
1641
+ learningEnabled: z2.boolean().optional(),
1642
+ /** Automatically apply generated learning output into session notes or global rules when available. */
1643
+ learningAutoApply: z2.boolean().optional(),
1644
+ /** Retention window in milliseconds for temporary learning notes. */
1645
+ learningNoteTtlMs: z2.number().int().min(6e4).optional(),
1646
+ /** Convert markdown tables to plain text before sending when you want more consistent DingTalk rendering. */
1647
+ convertMarkdownTables: z2.boolean().optional().default(true),
1648
+ /** @mention the sender after card finalization in group chats.
1649
+ * Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
1650
+ * Leave empty or omit to disable. */
1651
+ cardAtSender: z2.string().optional(),
1652
+ /** Status line visibility toggles for the AI card footer. */
1653
+ cardStatusLine: z2.object({
1654
+ /** Show model name. */
1655
+ model: z2.boolean().optional().default(true),
1656
+ /** Show thinking effort level. */
1657
+ effort: z2.boolean().optional().default(true),
1658
+ /** Show agent display name. */
1659
+ agent: z2.boolean().optional().default(true),
1660
+ /** Show task elapsed time. */
1661
+ taskTime: z2.boolean().optional().default(false),
1662
+ /** Show token usage summary (input/output/cache). */
1663
+ tokens: z2.boolean().optional().default(false),
1664
+ /** Show DingTalk API call count. */
1665
+ dapiUsage: z2.boolean().optional().default(false)
1666
+ }).optional().default({ model: true, effort: true, agent: true, taskTime: false, tokens: false, dapiUsage: false })
1667
+ };
1668
+ var DingTalkAccountConfigSchema = z2.object(DingTalkAccountConfigShape);
1669
+ var DingTalkConfigSchema = DingTalkAccountConfigSchema.extend({
1670
+ /** Multi-account configuration */
1671
+ accounts: z2.record(z2.string(), DingTalkAccountConfigSchema.optional()).optional()
1672
+ });
1673
+
1674
+ // src/gateway/channel-gateway.ts
1675
+ import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
1676
+
1647
1677
  // src/card-callback-service.ts
1648
1678
  var DINGTALK_API = "https://api.dingtalk.com";
1649
1679
  function stringifyCandidate(value) {
@@ -1784,7 +1814,7 @@ async function updateCardVariables(outTrackId, params, token, config) {
1784
1814
  {
1785
1815
  outTrackId,
1786
1816
  cardData: { cardParamMap: stringMap },
1787
- cardUpdateOptions: { updateCardDataByKey: true, updatePrivateDataByKey: true }
1817
+ cardUpdateOptions: { updateCardDataByKey: true }
1788
1818
  },
1789
1819
  {
1790
1820
  headers: {
@@ -1904,36 +1934,6 @@ import { randomUUID } from "node:crypto";
1904
1934
  import * as fs3 from "node:fs";
1905
1935
  import * as path4 from "node:path";
1906
1936
 
1907
- // src/auth.ts
1908
- var accessTokenCache = /* @__PURE__ */ new Map();
1909
- async function getAccessToken(config, log) {
1910
- const cacheKey = config.clientId;
1911
- const now = Date.now();
1912
- const cached = accessTokenCache.get(cacheKey);
1913
- if (cached && cached.expiry > now + 6e4) {
1914
- return cached.accessToken;
1915
- }
1916
- const runtimeConfig = await resolveRuntimeConfig(config, log);
1917
- const token = await retryWithBackoff(
1918
- async () => {
1919
- const response = await http_client_default.post(
1920
- "https://api.dingtalk.com/v1.0/oauth2/accessToken",
1921
- {
1922
- appKey: runtimeConfig.clientId,
1923
- appSecret: runtimeConfig.clientSecret
1924
- }
1925
- );
1926
- accessTokenCache.set(cacheKey, {
1927
- accessToken: response.data.accessToken,
1928
- expiry: now + response.data.expireIn * 1e3
1929
- });
1930
- return response.data.accessToken;
1931
- },
1932
- { maxRetries: 3, log }
1933
- );
1934
- return token;
1935
- }
1936
-
1937
1937
  // src/card/card-template.ts
1938
1938
  var STOP_ACTION_VISIBLE = true;
1939
1939
  var BUILTIN_DINGTALK_CARD_TEMPLATE_ID = process.env.DINGTALK_CARD_TEMPLATE_ID || "675cde2f-f526-40cb-b828-f5b2b57b8b77.schema";
@@ -2614,6 +2614,19 @@ async function createAICard(config, conversationId, log, options = {}) {
2614
2614
  upsertPendingCard(aiCardInstance, options.storePath, log);
2615
2615
  }
2616
2616
  clearAICardDegrade(accountId, log);
2617
+ try {
2618
+ await putAICardStreamingField(aiCardInstance, template.contentKey, "", false, log, {
2619
+ suppressDegrade: true
2620
+ });
2621
+ aiCardInstance.state = AICardStatus.INPUTING;
2622
+ if (shouldPersistPending) {
2623
+ upsertPendingCard(aiCardInstance, options.storePath, log);
2624
+ }
2625
+ } catch (kickErr) {
2626
+ log?.debug?.(
2627
+ `[DingTalk][AICard] Non-critical: failed to kick card into streaming mode: ${kickErr.message}`
2628
+ );
2629
+ }
2617
2630
  return aiCardInstance;
2618
2631
  } catch (err) {
2619
2632
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
@@ -2712,13 +2725,13 @@ async function clearAICardStreamingContent(card, log) {
2712
2725
  log?.debug?.(`[DingTalk][AICard] Non-critical: failed to clear streaming content: ${message}`);
2713
2726
  }
2714
2727
  }
2715
- async function finalizeAICardStreamingLifecycleIfNeeded(card, content, log) {
2728
+ async function finalizeAICardStreamingLifecycleIfNeeded(card, log) {
2716
2729
  if (!card.streamLifecycleOpened) {
2717
2730
  return;
2718
2731
  }
2719
2732
  const template = DINGTALK_CARD_TEMPLATE;
2720
2733
  try {
2721
- await putAICardStreamingField(card, template.streamingKey, content, true, log, {
2734
+ await putAICardStreamingField(card, template.streamingKey, "", true, log, {
2722
2735
  suppressDegrade: true
2723
2736
  });
2724
2737
  } catch (err) {
@@ -2734,7 +2747,7 @@ async function commitAICardBlocks(card, options, log) {
2734
2747
  return;
2735
2748
  }
2736
2749
  await ensureFreshToken(card, log);
2737
- await finalizeAICardStreamingLifecycleIfNeeded(card, options.content, log);
2750
+ await finalizeAICardStreamingLifecycleIfNeeded(card, log);
2738
2751
  const template = DINGTALK_CARD_TEMPLATE;
2739
2752
  const updates = {
2740
2753
  [template.blockListKey]: options.blockListJson,
@@ -2797,7 +2810,7 @@ async function finalizeStoppedAICard(card, options, log) {
2797
2810
  await ensureFreshToken(card, log);
2798
2811
  const template = DINGTALK_CARD_TEMPLATE;
2799
2812
  const payload = buildStoppedCardFinalizePayload(options);
2800
- await finalizeAICardStreamingLifecycleIfNeeded(card, payload.content, log);
2813
+ await finalizeAICardStreamingLifecycleIfNeeded(card, log);
2801
2814
  try {
2802
2815
  await updateCardVariables(
2803
2816
  card.outTrackId || card.cardInstanceId,
@@ -4475,7 +4488,7 @@ function stripLeadingInvisibleChars(value) {
4475
4488
 
4476
4489
  // src/inbound-handler.ts
4477
4490
  import fs6 from "node:fs";
4478
- import * as path9 from "node:path";
4491
+ import * as path10 from "node:path";
4479
4492
  import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
4480
4493
  import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
4481
4494
 
@@ -6716,7 +6729,7 @@ function isMarkdownTableSeparator(line) {
6716
6729
  return false;
6717
6730
  }
6718
6731
  const cells = normalized.replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
6719
- return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
6732
+ return cells.length > 0 && cells.every((cell) => /^:?-{1,}:?$/.test(cell));
6720
6733
  }
6721
6734
  function isMarkdownTableRow(line) {
6722
6735
  const trimmed = line.trim();
@@ -6725,9 +6738,58 @@ function isMarkdownTableRow(line) {
6725
6738
  function parseMarkdownTableRow(line) {
6726
6739
  return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
6727
6740
  }
6728
- function renderMarkdownTable(lines) {
6729
- const rows = lines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
6730
- return rows.map((cells) => cells.join(" | ")).join(" \n");
6741
+ function parseSeparatorAlignment(cell) {
6742
+ const trimmed = cell.trim();
6743
+ const hasLeftColon = trimmed.startsWith(":");
6744
+ const hasRightColon = trimmed.endsWith(":");
6745
+ if (hasLeftColon && hasRightColon) {
6746
+ return "center";
6747
+ }
6748
+ if (hasRightColon) {
6749
+ return "right";
6750
+ }
6751
+ if (hasLeftColon) {
6752
+ return "left";
6753
+ }
6754
+ return "center";
6755
+ }
6756
+ function buildSeparatorRow(alignments) {
6757
+ const cells = alignments.map((align) => {
6758
+ if (align === "left") {
6759
+ return ":---";
6760
+ }
6761
+ if (align === "right") {
6762
+ return "---:";
6763
+ }
6764
+ return ":---:";
6765
+ });
6766
+ return "|" + cells.join("|") + "|";
6767
+ }
6768
+ function renderMarkdownTable(headerLine, separatorLine, dataLines) {
6769
+ const headerCells = parseMarkdownTableRow(headerLine);
6770
+ const separatorCells = parseMarkdownTableRow(separatorLine);
6771
+ const dataRows = dataLines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
6772
+ if (headerCells.length === 0) {
6773
+ return "";
6774
+ }
6775
+ const colCount = Math.max(
6776
+ headerCells.length,
6777
+ separatorCells.length,
6778
+ ...dataRows.map((cells) => cells.length)
6779
+ );
6780
+ const alignments = [];
6781
+ for (let i = 0; i < colCount; i++) {
6782
+ const sepCell = separatorCells[i] || "";
6783
+ alignments.push(parseSeparatorAlignment(sepCell));
6784
+ }
6785
+ const separator = buildSeparatorRow(alignments);
6786
+ const allRows = [headerCells, ...dataRows];
6787
+ const rendered = allRows.map((cells) => {
6788
+ const padded = cells.length < colCount ? [...cells, ...Array(colCount - cells.length).fill("")] : cells;
6789
+ return "|" + padded.join("|") + "|";
6790
+ });
6791
+ rendered.splice(1, 0, separator);
6792
+ return rendered.join("\n");
6731
6793
  }
6732
6794
  function convertMarkdownTablesToPlainText(text) {
6733
6795
  const lines = text.split("\n");
@@ -6743,13 +6805,20 @@ function convertMarkdownTablesToPlainText(text) {
6743
6805
  continue;
6744
6806
  }
6745
6807
  if (!inCodeFence && index + 1 < lines.length && isMarkdownTableRow(line) && isMarkdownTableSeparator(lines[index + 1] || "")) {
6746
- const tableLines = [line];
6808
+ const headerLine = line;
6809
+ const separatorLine = lines[index + 1] || "";
6810
+ const dataLines = [];
6747
6811
  index += 2;
6748
6812
  while (index < lines.length && isMarkdownTableRow(lines[index] || "")) {
6749
- tableLines.push(lines[index] || "");
6813
+ dataLines.push(lines[index] || "");
6750
6814
  index += 1;
6751
6815
  }
6752
- output.push(renderMarkdownTable(tableLines));
6816
+ const renderedTable = renderMarkdownTable(headerLine, separatorLine, dataLines);
6817
+ const lastOutput = output[output.length - 1];
6818
+ if (lastOutput !== void 0 && lastOutput.trim() !== "") {
6819
+ output.push("");
6820
+ }
6821
+ output.push(renderedTable);
6753
6822
  continue;
6754
6823
  }
6755
6824
  output.push(line);
@@ -7247,6 +7316,9 @@ function buildPersistedOutboundText(text, options) {
7247
7316
  }
7248
7317
  return text;
7249
7318
  }
7319
+ function shouldRouteSessionMediaViaProactive(mediaType) {
7320
+ return mediaType === "voice" || mediaType === "video" || mediaType === "file";
7321
+ }
7250
7322
  var DINGTALK_TEXT_CHUNK_LIMIT = 3800;
7251
7323
  var CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS = 150;
7252
7324
  var CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS = 25;
@@ -7643,51 +7715,33 @@ async function sendBySession(config, sessionWebhook, text, options = {}) {
7643
7715
  const token = await getAccessToken(config, options.log);
7644
7716
  const log = options.log || getLogger();
7645
7717
  if (options.mediaPath && options.mediaType) {
7646
- const uploadResult = await uploadMedia2(config, options.mediaPath, options.mediaType, log, {
7647
- mediaLocalRoots: options.mediaLocalRoots
7648
- });
7649
- if (uploadResult) {
7650
- const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
7651
- let body;
7652
- if (options.mediaType === "image") {
7653
- body = { msgtype: "image", image: { media_id: mediaId } };
7654
- } else if (options.mediaType === "voice") {
7655
- const durationMs = uploadedDurationMs ?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
7656
- body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
7657
- log?.debug?.(
7658
- `[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`
7659
- );
7660
- } else if (options.mediaType === "video") {
7661
- body = { msgtype: "video", video: { media_id: mediaId } };
7662
- } else if (options.mediaType === "file") {
7663
- body = { msgtype: "file", file: { media_id: mediaId } };
7664
- }
7665
- if (body) {
7666
- const result = await http_client_default({
7667
- url: sessionWebhook,
7668
- method: "POST",
7669
- data: body,
7670
- headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
7671
- ...getProxyBypassOption(config)
7672
- });
7718
+ if (options.mediaType === "image") {
7719
+ const uploadResult = await uploadMedia2(config, options.mediaPath, options.mediaType, log, {
7720
+ mediaLocalRoots: options.mediaLocalRoots
7721
+ });
7722
+ if (uploadResult) {
7723
+ const imageMarkdown = `![${path7.basename(options.mediaPath)}](${uploadResult.mediaId})`;
7724
+ text = text ? `${text}
7725
+
7726
+ ${imageMarkdown}` : imageMarkdown;
7673
7727
  log?.debug?.(
7674
- `[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`
7728
+ `[DingTalk] Session webhook image will be delivered as markdown media reference mediaId=${uploadResult.mediaId}`
7675
7729
  );
7676
- ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
7677
- const delivery = extractOutboundDeliveryMetadata(result.data);
7678
- if (!delivery.messageId && !delivery.processQueryKey && !delivery.outTrackId) {
7679
- log?.warn?.(
7680
- `[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` + summarizeSessionWebhookResponse(result.data)
7681
- );
7682
- }
7683
- return result.data;
7730
+ } else {
7731
+ const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(\u5A92\u4F53\u53D1\u9001\u5931\u8D25)";
7732
+ text = `${text}
7733
+
7734
+ \u{1F4CE} \u5A92\u4F53\u53D1\u9001\u5931\u8D25\uFF0C\u515C\u5E95\u94FE\u63A5/\u8DEF\u5F84\uFF1A${mediaHint}`.trim();
7735
+ log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
7684
7736
  }
7685
7737
  } else {
7686
7738
  const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(\u5A92\u4F53\u53D1\u9001\u5931\u8D25)";
7687
7739
  text = `${text}
7688
7740
 
7689
- \u{1F4CE} \u5A92\u4F53\u53D1\u9001\u5931\u8D25\uFF0C\u515C\u5E95\u94FE\u63A5/\u8DEF\u5F84\uFF1A${mediaHint}`.trim();
7690
- log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
7741
+ \u{1F4CE} \u5F53\u524D\u4F1A\u8BDD\u65E0\u6CD5\u76F4\u63A5\u53D1\u9001 ${options.mediaType}\uFF0C\u515C\u5E95\u94FE\u63A5/\u8DEF\u5F84\uFF1A${mediaHint}`.trim();
7742
+ log?.warn?.(
7743
+ `[DingTalk] Session webhook does not support native ${options.mediaType} replies; falling back to text description`
7744
+ );
7691
7745
  }
7692
7746
  }
7693
7747
  const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
@@ -7733,6 +7787,48 @@ async function sendMessage(config, conversationId, text, options = {}) {
7733
7787
  try {
7734
7788
  const messageType = config.messageType || "markdown";
7735
7789
  const log = options.log || getLogger();
7790
+ if (options.sessionWebhook && options.mediaPath && shouldRouteSessionMediaViaProactive(options.mediaType)) {
7791
+ log?.debug?.(
7792
+ `[DingTalk] Session webhook does not support ${options.mediaType} replies reliably; using proactive media API instead`
7793
+ );
7794
+ const proactiveMediaResult = await sendProactiveMedia(
7795
+ config,
7796
+ conversationId,
7797
+ options.mediaPath,
7798
+ options.mediaType,
7799
+ options
7800
+ );
7801
+ if (!proactiveMediaResult.ok) {
7802
+ log?.warn?.(
7803
+ `[DingTalk] Proactive ${options.mediaType} reply failed; falling back to session markdown: ` + (proactiveMediaResult.error || "unknown")
7804
+ );
7805
+ const data = await sendBySession(config, options.sessionWebhook, text, options);
7806
+ const delivery2 = extractOutboundDeliveryMetadata(data);
7807
+ const messageId2 = delivery2.messageId || delivery2.processQueryKey || delivery2.outTrackId;
7808
+ const persistedText = buildPersistedOutboundText(text, options);
7809
+ persistOutboundMessageContext({
7810
+ storePath: options.storePath,
7811
+ accountId: options.accountId,
7812
+ conversationId: options.conversationId || conversationId,
7813
+ text: persistedText,
7814
+ messageType: "outbound-media",
7815
+ quotedRef: options.quotedRef,
7816
+ log,
7817
+ ...DEFAULT_OUTBOUND_SENDER,
7818
+ chatType: inferConversationChatType(options.conversationId || conversationId),
7819
+ delivery: {
7820
+ ...delivery2,
7821
+ kind: "session"
7822
+ }
7823
+ });
7824
+ return { ok: true, data, messageId: messageId2 };
7825
+ }
7826
+ return {
7827
+ ok: true,
7828
+ data: proactiveMediaResult.data,
7829
+ messageId: proactiveMediaResult.messageId
7830
+ };
7831
+ }
7736
7832
  if (messageType === "card" && options.card && !options.forceMarkdown) {
7737
7833
  const card = options.card;
7738
7834
  if (isCardInTerminalState(card.state)) {
@@ -7754,26 +7850,6 @@ async function sendMessage(config, conversationId, text, options = {}) {
7754
7850
  };
7755
7851
  }
7756
7852
  }
7757
- if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
7758
- log?.debug?.(
7759
- "[DingTalk] Session webhook does not support voice replies reliably; using proactive media API for this voice response"
7760
- );
7761
- const proactiveVoiceResult = await sendProactiveMedia(
7762
- config,
7763
- conversationId,
7764
- options.mediaPath,
7765
- options.mediaType,
7766
- options
7767
- );
7768
- if (!proactiveVoiceResult.ok) {
7769
- return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
7770
- }
7771
- return {
7772
- ok: true,
7773
- data: proactiveVoiceResult.data,
7774
- messageId: proactiveVoiceResult.messageId
7775
- };
7776
- }
7777
7853
  if (options.sessionWebhook) {
7778
7854
  const data = await sendBySession(config, options.sessionWebhook, text, options);
7779
7855
  const delivery2 = extractOutboundDeliveryMetadata(data);
@@ -9061,8 +9137,6 @@ function createCardDraftController(params) {
9061
9137
  if (stopped || failed) {
9062
9138
  return;
9063
9139
  }
9064
- await contentLoop.flush();
9065
- await contentLoop.waitForInFlight();
9066
9140
  if (hasStreamingContent) {
9067
9141
  await clearStreamingContentFromCard();
9068
9142
  }
@@ -9265,6 +9339,7 @@ function createCardDraftController(params) {
9265
9339
  discardCurrentAnswer();
9266
9340
  } else {
9267
9341
  sealCurrentAnswer();
9342
+ queueRender();
9268
9343
  }
9269
9344
  await beginBoundaryFlush();
9270
9345
  return;
@@ -9482,7 +9557,6 @@ function createCardReplyStrategy(ctx) {
9482
9557
  };
9483
9558
  const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
9484
9559
  const streamAnswerLive = mode === "answer" || mode === "all";
9485
- const renderAnswerBlocksLive = mode === "all";
9486
9560
  const streamThinkingLive = mode === "all";
9487
9561
  let lifecycleState = "open";
9488
9562
  const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
@@ -9616,7 +9690,10 @@ function createCardReplyStrategy(ctx) {
9616
9690
  finalTextForFallback = normalized.answerText;
9617
9691
  return;
9618
9692
  }
9619
- await controller.updateAnswer(normalized.answerText);
9693
+ await controller.updateAnswer(normalized.answerText, {
9694
+ stream: streamAnswerLive,
9695
+ renderBlocks: !streamAnswerLive
9696
+ });
9620
9697
  }
9621
9698
  };
9622
9699
  const rewriteLocalMarkdownImagesToPlaceholders = (text) => {
@@ -9652,7 +9729,8 @@ function createCardReplyStrategy(ctx) {
9652
9729
  }
9653
9730
  await controller.updateAnswer(answerSnapshot, {
9654
9731
  stream: streamAnswerLive,
9655
- renderBlocks: renderAnswerBlocksLive
9732
+ // Active answer previews live in the content field; blockList is committed at boundaries/finalize.
9733
+ renderBlocks: false
9656
9734
  });
9657
9735
  };
9658
9736
  const applySplitTextToTimeline = async (text, options = {}) => {
@@ -9712,6 +9790,11 @@ function createCardReplyStrategy(ctx) {
9712
9790
  // Card mode keeps runtime block streaming disabled, but still consumes
9713
9791
  // reasoning blocks through explicit callbacks and delivery metadata.
9714
9792
  disableBlockStreaming: ctx.disableBlockStreaming ?? true,
9793
+ // DingTalk card mode owns the visible reply surface. In group chats,
9794
+ // OpenClaw defaults source replies to message-tool-only; override that
9795
+ // so final replies are delivered into this card instead of spawning a
9796
+ // separate visible message/card via the message tool.
9797
+ sourceReplyDeliveryMode: "automatic",
9715
9798
  onAssistantMessageStart: async () => {
9716
9799
  if (isLifecycleSealed() || isStopRequested?.()) {
9717
9800
  return;
@@ -9951,6 +10034,7 @@ function createCardReplyStrategy(ctx) {
9951
10034
  }
9952
10035
  try {
9953
10036
  await flushPendingReasoning();
10037
+ await controller.clearStreamingContent?.();
9954
10038
  await controller.flush();
9955
10039
  await controller.waitForInFlight();
9956
10040
  const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : void 0);
@@ -10066,6 +10150,7 @@ function createCardReplyStrategy(ctx) {
10066
10150
  }
10067
10151
 
10068
10152
  // src/reply-strategy-markdown.ts
10153
+ import path8 from "node:path";
10069
10154
  var EMPTY_FINAL_FALLBACK_TEXT = "\u2705 Done";
10070
10155
  function renderQuotedSegment(text) {
10071
10156
  return text.split("\n").map((line) => line.length > 0 ? `> ${line}` : ">").join("\n");
@@ -10102,6 +10187,10 @@ function computeSharedPrefixTail(previous, next) {
10102
10187
  const suffix = current.slice(sharedPrefixLength);
10103
10188
  return suffix.trim() ? suffix : "";
10104
10189
  }
10190
+ function renderMarkdownImage(mediaPath) {
10191
+ const filename = path8.basename(mediaPath) || "image";
10192
+ return `![${filename}](${mediaPath})`;
10193
+ }
10105
10194
  function createMarkdownReplyStrategy(ctx) {
10106
10195
  let finalText;
10107
10196
  let activeAnswerText = "";
@@ -10125,7 +10214,7 @@ function createMarkdownReplyStrategy(ctx) {
10125
10214
  }
10126
10215
  sentVisibleContent = true;
10127
10216
  };
10128
- const emitAnswerSuffix = async (text) => {
10217
+ const prepareAnswerSuffix = (text) => {
10129
10218
  const current = typeof text === "string" ? text : "";
10130
10219
  if (current.length > 0) {
10131
10220
  activeAnswerText = current;
@@ -10133,37 +10222,118 @@ function createMarkdownReplyStrategy(ctx) {
10133
10222
  }
10134
10223
  const suffix = computeIncrementalSuffix(lastSentAnswerText, current);
10135
10224
  if (suffix) {
10136
- await sendMarkdownSegment(suffix);
10137
- lastSentAnswerText = current;
10138
- return;
10225
+ return {
10226
+ text: suffix,
10227
+ markSent: () => {
10228
+ lastSentAnswerText = current;
10229
+ }
10230
+ };
10139
10231
  }
10140
10232
  if (current.trim() && lastSentAnswerText && !current.startsWith(lastSentAnswerText)) {
10141
10233
  const suffix2 = computeSharedPrefixTail(lastSentAnswerText, current);
10142
10234
  ctx.log?.warn?.(
10143
10235
  `[DingTalk][Markdown] answer prefix drift detected; falling back to shared-prefix tail prevLen=${lastSentAnswerText.length} currentLen=${current.length}`
10144
10236
  );
10145
- lastSentAnswerText = "";
10146
10237
  if (suffix2) {
10147
- await sendMarkdownSegment(suffix2);
10148
- lastSentAnswerText = current;
10149
- return;
10238
+ return {
10239
+ text: suffix2,
10240
+ markSent: () => {
10241
+ lastSentAnswerText = current;
10242
+ }
10243
+ };
10244
+ }
10245
+ return {
10246
+ text: current,
10247
+ markSent: () => {
10248
+ lastSentAnswerText = current;
10249
+ }
10250
+ };
10251
+ }
10252
+ return null;
10253
+ };
10254
+ const emitAnswerSuffix = async (text) => {
10255
+ const suffix = prepareAnswerSuffix(text);
10256
+ if (suffix) {
10257
+ await sendMarkdownSegment(suffix.text);
10258
+ suffix.markSent();
10259
+ }
10260
+ };
10261
+ const prepareMarkdownImageAttachments = async (mediaUrls) => {
10262
+ const imageMarkdown = [];
10263
+ const passthroughMediaUrls = [];
10264
+ const cleanups = [];
10265
+ for (const rawMediaUrl of mediaUrls) {
10266
+ const preparedMedia = await prepareMediaInput(
10267
+ rawMediaUrl,
10268
+ ctx.log,
10269
+ ctx.config.mediaUrlAllowlist
10270
+ );
10271
+ const actualMediaPath = preparedMedia.cleanup ? preparedMedia.path : resolveRelativePath(preparedMedia.path);
10272
+ const mediaType = resolveOutboundMediaType({
10273
+ mediaPath: actualMediaPath,
10274
+ asVoice: false
10275
+ });
10276
+ if (mediaType === "image") {
10277
+ imageMarkdown.push(renderMarkdownImage(actualMediaPath));
10278
+ if (preparedMedia.cleanup) {
10279
+ cleanups.push(preparedMedia.cleanup);
10280
+ }
10281
+ } else {
10282
+ await preparedMedia.cleanup?.();
10283
+ passthroughMediaUrls.push(rawMediaUrl);
10150
10284
  }
10151
- await sendMarkdownSegment(current);
10152
- lastSentAnswerText = current;
10153
10285
  }
10286
+ return { imageMarkdown, passthroughMediaUrls, cleanups };
10154
10287
  };
10155
10288
  return {
10156
10289
  getReplyOptions() {
10157
10290
  return {
10158
- disableBlockStreaming: ctx.disableBlockStreaming === true
10291
+ disableBlockStreaming: ctx.disableBlockStreaming === true,
10292
+ // DingTalk markdown/sessionWebhook mode owns the visible reply surface.
10293
+ // Keep runtime final replies on this strategy even when group chats
10294
+ // default source replies to message-tool-only.
10295
+ sourceReplyDeliveryMode: "automatic"
10159
10296
  };
10160
10297
  },
10161
10298
  async deliver(payload) {
10299
+ let answerTextSentWithImages = false;
10300
+ let toolTextSentWithImages = false;
10162
10301
  if (payload.mediaUrls.length > 0) {
10163
- await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
10164
- sentVisibleContent = true;
10302
+ const prepared = payload.audioAsVoice === true ? {
10303
+ imageMarkdown: [],
10304
+ passthroughMediaUrls: payload.mediaUrls,
10305
+ cleanups: []
10306
+ } : await prepareMarkdownImageAttachments(payload.mediaUrls);
10307
+ try {
10308
+ if (prepared.passthroughMediaUrls.length > 0) {
10309
+ await ctx.deliverMedia(prepared.passthroughMediaUrls, {
10310
+ audioAsVoice: payload.audioAsVoice
10311
+ });
10312
+ sentVisibleContent = true;
10313
+ }
10314
+ if (prepared.imageMarkdown.length > 0) {
10315
+ const answerSuffix = payload.kind === "block" || payload.kind === "final" ? prepareAnswerSuffix(payload.text) : typeof payload.text === "string" ? { text: renderQuotedSegment(payload.text), markSent: () => {
10316
+ } } : null;
10317
+ const markdownParts = [answerSuffix?.text || "", ...prepared.imageMarkdown].filter(
10318
+ (part) => part.trim().length > 0
10319
+ );
10320
+ if (markdownParts.length > 0) {
10321
+ await sendMarkdownSegment(markdownParts.join("\n\n"));
10322
+ answerSuffix?.markSent();
10323
+ }
10324
+ answerTextSentWithImages = payload.kind === "block" || payload.kind === "final";
10325
+ toolTextSentWithImages = payload.kind === "tool";
10326
+ }
10327
+ } finally {
10328
+ for (const cleanup of prepared.cleanups) {
10329
+ await cleanup();
10330
+ }
10331
+ }
10165
10332
  }
10166
10333
  if (payload.kind === "tool") {
10334
+ if (toolTextSentWithImages) {
10335
+ return;
10336
+ }
10167
10337
  const text = typeof payload.text === "string" ? payload.text : "";
10168
10338
  if (!text.trim()) {
10169
10339
  return;
@@ -10171,7 +10341,7 @@ function createMarkdownReplyStrategy(ctx) {
10171
10341
  await sendMarkdownSegment(renderQuotedSegment(text));
10172
10342
  return;
10173
10343
  }
10174
- if ((payload.kind === "block" || payload.kind === "final") && typeof payload.text === "string") {
10344
+ if ((payload.kind === "block" || payload.kind === "final") && typeof payload.text === "string" && !answerTextSentWithImages) {
10175
10345
  await emitAnswerSuffix(payload.text);
10176
10346
  }
10177
10347
  },
@@ -10495,12 +10665,12 @@ async function dispatchSubAgents(params) {
10495
10665
 
10496
10666
  // src/targeting/group-members-store.ts
10497
10667
  import * as fs5 from "node:fs";
10498
- import * as path8 from "node:path";
10668
+ import * as path9 from "node:path";
10499
10669
  var GROUP_MEMBERS_NAMESPACE = "members.group-roster";
10500
10670
  function groupMembersFilePath(storePath, groupId) {
10501
- const dir = path8.join(path8.dirname(storePath), "dingtalk-members");
10671
+ const dir = path9.join(path9.dirname(storePath), "dingtalk-members");
10502
10672
  const safeId = groupId.replace(/\+/g, "-").replace(/\//g, "_");
10503
- return path8.join(dir, `${safeId}.json`);
10673
+ return path9.join(dir, `${safeId}.json`);
10504
10674
  }
10505
10675
  function readLegacyRoster(storePath, groupId) {
10506
10676
  const filePath = groupMembersFilePath(storePath, groupId);
@@ -11581,7 +11751,7 @@ async function handleDingTalkMessage(params) {
11581
11751
  if (!hasPathLikeShape) {
11582
11752
  return void 0;
11583
11753
  }
11584
- return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path9.extname(trimmed).toLowerCase()) ? trimmed : void 0;
11754
+ return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path10.extname(trimmed).toLowerCase()) ? trimmed : void 0;
11585
11755
  }, extractSharedAudioAsVoice = function(payload, inlineReplyPayload) {
11586
11756
  const richPayload = payload;
11587
11757
  const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
@@ -13025,14 +13195,14 @@ var RegistrationError = class extends Error {
13025
13195
  function asString(value) {
13026
13196
  return typeof value === "string" ? value : "";
13027
13197
  }
13028
- async function apiPost(path10, payload) {
13029
- const url = `${REGISTRATION_BASE_URL}${path10}`;
13198
+ async function apiPost(path11, payload) {
13199
+ const url = `${REGISTRATION_BASE_URL}${path11}`;
13030
13200
  const resp = await http_client_default.post(url, payload, { timeout: 15e3 });
13031
13201
  const data = resp.data;
13032
13202
  const errcode = data.errcode;
13033
13203
  if (errcode !== void 0 && errcode !== 0) {
13034
13204
  const errmsg = asString(data.errmsg) || "unknown error";
13035
- throw new RegistrationError(`API error [${path10}]: ${errmsg} (errcode=${typeof errcode === "number" ? errcode : asString(errcode)})`);
13205
+ throw new RegistrationError(`API error [${path11}]: ${errmsg} (errcode=${typeof errcode === "number" ? errcode : asString(errcode)})`);
13036
13206
  }
13037
13207
  return data;
13038
13208
  }
@@ -14365,7 +14535,7 @@ async function listDocs(config, spaceId, parentId, log = getLogger()) {
14365
14535
 
14366
14536
  // index.ts
14367
14537
  function registerDingTalkDocsGatewayMethods(api) {
14368
- api.registerGatewayMethod("dingtalk.docs.create", async ({ respond, params }) => {
14538
+ const createHandler = async ({ respond, params }) => {
14369
14539
  const accountId = readStringParam2(params, "accountId");
14370
14540
  const spaceId = readStringParam2(params, "spaceId", { required: true });
14371
14541
  const title = readStringParam2(params, "title", { required: true });
@@ -14394,31 +14564,187 @@ function registerDingTalkDocsGatewayMethods(api) {
14394
14564
  }
14395
14565
  throw error;
14396
14566
  }
14397
- });
14398
- api.registerGatewayMethod("dingtalk.docs.append", async ({ respond, params }) => {
14567
+ };
14568
+ const appendHandler = async ({ respond, params }) => {
14399
14569
  const accountId = readStringParam2(params, "accountId");
14400
14570
  const docId = readStringParam2(params, "docId", { required: true });
14401
14571
  const content = readStringParam2(params, "content", { required: true, allowEmpty: false });
14402
14572
  const config = getConfig(api.config, accountId ?? void 0);
14403
14573
  const result = await appendToDoc(config, docId, content, api.logger);
14404
14574
  return respond(true, result);
14405
- });
14406
- api.registerGatewayMethod("dingtalk.docs.search", async ({ respond, params }) => {
14575
+ };
14576
+ const searchHandler = async ({ respond, params }) => {
14407
14577
  const accountId = readStringParam2(params, "accountId");
14408
14578
  const keyword = readStringParam2(params, "keyword", { required: true });
14409
14579
  const spaceId = readStringParam2(params, "spaceId");
14410
14580
  const config = getConfig(api.config, accountId ?? void 0);
14411
14581
  const docs = await searchDocs(config, keyword, spaceId, api.logger);
14412
14582
  return respond(true, { docs });
14413
- });
14414
- api.registerGatewayMethod("dingtalk.docs.list", async ({ respond, params }) => {
14583
+ };
14584
+ const listHandler = async ({ respond, params }) => {
14415
14585
  const accountId = readStringParam2(params, "accountId");
14416
14586
  const spaceId = readStringParam2(params, "spaceId", { required: true });
14417
14587
  const parentId = readStringParam2(params, "parentId");
14418
14588
  const config = getConfig(api.config, accountId ?? void 0);
14419
14589
  const docs = await listDocs(config, spaceId, parentId, api.logger);
14420
14590
  return respond(true, { docs });
14421
- });
14591
+ };
14592
+ api.registerGatewayMethod("dingtalk.docs.create", createHandler);
14593
+ api.registerGatewayMethod("dingtalk.docs.append", appendHandler);
14594
+ api.registerGatewayMethod("dingtalk.docs.search", searchHandler);
14595
+ api.registerGatewayMethod("dingtalk.docs.list", listHandler);
14596
+ api.registerGatewayMethod("dingtalk-connector.docs.create", createHandler);
14597
+ api.registerGatewayMethod("dingtalk-connector.docs.append", appendHandler);
14598
+ api.registerGatewayMethod("dingtalk-connector.docs.search", searchHandler);
14599
+ api.registerGatewayMethod("dingtalk-connector.docs.list", listHandler);
14600
+ }
14601
+ function getContentParam(params) {
14602
+ return readStringParam2(params, "content") ?? readStringParam2(params, "message");
14603
+ }
14604
+ function getErrorMessage2(error, fallback) {
14605
+ return error instanceof Error ? error.message : fallback;
14606
+ }
14607
+ function maskClientId(clientId) {
14608
+ if (!clientId) {
14609
+ return null;
14610
+ }
14611
+ return clientId.length <= 4 ? "****" : `****${clientId.slice(-4)}`;
14612
+ }
14613
+ function isConnectorSendTarget(target) {
14614
+ return /^(user|group):\S+$/.test(target);
14615
+ }
14616
+ async function sendGatewayMessage(params) {
14617
+ const config = getConfig(params.api.config, params.accountId);
14618
+ const accountId = params.accountId ?? "default";
14619
+ if (!config.clientId || !config.clientSecret) {
14620
+ return params.respond(false, { error: "DingTalk not configured" });
14621
+ }
14622
+ let result;
14623
+ try {
14624
+ result = await sendMessage(config, params.target, params.content, {
14625
+ log: params.api.logger,
14626
+ accountId,
14627
+ conversationId: params.target,
14628
+ storePath: params.storePath,
14629
+ forceMarkdown: params.useAICard === false
14630
+ });
14631
+ } catch (error) {
14632
+ const message = getErrorMessage2(error, "send failed");
14633
+ params.api.logger?.warn?.(`[DingTalk][GatewayRPC] send failed: ${message}`);
14634
+ return params.respond(false, { error: message });
14635
+ }
14636
+ return params.respond(
14637
+ result.ok,
14638
+ result.ok ? {
14639
+ ok: true,
14640
+ target: params.target,
14641
+ messageId: result.messageId ?? null,
14642
+ tracking: result.tracking ?? null
14643
+ } : { error: result.error || "send failed" }
14644
+ );
14645
+ }
14646
+ function registerDingTalkConnectorCompatibilityGatewayMethods(api) {
14647
+ api.registerGatewayMethod(
14648
+ "dingtalk-connector.sendToUser",
14649
+ async ({ context, respond, params }) => {
14650
+ const accountId = readStringParam2(params, "accountId");
14651
+ const userId = readStringParam2(params, "userId", { required: true });
14652
+ const content = getContentParam(params);
14653
+ if (!content) {
14654
+ return respond(false, { error: "content or message is required" });
14655
+ }
14656
+ return sendGatewayMessage({
14657
+ api,
14658
+ respond,
14659
+ accountId: accountId ?? void 0,
14660
+ target: `user:${userId}`,
14661
+ content,
14662
+ storePath: context?.cronStorePath,
14663
+ useAICard: params.useAICard
14664
+ });
14665
+ }
14666
+ );
14667
+ api.registerGatewayMethod(
14668
+ "dingtalk-connector.sendToGroup",
14669
+ async ({ context, respond, params }) => {
14670
+ const accountId = readStringParam2(params, "accountId");
14671
+ const openConversationId = readStringParam2(params, "openConversationId", { required: true });
14672
+ const content = getContentParam(params);
14673
+ if (!content) {
14674
+ return respond(false, { error: "content or message is required" });
14675
+ }
14676
+ return sendGatewayMessage({
14677
+ api,
14678
+ respond,
14679
+ accountId: accountId ?? void 0,
14680
+ target: `group:${openConversationId}`,
14681
+ content,
14682
+ storePath: context?.cronStorePath,
14683
+ useAICard: params.useAICard
14684
+ });
14685
+ }
14686
+ );
14687
+ api.registerGatewayMethod(
14688
+ "dingtalk-connector.send",
14689
+ async ({ context, respond, params }) => {
14690
+ const accountId = readStringParam2(params, "accountId");
14691
+ const target = readStringParam2(params, "target", { required: true });
14692
+ const content = getContentParam(params);
14693
+ if (!content) {
14694
+ return respond(false, { error: "content or message is required" });
14695
+ }
14696
+ if (!isConnectorSendTarget(target)) {
14697
+ return respond(false, { error: "target must start with user: or group:" });
14698
+ }
14699
+ return sendGatewayMessage({
14700
+ api,
14701
+ respond,
14702
+ accountId: accountId ?? void 0,
14703
+ target,
14704
+ content,
14705
+ storePath: context?.cronStorePath,
14706
+ useAICard: params.useAICard
14707
+ });
14708
+ }
14709
+ );
14710
+ api.registerGatewayMethod(
14711
+ "dingtalk-connector.status",
14712
+ async ({ respond }) => {
14713
+ const accountIds = listDingTalkAccountIds(api.config);
14714
+ const accounts = accountIds.length > 0 ? accountIds : ["default"];
14715
+ return respond(true, {
14716
+ channel: "dingtalk",
14717
+ accounts: accounts.map((accountId) => {
14718
+ const account = resolveDingTalkAccount(api.config, accountId);
14719
+ return {
14720
+ accountId,
14721
+ configured: account.configured,
14722
+ enabled: account.enabled !== false,
14723
+ name: account.name ?? null,
14724
+ clientId: maskClientId(account.clientId)
14725
+ };
14726
+ })
14727
+ });
14728
+ }
14729
+ );
14730
+ api.registerGatewayMethod(
14731
+ "dingtalk-connector.probe",
14732
+ async ({ respond, params }) => {
14733
+ const accountId = readStringParam2(params, "accountId");
14734
+ const config = getConfig(api.config, accountId ?? void 0);
14735
+ if (!config.clientId || !config.clientSecret) {
14736
+ return respond(false, { error: "DingTalk not configured" });
14737
+ }
14738
+ try {
14739
+ await getAccessToken(config, api.logger);
14740
+ return respond(true, { ok: true, clientId: maskClientId(config.clientId) });
14741
+ } catch (error) {
14742
+ const message = getErrorMessage2(error, "probe failed");
14743
+ api.logger?.warn?.(`[DingTalk][GatewayRPC] probe failed: ${message}`);
14744
+ return respond(false, { error: message });
14745
+ }
14746
+ }
14747
+ );
14422
14748
  }
14423
14749
  var index_default = defineChannelPluginEntry({
14424
14750
  id: "dingtalk",
@@ -14428,11 +14754,15 @@ var index_default = defineChannelPluginEntry({
14428
14754
  setRuntime: setDingTalkRuntime,
14429
14755
  registerFull(api) {
14430
14756
  registerDingTalkDocsGatewayMethods(api);
14431
- api.on("llm_output", (event) => {
14432
- if (event.usage) {
14433
- accumulateUsage(event.runId, event.usage);
14757
+ registerDingTalkConnectorCompatibilityGatewayMethods(api);
14758
+ api.on(
14759
+ "llm_output",
14760
+ (event) => {
14761
+ if (event.usage) {
14762
+ accumulateUsage(event.runId, event.usage);
14763
+ }
14434
14764
  }
14435
- });
14765
+ );
14436
14766
  }
14437
14767
  });
14438
14768
  export {