@soimy/dingtalk 3.5.2 → 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/README.md +6 -23
- package/index.ts +7 -0
- package/openclaw.plugin.json +799 -0
- package/package.json +5 -5
- 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-streaming-mode.ts +30 -0
- package/src/card/card-template.ts +14 -3
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +326 -54
- package/src/card-service.ts +479 -8
- package/src/channel.ts +19 -1062
- package/src/config-schema.ts +81 -38
- package/src/config.ts +142 -4
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +489 -49
- package/src/media-utils.ts +169 -7
- package/src/message-utils.ts +153 -17
- 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/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +323 -205
- package/src/platform/channel-status.ts +81 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +568 -44
- package/src/reply-strategy-markdown.ts +2 -2
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -56
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +225 -7
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +49 -117
- package/src/utils.ts +25 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Reasoning display is intentionally unsupported on DingTalk markdown.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
9
|
+
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
|
|
10
10
|
import { sendMessage } from "./send-service";
|
|
11
11
|
|
|
12
12
|
const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
|
|
@@ -119,7 +119,7 @@ export function createMarkdownReplyStrategy(
|
|
|
119
119
|
|
|
120
120
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
121
121
|
if (payload.mediaUrls.length > 0) {
|
|
122
|
-
await ctx.deliverMedia(payload.mediaUrls);
|
|
122
|
+
await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
|
|
123
123
|
sentVisibleContent = true;
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions for reply strategy implementations.
|
|
3
|
+
*
|
|
4
|
+
* Extracted into a leaf module so that the factory (reply-strategy.ts) and
|
|
5
|
+
* concrete strategies (reply-strategy-card.ts, reply-strategy-markdown.ts)
|
|
6
|
+
* can share these interfaces without circular imports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
|
|
10
|
+
import type { DingTalkConfig, Logger, QuotedRef } from "./types";
|
|
11
|
+
|
|
12
|
+
// ---- Internal helper type ----
|
|
13
|
+
|
|
14
|
+
export type InternalReplyStrategyConfig = DingTalkConfig & {
|
|
15
|
+
/** @deprecated Internal compatibility only. Removed from public config surface. */
|
|
16
|
+
cardStreamReasoning?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ---- Public interfaces ----
|
|
20
|
+
|
|
21
|
+
export interface DeliverPayload {
|
|
22
|
+
text?: string;
|
|
23
|
+
mediaUrls: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Shared reply-runtime voice hint. Strategies forward this unchanged into the
|
|
26
|
+
* channel media delivery helper; inbound-handler is responsible for bridging
|
|
27
|
+
* legacy aliases (for example `asVoice`) into this single field.
|
|
28
|
+
*/
|
|
29
|
+
audioAsVoice?: boolean;
|
|
30
|
+
kind: "block" | "final" | "tool";
|
|
31
|
+
isReasoning?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReplyOptions {
|
|
35
|
+
disableBlockStreaming: boolean;
|
|
36
|
+
onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
|
|
37
|
+
onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
|
|
38
|
+
onAssistantMessageStart?: () => void | Promise<void>;
|
|
39
|
+
onAgentRunStart?: GetReplyOptions["onAgentRunStart"];
|
|
40
|
+
onModelSelected?: GetReplyOptions["onModelSelected"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReplyStrategy {
|
|
44
|
+
/** Options forwarded to the runtime dispatcher. */
|
|
45
|
+
getReplyOptions(): ReplyOptions;
|
|
46
|
+
|
|
47
|
+
/** Called by the deliver callback for each payload chunk. */
|
|
48
|
+
deliver(payload: DeliverPayload): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/** Called after dispatch completes successfully. */
|
|
51
|
+
finalize(): Promise<void>;
|
|
52
|
+
|
|
53
|
+
/** Called when dispatch throws an error. */
|
|
54
|
+
abort(error: Error): Promise<void>;
|
|
55
|
+
|
|
56
|
+
/** Last known final text (for external consumers such as logging). */
|
|
57
|
+
getFinalText(): string | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Shared context passed to every strategy implementation. */
|
|
61
|
+
export interface TaskMeta {
|
|
62
|
+
model?: string;
|
|
63
|
+
effort?: string;
|
|
64
|
+
usage?: number;
|
|
65
|
+
elapsedMs?: number;
|
|
66
|
+
agent?: string;
|
|
67
|
+
runIds?: Set<string>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ReplyStrategyContext {
|
|
71
|
+
config: InternalReplyStrategyConfig;
|
|
72
|
+
to: string;
|
|
73
|
+
sessionWebhook: string;
|
|
74
|
+
senderId: string;
|
|
75
|
+
isDirect: boolean;
|
|
76
|
+
accountId: string;
|
|
77
|
+
storePath: string;
|
|
78
|
+
disableBlockStreaming?: boolean;
|
|
79
|
+
sessionKey?: string;
|
|
80
|
+
sessionAgentId?: string;
|
|
81
|
+
groupId?: string;
|
|
82
|
+
log?: Logger;
|
|
83
|
+
replyQuotedRef?: QuotedRef;
|
|
84
|
+
/**
|
|
85
|
+
* Channel-level media delivery hook. The `audioAsVoice` option is the same
|
|
86
|
+
* shared voice semantic carried on DeliverPayload, not a second independent
|
|
87
|
+
* config knob.
|
|
88
|
+
*/
|
|
89
|
+
deliverMedia: (urls: string[], options?: { audioAsVoice?: boolean }) => Promise<void>;
|
|
90
|
+
isStopRequested?: () => boolean;
|
|
91
|
+
inboundText?: string;
|
|
92
|
+
taskMeta?: TaskMeta;
|
|
93
|
+
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* concurrently. The `sessionFilter` parameter is required for this reason.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import type { DeliverPayload, ReplyOptions, ReplyStrategy } from "./reply-strategy";
|
|
27
|
+
import type { DeliverPayload, ReplyOptions, ReplyStrategy } from "./reply-strategy-types";
|
|
28
28
|
import type { DingTalkConfig, Logger } from "./types";
|
|
29
29
|
|
|
30
30
|
const TOOL_REACTION_SILENCE_MS = 55_000;
|
package/src/reply-strategy.ts
CHANGED
|
@@ -1,66 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reply strategy
|
|
2
|
+
* Reply strategy factory for DingTalk message delivery.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* their own state and lifecycle independently.
|
|
4
|
+
* Delegates the "how to deliver a reply" concern to card or markdown
|
|
5
|
+
* strategy implementations. Type definitions live in reply-strategy-types.ts.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
import type { AICardInstance
|
|
8
|
+
import type { AICardInstance } from "./types";
|
|
9
|
+
import type { ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
|
|
10
10
|
import { createCardReplyStrategy } from "./reply-strategy-card";
|
|
11
11
|
import { createMarkdownReplyStrategy } from "./reply-strategy-markdown";
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
export interface ReplyOptions {
|
|
23
|
-
disableBlockStreaming: boolean;
|
|
24
|
-
onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
|
|
25
|
-
onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
|
|
26
|
-
onAssistantMessageStart?: () => void | Promise<void>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ReplyStrategy {
|
|
30
|
-
/** Options forwarded to the runtime dispatcher. */
|
|
31
|
-
getReplyOptions(): ReplyOptions;
|
|
32
|
-
|
|
33
|
-
/** Called by the deliver callback for each payload chunk. */
|
|
34
|
-
deliver(payload: DeliverPayload): Promise<void>;
|
|
35
|
-
|
|
36
|
-
/** Called after dispatch completes successfully. */
|
|
37
|
-
finalize(): Promise<void>;
|
|
38
|
-
|
|
39
|
-
/** Called when dispatch throws an error. */
|
|
40
|
-
abort(error: Error): Promise<void>;
|
|
41
|
-
|
|
42
|
-
/** Last known final text (for external consumers such as logging). */
|
|
43
|
-
getFinalText(): string | undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Shared context passed to every strategy implementation. */
|
|
47
|
-
export interface ReplyStrategyContext {
|
|
48
|
-
config: DingTalkConfig;
|
|
49
|
-
to: string;
|
|
50
|
-
sessionWebhook: string;
|
|
51
|
-
senderId: string;
|
|
52
|
-
isDirect: boolean;
|
|
53
|
-
accountId: string;
|
|
54
|
-
storePath: string;
|
|
55
|
-
disableBlockStreaming?: boolean;
|
|
56
|
-
sessionKey?: string;
|
|
57
|
-
sessionAgentId?: string;
|
|
58
|
-
groupId?: string;
|
|
59
|
-
log?: Logger;
|
|
60
|
-
replyQuotedRef?: QuotedRef;
|
|
61
|
-
deliverMedia: (urls: string[]) => Promise<void>;
|
|
62
|
-
isStopRequested?: () => boolean;
|
|
63
|
-
}
|
|
13
|
+
// Re-export all types so existing consumers that import from "./reply-strategy"
|
|
14
|
+
// continue to work without changes beyond the ones we explicitly migrate.
|
|
15
|
+
export type {
|
|
16
|
+
DeliverPayload,
|
|
17
|
+
ReplyOptions,
|
|
18
|
+
ReplyStrategy,
|
|
19
|
+
ReplyStrategyContext,
|
|
20
|
+
TaskMeta,
|
|
21
|
+
} from "./reply-strategy-types";
|
|
64
22
|
|
|
65
23
|
// ---- Factory -----------------------------------------------------
|
|
66
24
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface UsageAccumulation {
|
|
2
|
+
input?: number;
|
|
3
|
+
output?: number;
|
|
4
|
+
cacheRead?: number;
|
|
5
|
+
cacheWrite?: number;
|
|
6
|
+
total?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const usageStore = new Map<string, UsageAccumulation>();
|
|
10
|
+
|
|
11
|
+
export function recordRunStart(runId: string): void {
|
|
12
|
+
if (!usageStore.has(runId)) {
|
|
13
|
+
usageStore.set(runId, {});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function accumulateUsage(runId: string, usage: UsageAccumulation): void {
|
|
18
|
+
const existing = usageStore.get(runId);
|
|
19
|
+
if (!existing) { return; }
|
|
20
|
+
|
|
21
|
+
for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
|
|
22
|
+
const value = usage[field];
|
|
23
|
+
if (typeof value === "number") {
|
|
24
|
+
existing[field] = (existing[field] ?? 0) + value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getUsageByRunId(runId: string): UsageAccumulation | undefined {
|
|
30
|
+
return usageStore.get(runId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getAggregatedUsage(runIds: Set<string> | undefined): UsageAccumulation {
|
|
34
|
+
const result: UsageAccumulation = {};
|
|
35
|
+
if (!runIds) { return result; }
|
|
36
|
+
for (const runId of runIds) {
|
|
37
|
+
const usage = usageStore.get(runId);
|
|
38
|
+
if (!usage) { continue; }
|
|
39
|
+
for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
|
|
40
|
+
if (typeof usage[field] === "number") {
|
|
41
|
+
result[field] = (result[field] ?? 0) + usage[field];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clearRun(runId: string | undefined): void {
|
|
49
|
+
if (runId) { usageStore.delete(runId); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearRuns(runIds: Set<string> | undefined): void {
|
|
53
|
+
if (!runIds) { return; }
|
|
54
|
+
for (const runId of runIds) { usageStore.delete(runId); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clearAllForTest(): void {
|
|
58
|
+
usageStore.clear();
|
|
59
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import axios from "./http-client";
|
|
3
3
|
import { getAccessToken } from "./auth";
|
|
4
|
+
import { resolveCardRunByConversation, resolveCardRunByOwner } from "./card/card-run-registry";
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
isCardInTerminalState,
|
|
6
8
|
sendProactiveCardText,
|
|
7
9
|
} from "./card-service";
|
|
8
10
|
import { resolveRobotCode, stripTargetPrefix } from "./config";
|
|
9
11
|
import { getLogger } from "./logger-context";
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getVoiceDurationMs,
|
|
14
|
+
prepareMediaInput,
|
|
15
|
+
resolveOutboundMediaType,
|
|
16
|
+
uploadMedia as uploadMediaUtil,
|
|
17
|
+
} from "./media-utils";
|
|
11
18
|
import { convertMarkdownTablesToPlainText, detectMarkdownAndExtractTitle } from "./message-utils";
|
|
12
19
|
import {
|
|
13
20
|
DEFAULT_MESSAGE_CONTEXT_TTL_DAYS,
|
|
@@ -176,6 +183,29 @@ function buildPersistedOutboundText(text: string, options: SendMessageOptions):
|
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
const DINGTALK_TEXT_CHUNK_LIMIT = 3800;
|
|
186
|
+
const CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS = 150;
|
|
187
|
+
const CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS = 25;
|
|
188
|
+
|
|
189
|
+
async function waitForCardControllerAttachment(
|
|
190
|
+
activeRun: ReturnType<typeof resolveCardRunByConversation>,
|
|
191
|
+
): Promise<NonNullable<ReturnType<typeof resolveCardRunByConversation>>["controller"] | null> {
|
|
192
|
+
if (!activeRun) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (activeRun.controller?.appendImageBlock) {
|
|
196
|
+
return activeRun.controller;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const deadline = Date.now() + CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS;
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS));
|
|
202
|
+
if (activeRun.controller?.appendImageBlock) {
|
|
203
|
+
return activeRun.controller;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return activeRun.controller?.appendImageBlock ? activeRun.controller : null;
|
|
208
|
+
}
|
|
179
209
|
|
|
180
210
|
function splitMarkdownChunks(text: string, limit = DINGTALK_TEXT_CHUNK_LIMIT): string[] {
|
|
181
211
|
if (!text || text.length <= limit) {
|
|
@@ -212,6 +242,14 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
|
|
|
212
242
|
}
|
|
213
243
|
|
|
214
244
|
const payload = data as Record<string, unknown>;
|
|
245
|
+
const errcode = payload.errcode;
|
|
246
|
+
if (typeof errcode === "number" && Number.isFinite(errcode)) {
|
|
247
|
+
return String(errcode);
|
|
248
|
+
}
|
|
249
|
+
if (typeof errcode === "string" && errcode.trim()) {
|
|
250
|
+
return errcode.trim();
|
|
251
|
+
}
|
|
252
|
+
|
|
215
253
|
const code = payload.code;
|
|
216
254
|
if (typeof code === "string" && code.trim()) {
|
|
217
255
|
return code.trim();
|
|
@@ -225,6 +263,62 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
|
|
|
225
263
|
return null;
|
|
226
264
|
}
|
|
227
265
|
|
|
266
|
+
function summarizeSessionWebhookResponse(data: unknown): string {
|
|
267
|
+
if (!data || typeof data !== "object") {
|
|
268
|
+
return `type=${typeof data}`;
|
|
269
|
+
}
|
|
270
|
+
const payload = data as Record<string, unknown>;
|
|
271
|
+
const code = extractErrorCodeFromResponseData(payload) || "(none)";
|
|
272
|
+
const message = firstTrimmedString(
|
|
273
|
+
payload.message,
|
|
274
|
+
payload.errmsg,
|
|
275
|
+
payload.msg,
|
|
276
|
+
payload.errorMessage,
|
|
277
|
+
) || "(none)";
|
|
278
|
+
const success =
|
|
279
|
+
typeof payload.success === "boolean"
|
|
280
|
+
? String(payload.success)
|
|
281
|
+
: typeof payload.result === "boolean"
|
|
282
|
+
? String(payload.result)
|
|
283
|
+
: "(none)";
|
|
284
|
+
const delivery = extractOutboundDeliveryMetadata(payload);
|
|
285
|
+
return (
|
|
286
|
+
`success=${success} code=${code} message=${message} ` +
|
|
287
|
+
`messageId=${delivery.messageId || "(none)"} ` +
|
|
288
|
+
`processQueryKey=${delivery.processQueryKey || "(none)"} ` +
|
|
289
|
+
`outTrackId=${delivery.outTrackId || "(none)"}`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function ensureSessionWebhookBusinessSuccess(
|
|
294
|
+
data: unknown,
|
|
295
|
+
context: { msgtype: string },
|
|
296
|
+
): void {
|
|
297
|
+
if (!data || typeof data !== "object") {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const payload = data as Record<string, unknown>;
|
|
301
|
+
const code = extractErrorCodeFromResponseData(payload);
|
|
302
|
+
const message = firstTrimmedString(
|
|
303
|
+
payload.message,
|
|
304
|
+
payload.errmsg,
|
|
305
|
+
payload.msg,
|
|
306
|
+
payload.errorMessage,
|
|
307
|
+
) || "unknown error";
|
|
308
|
+
|
|
309
|
+
const hasFailureSuccessFlag = payload.success === false || payload.result === false;
|
|
310
|
+
const hasFailureCode = typeof code === "string" && code !== "" && code !== "0";
|
|
311
|
+
if (!hasFailureSuccessFlag && !hasFailureCode) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const reason = [
|
|
316
|
+
code && code !== "0" ? `code=${code}` : "",
|
|
317
|
+
message !== "unknown error" ? `message=${message}` : "",
|
|
318
|
+
].filter(Boolean).join(" ");
|
|
319
|
+
throw new Error(`Session webhook ${context.msgtype} send failed${reason ? `: ${reason}` : ""}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
228
322
|
function isProactivePermissionOrScopeError(code: string | null): boolean {
|
|
229
323
|
if (!code) {
|
|
230
324
|
return false;
|
|
@@ -389,7 +483,7 @@ export async function sendProactiveMedia(
|
|
|
389
483
|
mediaPath: string,
|
|
390
484
|
mediaType: "image" | "voice" | "video" | "file",
|
|
391
485
|
options: SendMessageOptions & { accountId?: string } = {},
|
|
392
|
-
): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
|
|
486
|
+
): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string; mediaId?: string }> {
|
|
393
487
|
const log = options.log || getLogger();
|
|
394
488
|
|
|
395
489
|
try {
|
|
@@ -400,7 +494,7 @@ export async function sendProactiveMedia(
|
|
|
400
494
|
if (!uploadResult) {
|
|
401
495
|
return { ok: false, error: "Failed to upload media" };
|
|
402
496
|
}
|
|
403
|
-
const { mediaId, buffer } = uploadResult;
|
|
497
|
+
const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
|
|
404
498
|
|
|
405
499
|
const token = await getAccessToken(config, log);
|
|
406
500
|
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
@@ -421,7 +515,8 @@ export async function sendProactiveMedia(
|
|
|
421
515
|
msgParam = JSON.stringify({ photoURL: mediaId });
|
|
422
516
|
} else if (mediaType === "voice") {
|
|
423
517
|
msgKey = "sampleAudio";
|
|
424
|
-
const durationMs =
|
|
518
|
+
const durationMs = uploadedDurationMs
|
|
519
|
+
?? await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
|
|
425
520
|
msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
|
|
426
521
|
} else {
|
|
427
522
|
// sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
|
|
@@ -476,7 +571,7 @@ export async function sendProactiveMedia(
|
|
|
476
571
|
kind: "proactive-media",
|
|
477
572
|
},
|
|
478
573
|
});
|
|
479
|
-
return { ok: true, data: result.data, messageId };
|
|
574
|
+
return { ok: true, data: result.data, messageId, mediaId };
|
|
480
575
|
} catch (err: any) {
|
|
481
576
|
log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
|
|
482
577
|
const normalizedTarget = resolveOriginalPeerId(stripTargetPrefix(target).targetId);
|
|
@@ -540,6 +635,88 @@ export async function sendProactiveMedia(
|
|
|
540
635
|
}
|
|
541
636
|
}
|
|
542
637
|
|
|
638
|
+
export async function sendMedia(
|
|
639
|
+
config: DingTalkConfig,
|
|
640
|
+
target: string,
|
|
641
|
+
mediaInput: string,
|
|
642
|
+
options: SendMessageOptions & {
|
|
643
|
+
mediaType?: "image" | "voice" | "video" | "file";
|
|
644
|
+
audioAsVoice?: boolean;
|
|
645
|
+
expectedCardOwnerId?: string;
|
|
646
|
+
} = {},
|
|
647
|
+
): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string; mediaId?: string }> {
|
|
648
|
+
const log = options.log || getLogger();
|
|
649
|
+
let preparedMedia: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
|
|
653
|
+
const mediaPath = preparedMedia.cleanup
|
|
654
|
+
? preparedMedia.path
|
|
655
|
+
: path.resolve(process.cwd(), preparedMedia.path);
|
|
656
|
+
const mediaType = resolveOutboundMediaType({
|
|
657
|
+
mediaType: options.mediaType,
|
|
658
|
+
mediaPath,
|
|
659
|
+
asVoice: options.audioAsVoice === true,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
if (config.messageType === "card" && mediaType === "image") {
|
|
663
|
+
const accountId = options.accountId ?? "default";
|
|
664
|
+
|
|
665
|
+
// Three-tier lookup strategy to handle sessionKey=- from runtime:
|
|
666
|
+
// 1. If conversationId is available: try owner-filtered then conversation-only
|
|
667
|
+
// 2. If conversationId is undefined but owner is provided: try owner-only lookup
|
|
668
|
+
// 3. Otherwise: no active card found
|
|
669
|
+
let activeRun = null;
|
|
670
|
+
|
|
671
|
+
if (options.conversationId) {
|
|
672
|
+
// conversationId explicitly provided (parsed from sessionKey)
|
|
673
|
+
activeRun = options.expectedCardOwnerId
|
|
674
|
+
? resolveCardRunByConversation(accountId, options.conversationId, {
|
|
675
|
+
ownerUserId: options.expectedCardOwnerId,
|
|
676
|
+
}) ?? resolveCardRunByConversation(accountId, options.conversationId, undefined)
|
|
677
|
+
: resolveCardRunByConversation(accountId, options.conversationId, undefined);
|
|
678
|
+
} else if (options.expectedCardOwnerId) {
|
|
679
|
+
// Fallback: when sessionKey=- causes conversationId to be undefined,
|
|
680
|
+
// try owner-only matching as last resort
|
|
681
|
+
activeRun = resolveCardRunByOwner(accountId, options.expectedCardOwnerId);
|
|
682
|
+
if (activeRun) {
|
|
683
|
+
log?.debug?.(
|
|
684
|
+
`[DingTalk] Matched active card by owner-only lookup (conversationId unavailable) ` +
|
|
685
|
+
`accountId=${accountId} ownerUserId=${options.expectedCardOwnerId} outTrackId=${activeRun.outTrackId}`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const uploadResult = await uploadMedia(config, mediaPath, "image", log, {
|
|
691
|
+
mediaLocalRoots: options.mediaLocalRoots,
|
|
692
|
+
});
|
|
693
|
+
if (!uploadResult?.mediaId) {
|
|
694
|
+
return { ok: false, error: "Failed to upload media" };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const activeController = await waitForCardControllerAttachment(activeRun);
|
|
698
|
+
if (activeController?.appendImageBlock) {
|
|
699
|
+
await activeController.appendImageBlock(uploadResult.mediaId);
|
|
700
|
+
return { ok: true, mediaId: uploadResult.mediaId };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (activeRun) {
|
|
704
|
+
log?.debug?.(
|
|
705
|
+
`[DingTalk] Active card matched but controller was not attached in time; fallback to proactive media send`,
|
|
706
|
+
);
|
|
707
|
+
} else {
|
|
708
|
+
log?.debug?.(
|
|
709
|
+
`[DingTalk] No active card found for media embedding; fallback to proactive media send`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return await sendProactiveMedia(config, target, mediaPath, mediaType, options);
|
|
715
|
+
} finally {
|
|
716
|
+
await preparedMedia?.cleanup?.();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
543
720
|
export async function sendBySession(
|
|
544
721
|
config: DingTalkConfig,
|
|
545
722
|
sessionWebhook: string,
|
|
@@ -555,14 +732,18 @@ export async function sendBySession(
|
|
|
555
732
|
mediaLocalRoots: options.mediaLocalRoots,
|
|
556
733
|
});
|
|
557
734
|
if (uploadResult) {
|
|
558
|
-
const { mediaId, buffer } = uploadResult;
|
|
735
|
+
const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
|
|
559
736
|
let body: any;
|
|
560
737
|
|
|
561
738
|
if (options.mediaType === "image") {
|
|
562
739
|
body = { msgtype: "image", image: { media_id: mediaId } };
|
|
563
740
|
} else if (options.mediaType === "voice") {
|
|
564
|
-
const durationMs =
|
|
741
|
+
const durationMs = uploadedDurationMs
|
|
742
|
+
?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
|
|
565
743
|
body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
|
|
744
|
+
log?.debug?.(
|
|
745
|
+
`[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`,
|
|
746
|
+
);
|
|
566
747
|
} else if (options.mediaType === "video") {
|
|
567
748
|
body = { msgtype: "video", video: { media_id: mediaId } };
|
|
568
749
|
} else if (options.mediaType === "file") {
|
|
@@ -577,6 +758,17 @@ export async function sendBySession(
|
|
|
577
758
|
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
578
759
|
...getProxyBypassOption(config),
|
|
579
760
|
});
|
|
761
|
+
log?.debug?.(
|
|
762
|
+
`[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
|
|
763
|
+
);
|
|
764
|
+
ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
|
|
765
|
+
const delivery = extractOutboundDeliveryMetadata(result.data);
|
|
766
|
+
if (!delivery.messageId && !delivery.processQueryKey && !delivery.outTrackId) {
|
|
767
|
+
log?.warn?.(
|
|
768
|
+
`[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` +
|
|
769
|
+
summarizeSessionWebhookResponse(result.data),
|
|
770
|
+
);
|
|
771
|
+
}
|
|
580
772
|
return result.data;
|
|
581
773
|
}
|
|
582
774
|
} else {
|
|
@@ -624,6 +816,10 @@ export async function sendBySession(
|
|
|
624
816
|
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
625
817
|
...getProxyBypassOption(config),
|
|
626
818
|
});
|
|
819
|
+
log?.debug?.(
|
|
820
|
+
`[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
|
|
821
|
+
);
|
|
822
|
+
ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
|
|
627
823
|
lastResult = result.data;
|
|
628
824
|
}
|
|
629
825
|
return lastResult;
|
|
@@ -662,6 +858,28 @@ export async function sendMessage(
|
|
|
662
858
|
}
|
|
663
859
|
}
|
|
664
860
|
|
|
861
|
+
if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
|
|
862
|
+
log?.debug?.(
|
|
863
|
+
"[DingTalk] Session webhook does not support voice replies reliably; " +
|
|
864
|
+
"using proactive media API for this voice response",
|
|
865
|
+
);
|
|
866
|
+
const proactiveVoiceResult = await sendProactiveMedia(
|
|
867
|
+
config,
|
|
868
|
+
conversationId,
|
|
869
|
+
options.mediaPath,
|
|
870
|
+
options.mediaType,
|
|
871
|
+
options,
|
|
872
|
+
);
|
|
873
|
+
if (!proactiveVoiceResult.ok) {
|
|
874
|
+
return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
ok: true,
|
|
878
|
+
data: proactiveVoiceResult.data,
|
|
879
|
+
messageId: proactiveVoiceResult.messageId,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
665
883
|
if (options.sessionWebhook) {
|
|
666
884
|
const data = await sendBySession(config, options.sessionWebhook, text, options);
|
|
667
885
|
const delivery = extractOutboundDeliveryMetadata(data);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
interface SessionState {
|
|
2
|
+
model?: string;
|
|
3
|
+
effort?: string;
|
|
4
|
+
taskStartTime: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const sessionStore = new Map<string, SessionState>();
|
|
8
|
+
|
|
9
|
+
function sessionKey(accountId: string, conversationId: string): string {
|
|
10
|
+
return `${accountId}:${conversationId}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function initSessionState(accountId: string, conversationId: string): SessionState {
|
|
14
|
+
const key = sessionKey(accountId, conversationId);
|
|
15
|
+
const existing = sessionStore.get(key);
|
|
16
|
+
if (existing) {
|
|
17
|
+
existing.taskStartTime = Date.now();
|
|
18
|
+
return existing;
|
|
19
|
+
}
|
|
20
|
+
const state: SessionState = {
|
|
21
|
+
taskStartTime: Date.now(),
|
|
22
|
+
};
|
|
23
|
+
sessionStore.set(key, state);
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getSessionState(accountId: string, conversationId: string): SessionState | undefined {
|
|
28
|
+
return sessionStore.get(sessionKey(accountId, conversationId));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function updateSessionState(
|
|
32
|
+
accountId: string,
|
|
33
|
+
conversationId: string,
|
|
34
|
+
patch: Partial<Pick<SessionState, "model" | "effort">>,
|
|
35
|
+
): void {
|
|
36
|
+
const state = sessionStore.get(sessionKey(accountId, conversationId));
|
|
37
|
+
if (!state) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (patch.model !== undefined) {
|
|
41
|
+
state.model = patch.model;
|
|
42
|
+
}
|
|
43
|
+
if (patch.effort !== undefined) {
|
|
44
|
+
state.effort = patch.effort;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getTaskTimeSeconds(accountId: string, conversationId: string): number | undefined {
|
|
49
|
+
const state = sessionStore.get(sessionKey(accountId, conversationId));
|
|
50
|
+
if (!state) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return Math.round((Date.now() - state.taskStartTime) / 1000);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function clearSessionState(accountId: string, conversationId: string): void {
|
|
57
|
+
sessionStore.delete(sessionKey(accountId, conversationId));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function clearAllSessionStatesForTest(): void {
|
|
61
|
+
sessionStore.clear();
|
|
62
|
+
}
|
|
@@ -146,3 +146,31 @@ export function resolveAtAgents(
|
|
|
146
146
|
hasInvalidAgentNames,
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get agent display name for statusLine in card template.
|
|
152
|
+
*
|
|
153
|
+
* Priority:
|
|
154
|
+
* 1. subAgentOptions.matchedName - user-friendly name from @mention (e.g., "代码专家")
|
|
155
|
+
* 2. agents.list lookup - name field from agent config
|
|
156
|
+
* 3. agentId fallback - technical identifier
|
|
157
|
+
*/
|
|
158
|
+
export function getAgentDisplayName(params: {
|
|
159
|
+
subAgentOptions?: { matchedName?: string };
|
|
160
|
+
agentId: string;
|
|
161
|
+
agentsList?: Array<{ id: string; name?: string }>;
|
|
162
|
+
}): string {
|
|
163
|
+
// Priority 1: sub-agent matchedName (user-friendly)
|
|
164
|
+
if (params.subAgentOptions?.matchedName) {
|
|
165
|
+
return params.subAgentOptions.matchedName;
|
|
166
|
+
}
|
|
167
|
+
// Priority 2: lookup from agents.list
|
|
168
|
+
if (params.agentsList) {
|
|
169
|
+
const agent = params.agentsList.find((a) => a.id === params.agentId);
|
|
170
|
+
if (agent?.name) {
|
|
171
|
+
return agent.name;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Priority 3: fallback to agentId
|
|
175
|
+
return params.agentId;
|
|
176
|
+
}
|