@soimy/dingtalk 3.5.3 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +7 -0
- package/openclaw.plugin.json +104 -0
- package/package.json +1 -1
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-template.ts +14 -3
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +245 -52
- package/src/card-service.ts +368 -8
- package/src/channel.ts +19 -1081
- package/src/config-schema.ts +19 -0
- package/src/config.ts +117 -1
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +147 -24
- package/src/media-utils.ts +6 -0
- package/src/message-utils.ts +124 -16
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/onboarding.ts +321 -232
- package/src/platform/channel-status.ts +81 -0
- package/src/reply-strategy-card.ts +373 -64
- package/src/reply-strategy-markdown.ts +1 -1
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -72
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +115 -3
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/types.ts +23 -147
package/src/inbound-handler.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
|
3
4
|
import axios from "./http-client";
|
|
4
5
|
import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
|
|
5
6
|
import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
|
|
7
|
+
import { getAgentDisplayName } from "./targeting/agent-name-matcher";
|
|
6
8
|
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
7
9
|
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
8
10
|
import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
|
|
9
11
|
import { extractAttachmentText } from "./messaging/attachment-text-extractor";
|
|
10
12
|
import { getAccessToken } from "./auth";
|
|
11
|
-
import { createAICard,
|
|
13
|
+
import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
|
|
14
|
+
import { renderStatusLine } from "./card/statusline-renderer";
|
|
12
15
|
import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
|
|
13
16
|
import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
|
|
14
17
|
import { AICardStatus } from "./types";
|
|
@@ -30,6 +33,7 @@ import {
|
|
|
30
33
|
upsertInboundMessageContext,
|
|
31
34
|
} from "./message-context-store";
|
|
32
35
|
import { extractMessageContent } from "./message-utils";
|
|
36
|
+
import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
|
|
33
37
|
import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
|
|
34
38
|
import {
|
|
35
39
|
buildInboundQuotedRef,
|
|
@@ -43,7 +47,7 @@ import {
|
|
|
43
47
|
} from "./proactive-risk-registry";
|
|
44
48
|
import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
|
|
45
49
|
import { createReplyStrategy } from "./reply-strategy";
|
|
46
|
-
import type { DeliverPayload } from "./reply-strategy";
|
|
50
|
+
import type { DeliverPayload } from "./reply-strategy-types";
|
|
47
51
|
import { getDingTalkRuntime } from "./runtime";
|
|
48
52
|
import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
|
|
49
53
|
import { acquireSessionLock } from "./session-lock";
|
|
@@ -53,13 +57,13 @@ import {
|
|
|
53
57
|
setSessionPeerOverride,
|
|
54
58
|
} from "./session-peer-store";
|
|
55
59
|
import { resolveDingTalkSessionPeer } from "./session-routing";
|
|
60
|
+
import { getSessionState, initSessionState } from "./session-state";
|
|
56
61
|
import {
|
|
57
62
|
upsertObservedGroupTarget,
|
|
58
63
|
upsertObservedUserTarget,
|
|
59
64
|
} from "./targeting/target-directory-store";
|
|
60
65
|
import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
|
|
61
66
|
import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
|
|
62
|
-
import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
|
63
67
|
import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
|
|
64
68
|
|
|
65
69
|
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
@@ -387,6 +391,8 @@ function buildGroupTurnContextPrompt(params: {
|
|
|
387
391
|
type ReplyStreamPayload = {
|
|
388
392
|
text?: string;
|
|
389
393
|
isReasoning?: boolean;
|
|
394
|
+
mediaUrl?: string;
|
|
395
|
+
mediaUrls?: string[];
|
|
390
396
|
};
|
|
391
397
|
|
|
392
398
|
type ReplyChunkInfo = {
|
|
@@ -534,6 +540,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
534
540
|
return;
|
|
535
541
|
}
|
|
536
542
|
|
|
543
|
+
// Preserve raw inbound text before any rewriting (e.g., sub-agent context hint)
|
|
544
|
+
// for use in card quoteContent which should show the user's original message.
|
|
545
|
+
const rawInboundText = extractedContent.text.trim();
|
|
546
|
+
|
|
537
547
|
// Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
|
|
538
548
|
if (subAgentOptions) {
|
|
539
549
|
const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
@@ -808,21 +818,51 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
808
818
|
if (commandHandled) {
|
|
809
819
|
return;
|
|
810
820
|
}
|
|
821
|
+
|
|
822
|
+
const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
|
|
823
|
+
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
824
|
+
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
825
|
+
const content = extractedContent;
|
|
826
|
+
const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
|
|
827
|
+
const taskInfoConversationId = groupId || to;
|
|
828
|
+
const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
|
|
829
|
+
const initialStatusLine = renderStatusLine({
|
|
830
|
+
model: sessionTaskState.model,
|
|
831
|
+
effort: sessionTaskState.effort,
|
|
832
|
+
agent: getAgentDisplayName({
|
|
833
|
+
subAgentOptions,
|
|
834
|
+
agentId: route.agentId,
|
|
835
|
+
agentsList: cfg.agents?.list,
|
|
836
|
+
}),
|
|
837
|
+
}, dingtalkConfig) || undefined;
|
|
838
|
+
|
|
811
839
|
// 3) Select response mode (card vs markdown).
|
|
812
840
|
// Card creation runs BEFORE media download so the user sees immediate visual
|
|
813
841
|
// feedback while large files are still being downloaded.
|
|
842
|
+
// /btw is gated out here (`!isBtwBypass`) because it must never create a card —
|
|
843
|
+
// it has its own bypass dispatch later that returns before reaching the main
|
|
844
|
+
// run. Abort (/stop) is intentionally NOT gated: in card mode the existing
|
|
845
|
+
// abort branch finalizes the card with the abort confirmation text instead of
|
|
846
|
+
// sending a separate plain-text message.
|
|
814
847
|
let useCardMode = dingtalkConfig.messageType === "card";
|
|
815
848
|
let currentAICard: import("./types").AICardInstance | undefined;
|
|
816
849
|
|
|
817
|
-
if (useCardMode) {
|
|
850
|
+
if (useCardMode && !isBtwBypass) {
|
|
818
851
|
try {
|
|
819
852
|
log?.debug?.(
|
|
820
853
|
`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
|
|
821
854
|
);
|
|
855
|
+
// quoteContent always shows the inbound message text so the user can
|
|
856
|
+
// identify which of their messages this card is replying to.
|
|
857
|
+
// Use rawInboundText ( preserved before sub-agent rewriting) to avoid
|
|
858
|
+
// showing internal routing context like "[你被 @ 为...]" in the card UI.
|
|
859
|
+
const inboundQuoteText = rawInboundText.slice(0, 200);
|
|
822
860
|
const aiCard = await createAICard(dingtalkConfig, to, log, {
|
|
823
861
|
accountId,
|
|
824
862
|
storePath: accountStorePath,
|
|
825
863
|
contextConversationId: groupId,
|
|
864
|
+
quoteContent: inboundQuoteText,
|
|
865
|
+
statusLine: initialStatusLine,
|
|
826
866
|
});
|
|
827
867
|
if (aiCard) {
|
|
828
868
|
currentAICard = aiCard;
|
|
@@ -848,11 +888,6 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
848
888
|
);
|
|
849
889
|
}
|
|
850
890
|
}
|
|
851
|
-
|
|
852
|
-
const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
|
|
853
|
-
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
854
|
-
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
855
|
-
const content = extractedContent;
|
|
856
891
|
const hasLegacyQuoteContent =
|
|
857
892
|
typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
|
|
858
893
|
|
|
@@ -1436,7 +1471,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1436
1471
|
// "@Agent /stop" are correctly recognised as abort requests in both DM and group
|
|
1437
1472
|
// chats. In groups DingTalk usually strips @BotName at the protocol level, but
|
|
1438
1473
|
// in DMs with multi-agent routing the @mention prefix survives all the way here.
|
|
1439
|
-
const textForAbortCheck = inboundText
|
|
1474
|
+
const textForAbortCheck = stripLeadingMentions(inboundText).trim();
|
|
1440
1475
|
if (isAbortRequestText(textForAbortCheck)) {
|
|
1441
1476
|
log?.info?.(
|
|
1442
1477
|
`[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
@@ -1488,9 +1523,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1488
1523
|
}
|
|
1489
1524
|
// Finalize the card that was created for this message before the abort check.
|
|
1490
1525
|
// Without this, the card stays in PROCESSING ("处理中...") indefinitely.
|
|
1526
|
+
// Use V2 finalize (commitAICardBlocks) for consistent state transition.
|
|
1491
1527
|
if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
|
|
1492
1528
|
try {
|
|
1493
|
-
|
|
1529
|
+
const abortText = abortConfirmationText ?? "已停止";
|
|
1530
|
+
const abortBlockList = [{ type: 0, markdown: abortText }];
|
|
1531
|
+
const blockListJson = JSON.stringify(abortBlockList);
|
|
1532
|
+
|
|
1533
|
+
await commitAICardBlocks(currentAICard, {
|
|
1534
|
+
blockListJson,
|
|
1535
|
+
content: abortText,
|
|
1536
|
+
}, log);
|
|
1537
|
+
|
|
1538
|
+
log?.debug?.(`[DingTalk] Abort card finalized via V2 API: card=${currentAICard.cardInstanceId}`);
|
|
1494
1539
|
} catch (cardErr) {
|
|
1495
1540
|
log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
|
|
1496
1541
|
currentAICard.state = AICardStatus.FAILED;
|
|
@@ -1499,6 +1544,61 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1499
1544
|
return;
|
|
1500
1545
|
}
|
|
1501
1546
|
|
|
1547
|
+
// ---- Pre-lock BTW: bypass session lock for /btw side questions ----
|
|
1548
|
+
// /btw runs an isolated, tool-less side query in openclaw without polluting
|
|
1549
|
+
// the main run's transcript. The dispatch must NOT acquire the session lock,
|
|
1550
|
+
// otherwise it would queue behind the in-flight main task and lose its "side
|
|
1551
|
+
// question" semantics.
|
|
1552
|
+
//
|
|
1553
|
+
// The `isBtwBypass` flag is computed once early (just after `content` is
|
|
1554
|
+
// resolved, just before `createAICard`). The same constant gates `createAICard` above and
|
|
1555
|
+
// drives this branch — single decision, two consequences. See the comment
|
|
1556
|
+
// at the flag's definition for why /btw uses pre-OCR `content.text` while
|
|
1557
|
+
// abort uses `inboundText`.
|
|
1558
|
+
if (isBtwBypass) {
|
|
1559
|
+
log?.info?.(
|
|
1560
|
+
`[DingTalk] BTW request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
1561
|
+
);
|
|
1562
|
+
// Empty fallback (NOT "Unknown" like the file's main `senderName` variable):
|
|
1563
|
+
// when the nickname is missing we want the blockquote to render as
|
|
1564
|
+
// `> /btw <question>` rather than `> Unknown: /btw <question>`. Read locally
|
|
1565
|
+
// here so the existing `senderName` semantics elsewhere in the file are not
|
|
1566
|
+
// affected.
|
|
1567
|
+
const btwSenderName = data.senderNick || "";
|
|
1568
|
+
try {
|
|
1569
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1570
|
+
ctx,
|
|
1571
|
+
cfg,
|
|
1572
|
+
dispatcherOptions: {
|
|
1573
|
+
responsePrefix: "",
|
|
1574
|
+
deliver: async (payload) => {
|
|
1575
|
+
if (!payload.text) {
|
|
1576
|
+
log?.debug?.(`[DingTalk] BTW deliver received non-text payload, skipping`);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
await deliverBtwReply({
|
|
1580
|
+
config: dingtalkConfig,
|
|
1581
|
+
sessionWebhook,
|
|
1582
|
+
conversationId: groupId,
|
|
1583
|
+
to,
|
|
1584
|
+
senderName: btwSenderName,
|
|
1585
|
+
// Use pre-OCR content.text (consistent with isBtwBypass detection)
|
|
1586
|
+
// so the blockquote shows the user's typed body, not body + OCR.
|
|
1587
|
+
rawQuestion: content.text,
|
|
1588
|
+
replyText: payload.text,
|
|
1589
|
+
log,
|
|
1590
|
+
accountId,
|
|
1591
|
+
storePath: accountStorePath,
|
|
1592
|
+
});
|
|
1593
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
} catch (btwErr) {
|
|
1597
|
+
log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1502
1602
|
const ackReaction =
|
|
1503
1603
|
typeof dingtalkConfig.ackReaction === "string"
|
|
1504
1604
|
? dingtalkConfig.ackReaction.trim()
|
|
@@ -1784,6 +1884,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1784
1884
|
legacyCardStreamReasoning === undefined
|
|
1785
1885
|
? dingtalkConfig
|
|
1786
1886
|
: { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
|
|
1887
|
+
const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
|
|
1888
|
+
const taskMeta = {
|
|
1889
|
+
model: sessionTaskState?.model,
|
|
1890
|
+
effort: sessionTaskState?.effort,
|
|
1891
|
+
elapsedMs: typeof sessionTaskState?.taskStartTime === "number"
|
|
1892
|
+
? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
|
|
1893
|
+
: undefined,
|
|
1894
|
+
agent: getAgentDisplayName({
|
|
1895
|
+
subAgentOptions,
|
|
1896
|
+
agentId: route.agentId,
|
|
1897
|
+
agentsList: cfg.agents?.list,
|
|
1898
|
+
}),
|
|
1899
|
+
};
|
|
1900
|
+
|
|
1787
1901
|
const strategy = createReplyStrategy({
|
|
1788
1902
|
config: strategyConfig,
|
|
1789
1903
|
card: currentAICard,
|
|
@@ -1807,6 +1921,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1807
1921
|
replyQuotedRef,
|
|
1808
1922
|
deliverMedia: deliverMediaAttachments,
|
|
1809
1923
|
isStopRequested: isCurrentCardStopRequested,
|
|
1924
|
+
inboundText: rawInboundText,
|
|
1925
|
+
taskMeta,
|
|
1810
1926
|
});
|
|
1811
1927
|
|
|
1812
1928
|
try {
|
|
@@ -1863,19 +1979,26 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
1863
1979
|
`queuedFinalType=${typeof bufferedFinal}`,
|
|
1864
1980
|
);
|
|
1865
1981
|
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1982
|
+
const bufferedFinalPayload =
|
|
1983
|
+
typeof bufferedFinal === "string"
|
|
1984
|
+
? ({ text: bufferedFinal } satisfies ReplyStreamPayload)
|
|
1985
|
+
: bufferedFinal && typeof bufferedFinal === "object"
|
|
1986
|
+
? (bufferedFinal as ReplyStreamPayload)
|
|
1987
|
+
: undefined;
|
|
1988
|
+
|
|
1989
|
+
if (deliveredFinalCount === 0 && bufferedFinalPayload) {
|
|
1990
|
+
const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinalPayload.text);
|
|
1991
|
+
const mediaUrls = extractMediaUrls(bufferedFinalPayload, inlineReplyPayload);
|
|
1992
|
+
const hasBufferedText = typeof bufferedFinalPayload.text === "string" && bufferedFinalPayload.text.trim().length > 0;
|
|
1993
|
+
if (hasBufferedText || mediaUrls.length > 0) {
|
|
1994
|
+
await strategy.deliver({
|
|
1995
|
+
text: inlineReplyPayload.text,
|
|
1996
|
+
mediaUrls,
|
|
1997
|
+
audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
|
|
1998
|
+
kind: "final",
|
|
1999
|
+
isReasoning: bufferedFinalPayload.isReasoning === true,
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
1879
2002
|
}
|
|
1880
2003
|
} catch (dispatchErr: unknown) {
|
|
1881
2004
|
const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
|
package/src/media-utils.ts
CHANGED
|
@@ -534,6 +534,12 @@ export function resolveOutboundMediaType(params: {
|
|
|
534
534
|
return explicitType;
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
// Audio files default to "file" (attachment) unless asVoice is explicitly set.
|
|
538
|
+
// This prevents mp3/wav/ogg/amr from being sent as voice messages unexpectedly.
|
|
539
|
+
if (detectedType === "voice") {
|
|
540
|
+
return "file";
|
|
541
|
+
}
|
|
542
|
+
|
|
537
543
|
return detectedType;
|
|
538
544
|
}
|
|
539
545
|
|
package/src/message-utils.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
AtMention,
|
|
3
|
+
DingTalkInboundMessage,
|
|
4
|
+
MessageContent,
|
|
5
|
+
QuotedInfo,
|
|
6
|
+
SendMessageOptions,
|
|
7
|
+
} from "./types";
|
|
3
8
|
|
|
4
9
|
interface DingTalkDocMeta {
|
|
5
10
|
spaceId: string;
|
|
6
11
|
fileId: string;
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
const UNKNOWN_PERSON_LABEL = "某人";
|
|
15
|
+
|
|
9
16
|
function parseBizCustomActionUrl(url: string | undefined): DingTalkDocMeta | null {
|
|
10
17
|
if (!url || typeof url !== "string") {
|
|
11
18
|
return null;
|
|
@@ -76,7 +83,7 @@ function extractRichTextQuoteParts(
|
|
|
76
83
|
? part.atName
|
|
77
84
|
: typeof textValue === "string"
|
|
78
85
|
? textValue
|
|
79
|
-
:
|
|
86
|
+
: UNKNOWN_PERSON_LABEL;
|
|
80
87
|
textParts.push(`@${atName}`);
|
|
81
88
|
continue;
|
|
82
89
|
}
|
|
@@ -94,7 +101,8 @@ function extractRichTextQuoteParts(
|
|
|
94
101
|
return {
|
|
95
102
|
summary,
|
|
96
103
|
pictureDownloadCode,
|
|
97
|
-
pictureDownloadCodes:
|
|
104
|
+
pictureDownloadCodes:
|
|
105
|
+
uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
|
|
98
106
|
};
|
|
99
107
|
}
|
|
100
108
|
|
|
@@ -115,7 +123,101 @@ function trimString(value: string | undefined): string | undefined {
|
|
|
115
123
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
|
|
126
|
+
const MAX_CHAT_RECORD_ENTRIES = 30;
|
|
127
|
+
|
|
128
|
+
function stringifyChatRecordContentValue(value: unknown): string | undefined {
|
|
129
|
+
if (typeof value === "string") {
|
|
130
|
+
return trimString(value);
|
|
131
|
+
}
|
|
132
|
+
if (!value || typeof value !== "object") {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const record = value as Record<string, unknown>;
|
|
136
|
+
const content = record.content;
|
|
137
|
+
const contentText =
|
|
138
|
+
typeof content === "string"
|
|
139
|
+
? trimString(content)
|
|
140
|
+
: content && typeof content === "object"
|
|
141
|
+
? trimString((content as Record<string, unknown>).text as string | undefined)
|
|
142
|
+
: undefined;
|
|
143
|
+
|
|
144
|
+
// Preserve DingTalk's observed chatRecord text priority: explicit text first,
|
|
145
|
+
// then nested content text, then the legacy message fallback.
|
|
146
|
+
return (
|
|
147
|
+
trimString(record.text as string | undefined) ||
|
|
148
|
+
contentText ||
|
|
149
|
+
trimString(record.message as string | undefined)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getChatRecordEntriesSource(content: Record<string, unknown> | undefined): unknown {
|
|
154
|
+
return content?.chatRecord ?? content?.records ?? content?.messages;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatChatRecordEntries(rawRecord: unknown): string[] {
|
|
158
|
+
let entries = rawRecord;
|
|
159
|
+
if (typeof rawRecord === "string") {
|
|
160
|
+
const trimmed = rawRecord.trim();
|
|
161
|
+
if (!trimmed || trimmed === "[]") {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
entries = JSON.parse(trimmed);
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(entries)) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return entries
|
|
174
|
+
.map((entry) => {
|
|
175
|
+
if (!entry || typeof entry !== "object") {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const record = entry as Record<string, unknown>;
|
|
179
|
+
const sender =
|
|
180
|
+
trimString(record.senderName as string | undefined) ||
|
|
181
|
+
trimString(record.senderNick as string | undefined) ||
|
|
182
|
+
trimString(record.sender as string | undefined) ||
|
|
183
|
+
trimString(record.senderId as string | undefined) ||
|
|
184
|
+
UNKNOWN_PERSON_LABEL;
|
|
185
|
+
const body = stringifyChatRecordContentValue(
|
|
186
|
+
record.content ?? record.text ?? record.message ?? record.body,
|
|
187
|
+
);
|
|
188
|
+
return body ? `${sender}: ${body}` : undefined;
|
|
189
|
+
})
|
|
190
|
+
.filter((line): line is string => Boolean(line))
|
|
191
|
+
.slice(0, MAX_CHAT_RECORD_ENTRIES);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formatChatRecordPreview(
|
|
195
|
+
content: Record<string, unknown> | undefined,
|
|
196
|
+
options: { useTitleAsLabel?: boolean } = {},
|
|
197
|
+
): string | undefined {
|
|
198
|
+
const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
|
|
199
|
+
const title = typeof content?.title === "string" ? content.title.trim() : "";
|
|
200
|
+
const rawRecord = getChatRecordEntriesSource(content);
|
|
201
|
+
const recordLines = formatChatRecordEntries(rawRecord);
|
|
202
|
+
const parts: string[] = [];
|
|
203
|
+
if (summary && summary !== "[]") {
|
|
204
|
+
const label = options.useTitleAsLabel
|
|
205
|
+
? title
|
|
206
|
+
? `[${title}] `
|
|
207
|
+
: "[聊天记录] "
|
|
208
|
+
: "[聊天记录摘要] ";
|
|
209
|
+
parts.push(`${label}${summary}`);
|
|
210
|
+
}
|
|
211
|
+
if (recordLines.length > 0) {
|
|
212
|
+
parts.push(`[聊天记录内容]\n${recordLines.join("\n")}`);
|
|
213
|
+
}
|
|
214
|
+
return parts.join("\n\n") || undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildQuotedMessageTypePlaceholder(
|
|
218
|
+
messageType: string | undefined,
|
|
219
|
+
fileName?: string,
|
|
220
|
+
): string | undefined {
|
|
119
221
|
switch (messageType) {
|
|
120
222
|
case "text":
|
|
121
223
|
return undefined;
|
|
@@ -147,8 +249,7 @@ function buildLegacyQuoteMessagePreview(message: DingTalkInboundMessage["quoteMe
|
|
|
147
249
|
const previewMessageType = trimString(message?.msgtype);
|
|
148
250
|
return {
|
|
149
251
|
previewText:
|
|
150
|
-
trimString(message?.text?.content) ||
|
|
151
|
-
buildQuotedMessageTypePlaceholder(previewMessageType),
|
|
252
|
+
trimString(message?.text?.content) || buildQuotedMessageTypePlaceholder(previewMessageType),
|
|
152
253
|
previewMessageType,
|
|
153
254
|
previewSenderId: trimString(message?.senderId),
|
|
154
255
|
};
|
|
@@ -202,7 +303,10 @@ function buildRepliedMessagePreview(params: {
|
|
|
202
303
|
return {
|
|
203
304
|
isQuotedFile: true,
|
|
204
305
|
fileCreatedAt: repliedMsg.createdAt,
|
|
205
|
-
previewText: buildQuotedMessageTypePlaceholder(
|
|
306
|
+
previewText: buildQuotedMessageTypePlaceholder(
|
|
307
|
+
repliedMsgType,
|
|
308
|
+
hasFileName ? fileName : undefined,
|
|
309
|
+
),
|
|
206
310
|
previewMessageType: repliedMsgType,
|
|
207
311
|
...(hasFileName ? { previewFileName: fileName } : {}),
|
|
208
312
|
previewSenderId: trimString(repliedMsg.senderId),
|
|
@@ -235,11 +339,11 @@ function buildRepliedMessagePreview(params: {
|
|
|
235
339
|
}
|
|
236
340
|
|
|
237
341
|
if (repliedMsgType === "chatRecord") {
|
|
238
|
-
const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
|
|
239
|
-
const title = typeof content?.title === "string" ? content.title.trim() : "";
|
|
240
|
-
const chatRecordLabel = title ? `[${title}] ` : "[聊天记录] ";
|
|
241
342
|
return {
|
|
242
|
-
previewText:
|
|
343
|
+
previewText:
|
|
344
|
+
formatChatRecordPreview(content as Record<string, unknown> | undefined, {
|
|
345
|
+
useTitleAsLabel: true,
|
|
346
|
+
}) || buildQuotedMessageTypePlaceholder("chatRecord"),
|
|
243
347
|
previewMessageType: "chatRecord",
|
|
244
348
|
previewSenderId: trimString(repliedMsg.senderId),
|
|
245
349
|
};
|
|
@@ -517,7 +621,10 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
517
621
|
mediaPath: pictureDownloadCode,
|
|
518
622
|
mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
|
|
519
623
|
mediaType: pictureDownloadCode ? "image" : undefined,
|
|
520
|
-
mediaTypes:
|
|
624
|
+
mediaTypes:
|
|
625
|
+
uniquePictureDownloadCodes.length > 0
|
|
626
|
+
? uniquePictureDownloadCodes.map(() => "image")
|
|
627
|
+
: undefined,
|
|
521
628
|
messageType: "richText",
|
|
522
629
|
quoted: quoted ?? undefined,
|
|
523
630
|
atMentions,
|
|
@@ -605,7 +712,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
605
712
|
if (msgtype === "chatRecord") {
|
|
606
713
|
const content = data.content as Record<string, unknown> | undefined;
|
|
607
714
|
const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
|
|
608
|
-
const rawRecord = content
|
|
715
|
+
const rawRecord = getChatRecordEntriesSource(content);
|
|
716
|
+
const chatRecordText = formatChatRecordPreview(content);
|
|
609
717
|
if (
|
|
610
718
|
summary === "[]" ||
|
|
611
719
|
(typeof rawRecord === "string" && rawRecord.trim() === "[]") ||
|
|
@@ -619,9 +727,9 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
619
727
|
atUserDingtalkIds,
|
|
620
728
|
};
|
|
621
729
|
}
|
|
622
|
-
if (
|
|
730
|
+
if (chatRecordText) {
|
|
623
731
|
return {
|
|
624
|
-
text:
|
|
732
|
+
text: chatRecordText,
|
|
625
733
|
messageType: "chatRecord",
|
|
626
734
|
quoted: quoted ?? undefined,
|
|
627
735
|
atMentions,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { sendMessage } from "../send-service";
|
|
2
|
+
import type { DingTalkConfig, Logger } from "../types";
|
|
3
|
+
|
|
4
|
+
const MAX_QUESTION_LENGTH = 80;
|
|
5
|
+
const LEADING_MENTIONS_RE = /^(?:@\S+\s+)*/u;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Strip leading `@mention` tokens from inbound text. Used by both the abort and
|
|
9
|
+
* BTW bypass branches in `inbound-handler.ts` so that command detection works
|
|
10
|
+
* uniformly in DM and group chats.
|
|
11
|
+
*/
|
|
12
|
+
export function stripLeadingMentions(text: string): string {
|
|
13
|
+
return text.replace(LEADING_MENTIONS_RE, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildBtwBlockquote(senderName: string, rawQuestion: string): string {
|
|
17
|
+
const stripped = stripLeadingMentions(rawQuestion);
|
|
18
|
+
// Iterate by Unicode code points (not UTF-16 code units) so emoji /
|
|
19
|
+
// surrogate pairs aren't sliced in half at the truncation boundary.
|
|
20
|
+
const codePoints = Array.from(stripped);
|
|
21
|
+
const truncated =
|
|
22
|
+
codePoints.length > MAX_QUESTION_LENGTH
|
|
23
|
+
? `${codePoints.slice(0, MAX_QUESTION_LENGTH).join("")}…`
|
|
24
|
+
: stripped;
|
|
25
|
+
const senderPrefix = senderName ? `${senderName}: ` : "";
|
|
26
|
+
return `> ${senderPrefix}${truncated}\n\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DeliverBtwReplyArgs {
|
|
30
|
+
config: DingTalkConfig;
|
|
31
|
+
sessionWebhook: string | undefined;
|
|
32
|
+
conversationId: string;
|
|
33
|
+
to: string;
|
|
34
|
+
senderName: string;
|
|
35
|
+
rawQuestion: string;
|
|
36
|
+
replyText: string;
|
|
37
|
+
log: Logger | undefined;
|
|
38
|
+
accountId?: string;
|
|
39
|
+
storePath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Deliver a BTW reply through the unified `sendMessage` entry point.
|
|
44
|
+
*
|
|
45
|
+
* BTW is a special inbound trigger, but the *outbound* reply is still a regular
|
|
46
|
+
* markdown/text message and must inherit the standard send-service semantics:
|
|
47
|
+
* persistence into the message context store, delivery metadata tracking, and
|
|
48
|
+
* the single `{ ok, error, ... }` contract. We pass `forceMarkdown: true` so
|
|
49
|
+
* that `sendMessage` skips the card branch even when the channel is configured
|
|
50
|
+
* for card mode — BTW must never create or touch an AI Card (see CLAUDE.md
|
|
51
|
+
* anti-pattern: "Do not create multiple active AI Cards for the same
|
|
52
|
+
* `accountId:conversationId`").
|
|
53
|
+
*
|
|
54
|
+
* When `sessionWebhook` is present `sendMessage` internally dispatches via
|
|
55
|
+
* `sendBySession`; otherwise it falls back to the proactive text/markdown API.
|
|
56
|
+
* Either way the caller sees the same return shape, and failures propagate as
|
|
57
|
+
* `{ ok: false }` instead of being silently swallowed.
|
|
58
|
+
*/
|
|
59
|
+
export async function deliverBtwReply(
|
|
60
|
+
args: DeliverBtwReplyArgs,
|
|
61
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
62
|
+
const blockquote = buildBtwBlockquote(args.senderName, args.rawQuestion);
|
|
63
|
+
const fullText = `${blockquote}${args.replyText}`;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = await sendMessage(args.config, args.to, fullText, {
|
|
67
|
+
log: args.log,
|
|
68
|
+
accountId: args.accountId,
|
|
69
|
+
storePath: args.storePath,
|
|
70
|
+
conversationId: args.conversationId,
|
|
71
|
+
sessionWebhook: args.sessionWebhook,
|
|
72
|
+
forceMarkdown: true,
|
|
73
|
+
});
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
args.log?.warn?.(
|
|
76
|
+
`[DingTalk] BTW reply delivery returned not-ok: ${result.error ?? "unknown"}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { ok: result.ok, error: result.error };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
82
|
+
args.log?.warn?.(`[DingTalk] BTW reply delivery threw: ${error}`);
|
|
83
|
+
return { ok: false, error };
|
|
84
|
+
}
|
|
85
|
+
}
|