@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
|
@@ -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,82 +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
|
-
type
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
mediaUrls: string[];
|
|
23
|
-
/**
|
|
24
|
-
* Shared reply-runtime voice hint. Strategies forward this unchanged into the
|
|
25
|
-
* channel media delivery helper; inbound-handler is responsible for bridging
|
|
26
|
-
* legacy aliases (for example `asVoice`) into this single field.
|
|
27
|
-
*/
|
|
28
|
-
audioAsVoice?: boolean;
|
|
29
|
-
kind: "block" | "final" | "tool";
|
|
30
|
-
isReasoning?: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ReplyOptions {
|
|
34
|
-
disableBlockStreaming: boolean;
|
|
35
|
-
onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
|
|
36
|
-
onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
|
|
37
|
-
onAssistantMessageStart?: () => void | Promise<void>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ReplyStrategy {
|
|
41
|
-
/** Options forwarded to the runtime dispatcher. */
|
|
42
|
-
getReplyOptions(): ReplyOptions;
|
|
43
|
-
|
|
44
|
-
/** Called by the deliver callback for each payload chunk. */
|
|
45
|
-
deliver(payload: DeliverPayload): Promise<void>;
|
|
46
|
-
|
|
47
|
-
/** Called after dispatch completes successfully. */
|
|
48
|
-
finalize(): Promise<void>;
|
|
49
|
-
|
|
50
|
-
/** Called when dispatch throws an error. */
|
|
51
|
-
abort(error: Error): Promise<void>;
|
|
52
|
-
|
|
53
|
-
/** Last known final text (for external consumers such as logging). */
|
|
54
|
-
getFinalText(): string | undefined;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Shared context passed to every strategy implementation. */
|
|
58
|
-
export interface ReplyStrategyContext {
|
|
59
|
-
config: InternalReplyStrategyConfig;
|
|
60
|
-
to: string;
|
|
61
|
-
sessionWebhook: string;
|
|
62
|
-
senderId: string;
|
|
63
|
-
isDirect: boolean;
|
|
64
|
-
accountId: string;
|
|
65
|
-
storePath: string;
|
|
66
|
-
disableBlockStreaming?: boolean;
|
|
67
|
-
sessionKey?: string;
|
|
68
|
-
sessionAgentId?: string;
|
|
69
|
-
groupId?: string;
|
|
70
|
-
log?: Logger;
|
|
71
|
-
replyQuotedRef?: QuotedRef;
|
|
72
|
-
/**
|
|
73
|
-
* Channel-level media delivery hook. The `audioAsVoice` option is the same
|
|
74
|
-
* shared voice semantic carried on DeliverPayload, not a second independent
|
|
75
|
-
* config knob.
|
|
76
|
-
*/
|
|
77
|
-
deliverMedia: (urls: string[], options?: { audioAsVoice?: boolean }) => Promise<void>;
|
|
78
|
-
isStopRequested?: () => boolean;
|
|
79
|
-
}
|
|
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";
|
|
80
22
|
|
|
81
23
|
// ---- Factory -----------------------------------------------------
|
|
82
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) {
|
|
@@ -453,7 +483,7 @@ export async function sendProactiveMedia(
|
|
|
453
483
|
mediaPath: string,
|
|
454
484
|
mediaType: "image" | "voice" | "video" | "file",
|
|
455
485
|
options: SendMessageOptions & { accountId?: string } = {},
|
|
456
|
-
): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
|
|
486
|
+
): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string; mediaId?: string }> {
|
|
457
487
|
const log = options.log || getLogger();
|
|
458
488
|
|
|
459
489
|
try {
|
|
@@ -541,7 +571,7 @@ export async function sendProactiveMedia(
|
|
|
541
571
|
kind: "proactive-media",
|
|
542
572
|
},
|
|
543
573
|
});
|
|
544
|
-
return { ok: true, data: result.data, messageId };
|
|
574
|
+
return { ok: true, data: result.data, messageId, mediaId };
|
|
545
575
|
} catch (err: any) {
|
|
546
576
|
log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
|
|
547
577
|
const normalizedTarget = resolveOriginalPeerId(stripTargetPrefix(target).targetId);
|
|
@@ -605,6 +635,88 @@ export async function sendProactiveMedia(
|
|
|
605
635
|
}
|
|
606
636
|
}
|
|
607
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
|
+
|
|
608
720
|
export async function sendBySession(
|
|
609
721
|
config: DingTalkConfig,
|
|
610
722
|
sessionWebhook: string,
|
|
@@ -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
|
+
}
|