@soimy/dingtalk 3.5.1 → 3.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +8 -3
- package/src/ack-reaction-service.ts +1 -1
- package/src/auth.ts +1 -1
- package/src/card/card-action-handler.ts +1 -1
- package/src/card/card-stop-handler.ts +1 -1
- package/src/card/reasoning-block-assembler.ts +157 -0
- package/src/card-callback-service.ts +1 -1
- package/src/card-draft-controller.ts +32 -0
- package/src/card-service.ts +1 -1
- package/src/channel.ts +71 -55
- package/src/command/card-stop-command.ts +4 -22
- package/src/command/inbound-command-dispatch-service.ts +464 -0
- package/src/docs-service.ts +5 -5
- package/src/http-client.ts +20 -0
- package/src/inbound-handler.ts +120 -463
- package/src/logger-context.ts +16 -2
- package/src/media-utils.ts +3 -3
- package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
- package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +5 -5
- package/src/reply-strategy-card.ts +64 -13
- package/src/reply-strategy-markdown.ts +123 -18
- package/src/reply-strategy.ts +4 -0
- package/src/send-service.ts +68 -3
- package/src/targeting/agent-routing.ts +11 -4
- package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
- package/src/utils.ts +165 -0
package/src/logger-context.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import type { Logger } from "./types";
|
|
2
2
|
|
|
3
3
|
let currentLogger: Logger | undefined;
|
|
4
|
+
const loggerByAccountId = new Map<string, Logger>();
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Persist current request logger for shared services invoked outside handler scope.
|
|
7
8
|
*/
|
|
8
|
-
export function setCurrentLogger(log?: Logger): void {
|
|
9
|
+
export function setCurrentLogger(log?: Logger, accountId?: string | null): void {
|
|
9
10
|
currentLogger = log;
|
|
11
|
+
const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
|
|
12
|
+
if (!normalizedAccountId) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (log) {
|
|
16
|
+
loggerByAccountId.set(normalizedAccountId, log);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
loggerByAccountId.delete(normalizedAccountId);
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Read current logger bound by inbound handler.
|
|
14
24
|
*/
|
|
15
|
-
export function getLogger(): Logger | undefined {
|
|
25
|
+
export function getLogger(accountId?: string | null): Logger | undefined {
|
|
26
|
+
const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
|
|
27
|
+
if (normalizedAccountId) {
|
|
28
|
+
return loggerByAccountId.get(normalizedAccountId);
|
|
29
|
+
}
|
|
16
30
|
return currentLogger;
|
|
17
31
|
}
|
package/src/media-utils.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as path from "node:path";
|
|
|
11
11
|
import { promises as fsPromises } from "node:fs";
|
|
12
12
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
13
13
|
import { BlockList, isIP } from "node:net";
|
|
14
|
-
import axios from "
|
|
14
|
+
import axios from "./http-client";
|
|
15
15
|
import FormData from "form-data";
|
|
16
16
|
import type { DingTalkConfig, Logger } from "./types";
|
|
17
17
|
import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
|
|
@@ -26,7 +26,7 @@ interface PluginRuntimeWithMedia {
|
|
|
26
26
|
media?: {
|
|
27
27
|
loadWebMedia(
|
|
28
28
|
mediaPath: string,
|
|
29
|
-
options?: {
|
|
29
|
+
options?: { localRoots?: readonly string[] | "any" },
|
|
30
30
|
): Promise<{ buffer: Buffer | ArrayBuffer; fileName?: string; contentType?: string } | null>;
|
|
31
31
|
};
|
|
32
32
|
[key: string]: unknown;
|
|
@@ -667,7 +667,7 @@ async function readMediaBuffer(
|
|
|
667
667
|
}
|
|
668
668
|
|
|
669
669
|
const media = await rt.media.loadWebMedia(mediaPath, {
|
|
670
|
-
|
|
670
|
+
localRoots: options?.mediaLocalRoots,
|
|
671
671
|
});
|
|
672
672
|
|
|
673
673
|
if (!media || !media.buffer) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { AttachmentTextSource } from "
|
|
3
|
+
import type { AttachmentTextSource } from "../types";
|
|
4
4
|
|
|
5
5
|
const MAX_EXTRACTED_TEXT_CHARS = 6000;
|
|
6
6
|
const MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
-
import axios from "
|
|
4
|
-
import { getAccessToken } from "
|
|
5
|
-
import { getDingTalkRuntime } from "
|
|
6
|
-
import type { DingTalkConfig, Logger, MediaFile } from "
|
|
7
|
-
import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "
|
|
3
|
+
import axios from "../http-client";
|
|
4
|
+
import { getAccessToken } from "../auth";
|
|
5
|
+
import { getDingTalkRuntime } from "../runtime";
|
|
6
|
+
import type { DingTalkConfig, Logger, MediaFile } from "../types";
|
|
7
|
+
import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "../utils";
|
|
8
8
|
|
|
9
9
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
10
10
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
finishAICard,
|
|
11
11
|
isCardInTerminalState,
|
|
12
12
|
} from "./card-service";
|
|
13
|
+
import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
|
|
13
14
|
import { createCardDraftController } from "./card-draft-controller";
|
|
14
15
|
import { attachCardRunController } from "./card/card-run-registry";
|
|
15
16
|
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
@@ -18,7 +19,7 @@ import type { AICardInstance } from "./types";
|
|
|
18
19
|
import { AICardStatus } from "./types";
|
|
19
20
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
+
const EMPTY_FINAL_REPLY = "✅ Done";
|
|
22
23
|
|
|
23
24
|
export function createCardReplyStrategy(
|
|
24
25
|
ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
|
|
@@ -26,6 +27,7 @@ export function createCardReplyStrategy(
|
|
|
26
27
|
const { card, config, log, isStopRequested } = ctx;
|
|
27
28
|
|
|
28
29
|
const controller = createCardDraftController({ card, log });
|
|
30
|
+
const reasoningAssembler = createReasoningBlockAssembler();
|
|
29
31
|
if (card.outTrackId) {
|
|
30
32
|
attachCardRunController(card.outTrackId, controller);
|
|
31
33
|
}
|
|
@@ -33,25 +35,61 @@ export function createCardReplyStrategy(
|
|
|
33
35
|
let sawFinalDelivery = false;
|
|
34
36
|
|
|
35
37
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
36
|
-
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ?
|
|
38
|
+
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
37
39
|
return controller.getRenderedContent({
|
|
38
40
|
fallbackAnswer,
|
|
39
41
|
overrideAnswer: options.preferFinalAnswer ? finalTextForFallback : undefined,
|
|
40
42
|
});
|
|
41
43
|
};
|
|
42
44
|
|
|
45
|
+
const appendAssembledThinkingBlocks = async (blocks: string[]): Promise<void> => {
|
|
46
|
+
for (const block of blocks) {
|
|
47
|
+
if (!block.trim() || isStopRequested?.()) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
await controller.appendThinkingBlock(block);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ingestReasoningSnapshot = async (text: string | undefined): Promise<void> => {
|
|
55
|
+
const blocks = reasoningAssembler.ingestSnapshot(text);
|
|
56
|
+
if (
|
|
57
|
+
blocks.length === 0
|
|
58
|
+
&& typeof text === "string"
|
|
59
|
+
&& text.trim()
|
|
60
|
+
&& !text.trimStart().startsWith("Reasoning:")
|
|
61
|
+
) {
|
|
62
|
+
await appendAssembledThinkingBlocks([text.trim()]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await appendAssembledThinkingBlocks(blocks);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const flushPendingReasoning = async (): Promise<void> => {
|
|
69
|
+
const blocks = reasoningAssembler.flushPendingAtBoundary();
|
|
70
|
+
await appendAssembledThinkingBlocks(blocks);
|
|
71
|
+
};
|
|
72
|
+
|
|
43
73
|
return {
|
|
44
74
|
getReplyOptions(): ReplyOptions {
|
|
45
75
|
return {
|
|
46
|
-
// Card mode
|
|
47
|
-
//
|
|
48
|
-
disableBlockStreaming: true,
|
|
76
|
+
// Card mode keeps runtime block streaming disabled, but still consumes
|
|
77
|
+
// reasoning blocks through explicit callbacks and delivery metadata.
|
|
78
|
+
disableBlockStreaming: ctx.disableBlockStreaming ?? true,
|
|
49
79
|
|
|
50
80
|
onAssistantMessageStart: async () => {
|
|
51
81
|
if (isStopRequested?.()) {
|
|
52
82
|
return;
|
|
53
83
|
}
|
|
54
|
-
|
|
84
|
+
const pendingReasoningBlocks = reasoningAssembler.flushPendingAtBoundary();
|
|
85
|
+
reasoningAssembler.reset();
|
|
86
|
+
const turnBoundary = controller.notifyNewAssistantTurn();
|
|
87
|
+
if (pendingReasoningBlocks.length > 0) {
|
|
88
|
+
await turnBoundary;
|
|
89
|
+
await appendAssembledThinkingBlocks(pendingReasoningBlocks);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await turnBoundary;
|
|
55
93
|
},
|
|
56
94
|
|
|
57
95
|
onPartialReply: config.cardRealTimeStream
|
|
@@ -64,7 +102,7 @@ export function createCardReplyStrategy(
|
|
|
64
102
|
|
|
65
103
|
onReasoningStream: async (payload) => {
|
|
66
104
|
if (payload.text && !isStopRequested?.()) {
|
|
67
|
-
await
|
|
105
|
+
await ingestReasoningSnapshot(payload.text);
|
|
68
106
|
}
|
|
69
107
|
},
|
|
70
108
|
};
|
|
@@ -82,6 +120,7 @@ export function createCardReplyStrategy(
|
|
|
82
120
|
|
|
83
121
|
// ---- final: defer to finalize, just save text ----
|
|
84
122
|
if (payload.kind === "final") {
|
|
123
|
+
await flushPendingReasoning();
|
|
85
124
|
sawFinalDelivery = true;
|
|
86
125
|
log?.info?.(
|
|
87
126
|
`[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
|
|
@@ -106,6 +145,7 @@ export function createCardReplyStrategy(
|
|
|
106
145
|
log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
|
|
107
146
|
return;
|
|
108
147
|
}
|
|
148
|
+
await flushPendingReasoning();
|
|
109
149
|
log?.info?.(
|
|
110
150
|
`[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
|
|
111
151
|
);
|
|
@@ -113,7 +153,16 @@ export function createCardReplyStrategy(
|
|
|
113
153
|
return;
|
|
114
154
|
}
|
|
115
155
|
|
|
116
|
-
|
|
156
|
+
const isReasoningBlock = payload.isReasoning === true;
|
|
157
|
+
if (typeof textToSend === "string" && textToSend.trim()) {
|
|
158
|
+
if (isReasoningBlock) {
|
|
159
|
+
await ingestReasoningSnapshot(textToSend);
|
|
160
|
+
} else {
|
|
161
|
+
await controller.updateAnswer(textToSend);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
117
166
|
if (payload.mediaUrls.length > 0) {
|
|
118
167
|
await ctx.deliverMedia(payload.mediaUrls);
|
|
119
168
|
}
|
|
@@ -173,13 +222,15 @@ export function createCardReplyStrategy(
|
|
|
173
222
|
|
|
174
223
|
// Normal finalize.
|
|
175
224
|
try {
|
|
225
|
+
await flushPendingReasoning();
|
|
176
226
|
await controller.flush();
|
|
177
227
|
await controller.waitForInFlight();
|
|
178
|
-
const
|
|
228
|
+
const renderedTimeline = getRenderedTimeline({ preferFinalAnswer: true });
|
|
229
|
+
const finalText = renderedTimeline || EMPTY_FINAL_REPLY;
|
|
179
230
|
controller.stop();
|
|
180
231
|
log?.info?.(
|
|
181
232
|
`[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
|
|
182
|
-
`source=${controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
233
|
+
`source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
183
234
|
`preview="${finalText.slice(0, 120)}"`,
|
|
184
235
|
);
|
|
185
236
|
await finishAICard(card, finalText, log, {
|
|
@@ -228,9 +279,9 @@ export function createCardReplyStrategy(
|
|
|
228
279
|
},
|
|
229
280
|
|
|
230
281
|
getFinalText(): string | undefined {
|
|
231
|
-
return
|
|
232
|
-
||
|
|
233
|
-
|| (sawFinalDelivery ?
|
|
282
|
+
return finalTextForFallback
|
|
283
|
+
|| controller.getFinalAnswerContent()
|
|
284
|
+
|| (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
234
285
|
},
|
|
235
286
|
};
|
|
236
287
|
}
|
|
@@ -1,47 +1,152 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown / text reply strategy.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* DingTalk cannot edit prior messages in place, so markdown mode emits
|
|
5
|
+
* incremental answer tails from dispatcher-delivered block/final payloads.
|
|
6
|
+
* Reasoning display is intentionally unsupported on DingTalk markdown.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
9
10
|
import { sendMessage } from "./send-service";
|
|
10
11
|
|
|
12
|
+
const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
|
|
13
|
+
|
|
14
|
+
function renderQuotedSegment(text: string): string {
|
|
15
|
+
return text
|
|
16
|
+
.split("\n")
|
|
17
|
+
.map((line) => line.length > 0 ? `> ${line}` : ">")
|
|
18
|
+
.join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function computeIncrementalSuffix(previous: string, next: string): string {
|
|
22
|
+
const prev = previous || "";
|
|
23
|
+
const current = next || "";
|
|
24
|
+
if (!current.trim()) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
if (!prev) {
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
if (!current.startsWith(prev)) {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
const suffix = current.slice(prev.length);
|
|
34
|
+
return suffix.trim() ? suffix : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function computeSharedPrefixTail(previous: string, next: string): string {
|
|
38
|
+
const prev = previous || "";
|
|
39
|
+
const current = next || "";
|
|
40
|
+
if (!prev || !current.trim()) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const limit = Math.min(prev.length, current.length);
|
|
44
|
+
let sharedPrefixLength = 0;
|
|
45
|
+
while (sharedPrefixLength < limit && prev[sharedPrefixLength] === current[sharedPrefixLength]) {
|
|
46
|
+
sharedPrefixLength += 1;
|
|
47
|
+
}
|
|
48
|
+
if (sharedPrefixLength === 0) {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
const suffix = current.slice(sharedPrefixLength);
|
|
52
|
+
return suffix.trim() ? suffix : "";
|
|
53
|
+
}
|
|
54
|
+
|
|
11
55
|
export function createMarkdownReplyStrategy(
|
|
12
56
|
ctx: ReplyStrategyContext,
|
|
13
57
|
): ReplyStrategy {
|
|
14
58
|
let finalText: string | undefined;
|
|
59
|
+
let activeAnswerText = "";
|
|
60
|
+
let lastSentAnswerText = "";
|
|
61
|
+
let sentVisibleContent = false;
|
|
62
|
+
|
|
63
|
+
const sendMarkdownSegment = async (text: string): Promise<void> => {
|
|
64
|
+
if (!text.trim()) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const sendResult = await sendMessage(ctx.config, ctx.to, text, {
|
|
68
|
+
sessionWebhook: ctx.sessionWebhook,
|
|
69
|
+
atUserId: !ctx.isDirect ? ctx.senderId : null,
|
|
70
|
+
log: ctx.log,
|
|
71
|
+
accountId: ctx.accountId,
|
|
72
|
+
storePath: ctx.storePath,
|
|
73
|
+
conversationId: ctx.groupId,
|
|
74
|
+
quotedRef: ctx.replyQuotedRef,
|
|
75
|
+
});
|
|
76
|
+
if (!sendResult.ok) {
|
|
77
|
+
throw new Error(sendResult.error || "Reply send failed");
|
|
78
|
+
}
|
|
79
|
+
sentVisibleContent = true;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const emitAnswerSuffix = async (text: string | undefined): Promise<void> => {
|
|
83
|
+
const current = typeof text === "string" ? text : "";
|
|
84
|
+
if (current.length > 0) {
|
|
85
|
+
activeAnswerText = current;
|
|
86
|
+
finalText = current;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const suffix = computeIncrementalSuffix(lastSentAnswerText, current);
|
|
90
|
+
if (suffix) {
|
|
91
|
+
await sendMarkdownSegment(suffix);
|
|
92
|
+
lastSentAnswerText = current;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (current.trim() && lastSentAnswerText && !current.startsWith(lastSentAnswerText)) {
|
|
97
|
+
const suffix = computeSharedPrefixTail(lastSentAnswerText, current);
|
|
98
|
+
ctx.log?.warn?.(
|
|
99
|
+
`[DingTalk][Markdown] answer prefix drift detected; falling back to shared-prefix tail ` +
|
|
100
|
+
`prevLen=${lastSentAnswerText.length} currentLen=${current.length}`,
|
|
101
|
+
);
|
|
102
|
+
lastSentAnswerText = "";
|
|
103
|
+
if (suffix) {
|
|
104
|
+
await sendMarkdownSegment(suffix);
|
|
105
|
+
lastSentAnswerText = current;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await sendMarkdownSegment(current);
|
|
109
|
+
lastSentAnswerText = current;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
15
112
|
|
|
16
113
|
return {
|
|
17
114
|
getReplyOptions(): ReplyOptions {
|
|
18
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
disableBlockStreaming: ctx.disableBlockStreaming === true,
|
|
117
|
+
};
|
|
19
118
|
},
|
|
20
119
|
|
|
21
120
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
22
121
|
if (payload.mediaUrls.length > 0) {
|
|
23
122
|
await ctx.deliverMedia(payload.mediaUrls);
|
|
123
|
+
sentVisibleContent = true;
|
|
24
124
|
}
|
|
25
125
|
|
|
26
|
-
if (payload.kind === "
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
atUserId: !ctx.isDirect ? ctx.senderId : null,
|
|
31
|
-
log: ctx.log,
|
|
32
|
-
accountId: ctx.accountId,
|
|
33
|
-
storePath: ctx.storePath,
|
|
34
|
-
conversationId: ctx.groupId,
|
|
35
|
-
quotedRef: ctx.replyQuotedRef,
|
|
36
|
-
});
|
|
37
|
-
if (!sendResult.ok) {
|
|
38
|
-
throw new Error(sendResult.error || "Reply send failed");
|
|
126
|
+
if (payload.kind === "tool") {
|
|
127
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
128
|
+
if (!text.trim()) {
|
|
129
|
+
return;
|
|
39
130
|
}
|
|
131
|
+
await sendMarkdownSegment(renderQuotedSegment(text));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
(payload.kind === "block" || payload.kind === "final")
|
|
137
|
+
&& typeof payload.text === "string"
|
|
138
|
+
) {
|
|
139
|
+
await emitAnswerSuffix(payload.text);
|
|
40
140
|
}
|
|
41
141
|
},
|
|
42
142
|
|
|
43
143
|
async finalize(): Promise<void> {
|
|
44
|
-
|
|
144
|
+
if (sentVisibleContent) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
finalText = EMPTY_FINAL_FALLBACK_TEXT;
|
|
148
|
+
activeAnswerText = EMPTY_FINAL_FALLBACK_TEXT;
|
|
149
|
+
await sendMarkdownSegment(EMPTY_FINAL_FALLBACK_TEXT);
|
|
45
150
|
},
|
|
46
151
|
|
|
47
152
|
async abort(): Promise<void> {
|
|
@@ -49,7 +154,7 @@ export function createMarkdownReplyStrategy(
|
|
|
49
154
|
},
|
|
50
155
|
|
|
51
156
|
getFinalText(): string | undefined {
|
|
52
|
-
return finalText;
|
|
157
|
+
return finalText || activeAnswerText || undefined;
|
|
53
158
|
},
|
|
54
159
|
};
|
|
55
160
|
}
|
package/src/reply-strategy.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface DeliverPayload {
|
|
|
16
16
|
text?: string;
|
|
17
17
|
mediaUrls: string[];
|
|
18
18
|
kind: "block" | "final" | "tool";
|
|
19
|
+
isReasoning?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface ReplyOptions {
|
|
@@ -51,6 +52,9 @@ export interface ReplyStrategyContext {
|
|
|
51
52
|
isDirect: boolean;
|
|
52
53
|
accountId: string;
|
|
53
54
|
storePath: string;
|
|
55
|
+
disableBlockStreaming?: boolean;
|
|
56
|
+
sessionKey?: string;
|
|
57
|
+
sessionAgentId?: string;
|
|
54
58
|
groupId?: string;
|
|
55
59
|
log?: Logger;
|
|
56
60
|
replyQuotedRef?: QuotedRef;
|
package/src/send-service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import axios from "
|
|
2
|
+
import axios from "./http-client";
|
|
3
3
|
import { getAccessToken } from "./auth";
|
|
4
4
|
import {
|
|
5
5
|
isCardInTerminalState,
|
|
@@ -37,6 +37,53 @@ import type {
|
|
|
37
37
|
|
|
38
38
|
export { detectMediaTypeFromExtension } from "./media-utils";
|
|
39
39
|
|
|
40
|
+
const MARKDOWN_LOCAL_IMAGE_RE =
|
|
41
|
+
/!\[([^\]]*)\]\((file:\/\/\/[^)]+|\/(?:tmp|var|private|Users|home|root)[^)]+|[A-Za-z]:[\\/][^)]+)\)/g;
|
|
42
|
+
|
|
43
|
+
function decodeMarkdownLocalImagePath(rawPath: string): string {
|
|
44
|
+
const unescapedPath = rawPath.replace(/\\ /g, " ");
|
|
45
|
+
if (unescapedPath.startsWith("file://")) {
|
|
46
|
+
try {
|
|
47
|
+
return decodeURIComponent(unescapedPath.replace("file://", ""));
|
|
48
|
+
} catch {
|
|
49
|
+
return unescapedPath.replace("file://", "");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return unescapedPath;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function replaceMarkdownLocalImages(params: {
|
|
56
|
+
config: DingTalkConfig;
|
|
57
|
+
text: string;
|
|
58
|
+
log?: Logger;
|
|
59
|
+
mediaLocalRoots?: string[];
|
|
60
|
+
}): Promise<string> {
|
|
61
|
+
const matches = [...params.text.matchAll(MARKDOWN_LOCAL_IMAGE_RE)];
|
|
62
|
+
if (matches.length === 0) {
|
|
63
|
+
return params.text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let result = params.text;
|
|
67
|
+
for (const match of matches) {
|
|
68
|
+
const [fullMatch, altText, rawPath] = match;
|
|
69
|
+
const mediaPath = decodeMarkdownLocalImagePath(rawPath);
|
|
70
|
+
const uploadResult = await uploadMedia(params.config, mediaPath, "image", params.log, {
|
|
71
|
+
mediaLocalRoots: params.mediaLocalRoots,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!uploadResult?.mediaId) {
|
|
75
|
+
params.log?.warn?.(
|
|
76
|
+
`[DingTalk] Markdown local image upload failed, keep original reference: ${mediaPath}`,
|
|
77
|
+
);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
result = result.replace(fullMatch, () => ``);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
40
87
|
type ProactiveTextSendResult = AxiosResponse | { tracking: DingTalkTrackingMetadata };
|
|
41
88
|
|
|
42
89
|
function isTrackingResult(result: ProactiveTextSendResult): result is { tracking: DingTalkTrackingMetadata } {
|
|
@@ -252,7 +299,16 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
252
299
|
? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
|
253
300
|
: "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
|
|
254
301
|
|
|
255
|
-
const
|
|
302
|
+
const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
|
|
303
|
+
config,
|
|
304
|
+
text,
|
|
305
|
+
log,
|
|
306
|
+
mediaLocalRoots: options.mediaLocalRoots,
|
|
307
|
+
});
|
|
308
|
+
const normalizedText =
|
|
309
|
+
config.convertMarkdownTables !== false
|
|
310
|
+
? convertMarkdownTablesToPlainText(textWithUploadedLocalImages)
|
|
311
|
+
: textWithUploadedLocalImages;
|
|
256
312
|
const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
|
|
257
313
|
|
|
258
314
|
log?.debug?.(
|
|
@@ -531,7 +587,16 @@ export async function sendBySession(
|
|
|
531
587
|
}
|
|
532
588
|
|
|
533
589
|
// Fallback to text/markdown reply payload.
|
|
534
|
-
const
|
|
590
|
+
const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
|
|
591
|
+
config,
|
|
592
|
+
text,
|
|
593
|
+
log,
|
|
594
|
+
mediaLocalRoots: options.mediaLocalRoots,
|
|
595
|
+
});
|
|
596
|
+
const normalizedText =
|
|
597
|
+
config.convertMarkdownTables !== false
|
|
598
|
+
? convertMarkdownTablesToPlainText(textWithUploadedLocalImages)
|
|
599
|
+
: textWithUploadedLocalImages;
|
|
535
600
|
const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "Clawdbot 消息");
|
|
536
601
|
const chunks = splitMarkdownChunks(normalizedText, DINGTALK_TEXT_CHUNK_LIMIT);
|
|
537
602
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
11
|
+
import { maybeResolveTextAlias } from "openclaw/plugin-sdk/command-auth";
|
|
11
12
|
import { resolveAtAgents } from "./agent-name-matcher";
|
|
12
13
|
import { resolveRobotCode } from "../config";
|
|
13
14
|
import { parseLearnCommand } from "../learning-command-service";
|
|
@@ -93,16 +94,22 @@ export async function resolveSubAgentRoute(params: {
|
|
|
93
94
|
const atMentions = extractedContent.atMentions || [];
|
|
94
95
|
// DM has no @picker list from DingTalk; only group chats provide atUsers for real-user hints.
|
|
95
96
|
const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : undefined;
|
|
96
|
-
// Strip quoted prefix before checking
|
|
97
|
-
// when the quoted message itself contains a
|
|
97
|
+
// Strip quoted prefix before checking commands to avoid false positives
|
|
98
|
+
// when the quoted message itself contains a command.
|
|
98
99
|
const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
99
100
|
const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
|
|
101
|
+
// Slash commands like /new, /stop, /reasoning etc. must bypass sub-agent
|
|
102
|
+
// routing so they reach the framework's own command handling layer.
|
|
103
|
+
// Strip leading @mention tokens first since DM text may look like "@Agent /new".
|
|
104
|
+
const textWithoutMentions = textForCommandCheck.replace(/^(?:@\S+\s+)*/u, "").trim();
|
|
105
|
+
const isSlashCommand = maybeResolveTextAlias(textWithoutMentions, cfg) !== null;
|
|
100
106
|
|
|
101
107
|
if (
|
|
102
108
|
atMentions.length === 0 ||
|
|
103
109
|
!cfg.agents?.list ||
|
|
104
110
|
cfg.agents.list.length === 0 ||
|
|
105
|
-
isLearnCommand
|
|
111
|
+
isLearnCommand ||
|
|
112
|
+
isSlashCommand
|
|
106
113
|
) {
|
|
107
114
|
return null;
|
|
108
115
|
}
|
|
@@ -173,7 +180,7 @@ export async function dispatchSubAgents(params: {
|
|
|
173
180
|
dingtalkConfig,
|
|
174
181
|
subAgentOptions: {
|
|
175
182
|
agentId: agentMatch.agentId,
|
|
176
|
-
responsePrefix:
|
|
183
|
+
responsePrefix: `> 🤖 **${sanitizeAgentName(agentMatch.matchedName)}**:\n\n`,
|
|
177
184
|
matchedName: agentMatch.matchedName,
|
|
178
185
|
},
|
|
179
186
|
preDownloadedMedia,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { readNamespaceJson, writeNamespaceJsonAtomic } from "
|
|
3
|
+
import { readNamespaceJson, writeNamespaceJsonAtomic } from "../persistence-store";
|
|
4
4
|
|
|
5
5
|
const GROUP_MEMBERS_NAMESPACE = "members.group-roster";
|
|
6
6
|
|