@soimy/dingtalk 3.5.3 → 3.6.1
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 +4 -1
- package/index.ts +7 -0
- package/openclaw.plugin.json +153 -5
- package/package.json +1 -1
- package/src/auth.ts +5 -2
- 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 +408 -23
- package/src/channel.ts +24 -1083
- package/src/config-schema.ts +21 -1
- package/src/config.ts +139 -66
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +637 -0
- package/src/inbound-handler.ts +1276 -975
- package/src/media-utils.ts +6 -0
- package/src/message-context-store.ts +183 -85
- package/src/message-utils.ts +124 -16
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +174 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/onboarding.ts +333 -235
- package/src/path-utils.ts +49 -0
- 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/secret-input.ts +216 -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/targeting/agent-routing.ts +30 -5
- package/src/types.ts +48 -157
package/README.md
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
- 支持私聊、群聊和 @机器人
|
|
21
21
|
- 支持文本、图片、语音、视频、文件和钉钉文档/文件卡片
|
|
22
22
|
- 支持引用消息恢复和常见文本附件正文抽取
|
|
23
|
-
- 支持 Markdown 回复与 AI
|
|
23
|
+
- 支持 Markdown 回复与 AI 卡片流式回复(v2 结构化 block 渲染、taskInfo 元数据、图片内联)
|
|
24
24
|
- 支持多 Agent、多机器人绑定和实验性的 `@多助手路由`
|
|
25
|
+
- 支持 `/btw` 旁路问答,绕过主会话锁立即获得独立快答
|
|
26
|
+
- 支持 DingTalk Device Flow 自动注册,扫码授权后自动获取凭证,无需手动复制
|
|
25
27
|
- 支持实时中止当前 AI generation。常用停止指令包括 `停止`、`stop`、`/stop`、`esc` 等
|
|
26
28
|
- 接入 OpenClaw 消息处理与 outbound 能力
|
|
27
29
|
|
|
@@ -141,6 +143,7 @@ openclaw configure --section channels
|
|
|
141
143
|
- [消息类型支持](docs/user/features/message-types.md)
|
|
142
144
|
- [回复模式](docs/user/features/reply-modes.md)
|
|
143
145
|
- [AI 卡片](docs/user/features/ai-card.md)
|
|
146
|
+
- [/btw 旁路问答](docs/user/features/btw.md)
|
|
144
147
|
- [钉钉文档 API](docs/user/features/dingtalk-docs-api.md)
|
|
145
148
|
- [反馈学习](docs/user/features/feedback-learning.md)
|
|
146
149
|
- [多 Agent 与多机器人绑定](docs/user/features/multi-agent-bindings.md)
|
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
|
|
|
3
3
|
import { dingtalkPlugin } from "./src/channel";
|
|
4
4
|
import { getConfig } from "./src/config";
|
|
5
5
|
import { appendToDoc, createDoc, DocCreateAppendError, listDocs, searchDocs } from "./src/docs-service";
|
|
6
|
+
import { accumulateUsage } from "./src/run-usage-store";
|
|
6
7
|
import { setDingTalkRuntime } from "./src/runtime";
|
|
7
8
|
|
|
8
9
|
type GatewayMethodContext = Pick<
|
|
@@ -81,5 +82,11 @@ export default defineChannelPluginEntry({
|
|
|
81
82
|
setRuntime: setDingTalkRuntime,
|
|
82
83
|
registerFull(api) {
|
|
83
84
|
registerDingTalkDocsGatewayMethods(api);
|
|
85
|
+
|
|
86
|
+
api.on("llm_output", (event: { runId: string; usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } }) => {
|
|
87
|
+
if (event.usage) {
|
|
88
|
+
accumulateUsage(event.runId, event.usage);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
84
91
|
},
|
|
85
92
|
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -36,8 +36,30 @@
|
|
|
36
36
|
"description": "DingTalk App Key (Client ID) used to authenticate API and Stream connections."
|
|
37
37
|
},
|
|
38
38
|
"clientSecret": {
|
|
39
|
-
"
|
|
40
|
-
|
|
39
|
+
"anyOf": [
|
|
40
|
+
{ "type": "string" },
|
|
41
|
+
{
|
|
42
|
+
"type": "object",
|
|
43
|
+
"additionalProperties": false,
|
|
44
|
+
"required": ["source", "provider", "id"],
|
|
45
|
+
"properties": {
|
|
46
|
+
"source": { "type": "string", "enum": ["env", "file", "exec"] },
|
|
47
|
+
"provider": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"minLength": 1,
|
|
50
|
+
"maxLength": 1024,
|
|
51
|
+
"pattern": "^[^:>]+$"
|
|
52
|
+
},
|
|
53
|
+
"id": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"minLength": 1,
|
|
56
|
+
"maxLength": 1024,
|
|
57
|
+
"pattern": "^[^>]+$"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"description": "DingTalk App Secret (Client Secret) used to obtain DingTalk access tokens. Supports SecretInput references such as env/file/exec. Security boundary: file reads the configured local path, and exec runs the configured provider binary with id as its only argument; use file/exec only with trusted plugin configuration."
|
|
41
63
|
},
|
|
42
64
|
"dmPolicy": {
|
|
43
65
|
"type": "string",
|
|
@@ -253,6 +275,44 @@
|
|
|
253
275
|
"type": "string",
|
|
254
276
|
"description": "Send a follow-up @mention text after card finalization in group chats. Any non-empty string becomes the message body."
|
|
255
277
|
},
|
|
278
|
+
"cardStatusLine": {
|
|
279
|
+
"type": "object",
|
|
280
|
+
"description": "Status line visibility toggles for the AI card footer.",
|
|
281
|
+
"additionalProperties": false,
|
|
282
|
+
"default": { "model": true, "effort": true, "agent": true, "taskTime": false, "tokens": false, "dapiUsage": false },
|
|
283
|
+
"properties": {
|
|
284
|
+
"model": {
|
|
285
|
+
"type": "boolean",
|
|
286
|
+
"default": true,
|
|
287
|
+
"description": "Show model name in AI card status line footer."
|
|
288
|
+
},
|
|
289
|
+
"effort": {
|
|
290
|
+
"type": "boolean",
|
|
291
|
+
"default": true,
|
|
292
|
+
"description": "Show thinking effort level in AI card status line footer."
|
|
293
|
+
},
|
|
294
|
+
"agent": {
|
|
295
|
+
"type": "boolean",
|
|
296
|
+
"default": true,
|
|
297
|
+
"description": "Show agent display name in AI card status line footer."
|
|
298
|
+
},
|
|
299
|
+
"taskTime": {
|
|
300
|
+
"type": "boolean",
|
|
301
|
+
"default": false,
|
|
302
|
+
"description": "Show task elapsed time in AI card status line footer."
|
|
303
|
+
},
|
|
304
|
+
"tokens": {
|
|
305
|
+
"type": "boolean",
|
|
306
|
+
"default": false,
|
|
307
|
+
"description": "Show token usage summary (input/output/cache) in AI card status line footer."
|
|
308
|
+
},
|
|
309
|
+
"dapiUsage": {
|
|
310
|
+
"type": "boolean",
|
|
311
|
+
"default": false,
|
|
312
|
+
"description": "Show DingTalk API call count in AI card status line footer."
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
},
|
|
256
316
|
"showThinkingStream": {
|
|
257
317
|
"type": "boolean",
|
|
258
318
|
"description": "Legacy compatibility field from older card streaming behavior. It is not used by the current runtime and new configs should omit it."
|
|
@@ -282,8 +342,30 @@
|
|
|
282
342
|
"description": "DingTalk App Key (Client ID) used to authenticate API and Stream connections."
|
|
283
343
|
},
|
|
284
344
|
"clientSecret": {
|
|
285
|
-
"
|
|
286
|
-
|
|
345
|
+
"anyOf": [
|
|
346
|
+
{ "type": "string" },
|
|
347
|
+
{
|
|
348
|
+
"type": "object",
|
|
349
|
+
"additionalProperties": false,
|
|
350
|
+
"required": ["source", "provider", "id"],
|
|
351
|
+
"properties": {
|
|
352
|
+
"source": { "type": "string", "enum": ["env", "file", "exec"] },
|
|
353
|
+
"provider": {
|
|
354
|
+
"type": "string",
|
|
355
|
+
"minLength": 1,
|
|
356
|
+
"maxLength": 1024,
|
|
357
|
+
"pattern": "^[^:>]+$"
|
|
358
|
+
},
|
|
359
|
+
"id": {
|
|
360
|
+
"type": "string",
|
|
361
|
+
"minLength": 1,
|
|
362
|
+
"maxLength": 1024,
|
|
363
|
+
"pattern": "^[^>]+$"
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
],
|
|
368
|
+
"description": "DingTalk App Secret (Client Secret) used to obtain DingTalk access tokens. Supports SecretInput references such as env/file/exec. Security boundary: file reads the configured local path, and exec runs the configured provider binary with id as its only argument; use file/exec only with trusted plugin configuration."
|
|
287
369
|
},
|
|
288
370
|
"dmPolicy": {
|
|
289
371
|
"type": "string",
|
|
@@ -498,6 +580,44 @@
|
|
|
498
580
|
"cardAtSender": {
|
|
499
581
|
"type": "string",
|
|
500
582
|
"description": "Send a follow-up @mention text after card finalization in group chats. Any non-empty string becomes the message body."
|
|
583
|
+
},
|
|
584
|
+
"cardStatusLine": {
|
|
585
|
+
"type": "object",
|
|
586
|
+
"description": "Status line visibility toggles for the AI card footer.",
|
|
587
|
+
"additionalProperties": false,
|
|
588
|
+
"default": { "model": true, "effort": true, "agent": true, "taskTime": false, "tokens": false, "dapiUsage": false },
|
|
589
|
+
"properties": {
|
|
590
|
+
"model": {
|
|
591
|
+
"type": "boolean",
|
|
592
|
+
"default": true,
|
|
593
|
+
"description": "Show model name in AI card status line footer."
|
|
594
|
+
},
|
|
595
|
+
"effort": {
|
|
596
|
+
"type": "boolean",
|
|
597
|
+
"default": true,
|
|
598
|
+
"description": "Show thinking effort level in AI card status line footer."
|
|
599
|
+
},
|
|
600
|
+
"agent": {
|
|
601
|
+
"type": "boolean",
|
|
602
|
+
"default": true,
|
|
603
|
+
"description": "Show agent display name in AI card status line footer."
|
|
604
|
+
},
|
|
605
|
+
"taskTime": {
|
|
606
|
+
"type": "boolean",
|
|
607
|
+
"default": false,
|
|
608
|
+
"description": "Show task elapsed time in AI card status line footer."
|
|
609
|
+
},
|
|
610
|
+
"tokens": {
|
|
611
|
+
"type": "boolean",
|
|
612
|
+
"default": false,
|
|
613
|
+
"description": "Show token usage summary (input/output/cache) in AI card status line footer."
|
|
614
|
+
},
|
|
615
|
+
"dapiUsage": {
|
|
616
|
+
"type": "boolean",
|
|
617
|
+
"default": false,
|
|
618
|
+
"description": "Show DingTalk API call count in AI card status line footer."
|
|
619
|
+
}
|
|
620
|
+
}
|
|
501
621
|
}
|
|
502
622
|
}
|
|
503
623
|
}
|
|
@@ -531,7 +651,7 @@
|
|
|
531
651
|
},
|
|
532
652
|
"clientSecret": {
|
|
533
653
|
"label": "DingTalk Client Secret",
|
|
534
|
-
"help": "App Secret used to obtain DingTalk access tokens.",
|
|
654
|
+
"help": "App Secret used to obtain DingTalk access tokens. SecretInput file/exec references are for trusted plugin configuration because file reads a local path and exec runs the configured provider binary.",
|
|
535
655
|
"sensitive": true
|
|
536
656
|
},
|
|
537
657
|
"dmPolicy": {
|
|
@@ -678,6 +798,34 @@
|
|
|
678
798
|
"label": "Card Mention Follow-Up",
|
|
679
799
|
"help": "Send a follow-up @mention text after card finalization in group chats. Any non-empty string becomes the message body."
|
|
680
800
|
},
|
|
801
|
+
"cardStatusLine": {
|
|
802
|
+
"label": "Status Line",
|
|
803
|
+
"help": "Configure which information segments appear in the AI card footer status line."
|
|
804
|
+
},
|
|
805
|
+
"cardStatusLine.model": {
|
|
806
|
+
"label": "Model Name",
|
|
807
|
+
"help": "Show the LLM model name in the AI card footer status line."
|
|
808
|
+
},
|
|
809
|
+
"cardStatusLine.effort": {
|
|
810
|
+
"label": "Thinking Effort",
|
|
811
|
+
"help": "Show the thinking effort level (e.g. high, medium) in the AI card footer."
|
|
812
|
+
},
|
|
813
|
+
"cardStatusLine.agent": {
|
|
814
|
+
"label": "Agent Name",
|
|
815
|
+
"help": "Show the agent display name in the AI card footer."
|
|
816
|
+
},
|
|
817
|
+
"cardStatusLine.taskTime": {
|
|
818
|
+
"label": "Task Time",
|
|
819
|
+
"help": "Show elapsed task time in the AI card footer."
|
|
820
|
+
},
|
|
821
|
+
"cardStatusLine.tokens": {
|
|
822
|
+
"label": "Token Usage",
|
|
823
|
+
"help": "Show a compact token usage summary (input/output/cache) in the AI card footer."
|
|
824
|
+
},
|
|
825
|
+
"cardStatusLine.dapiUsage": {
|
|
826
|
+
"label": "API Calls",
|
|
827
|
+
"help": "Show the DingTalk API call count in the AI card footer."
|
|
828
|
+
},
|
|
681
829
|
"showThinkingStream": {
|
|
682
830
|
"label": "Legacy Show Thinking Stream",
|
|
683
831
|
"help": "Deprecated compatibility field from older card streaming behavior. The current runtime ignores it."
|
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveRuntimeConfig } from "./config";
|
|
1
2
|
import axios from "./http-client";
|
|
2
3
|
import type { DingTalkConfig, Logger, TokenInfo } from "./types";
|
|
3
4
|
import { retryWithBackoff } from "./utils";
|
|
@@ -23,13 +24,15 @@ export async function getAccessToken(config: DingTalkConfig, log?: Logger): Prom
|
|
|
23
24
|
return cached.accessToken;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
const runtimeConfig = await resolveRuntimeConfig(config, log);
|
|
28
|
+
|
|
26
29
|
const token = await retryWithBackoff(
|
|
27
30
|
async () => {
|
|
28
31
|
const response = await axios.post<TokenInfo>(
|
|
29
32
|
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
|
|
30
33
|
{
|
|
31
|
-
appKey:
|
|
32
|
-
appSecret:
|
|
34
|
+
appKey: runtimeConfig.clientId,
|
|
35
|
+
appSecret: runtimeConfig.clientSecret,
|
|
33
36
|
},
|
|
34
37
|
);
|
|
35
38
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type MarkdownImageUrlClassification = "public" | "local" | "unsupported";
|
|
2
|
+
|
|
3
|
+
export interface MarkdownImageCandidate {
|
|
4
|
+
alt: string;
|
|
5
|
+
url: string;
|
|
6
|
+
raw: string;
|
|
7
|
+
classification: MarkdownImageUrlClassification;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
13
|
+
const PRIVATE_HOST_RE = /^(?:localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[0-1])\.\d+\.\d+)$/;
|
|
14
|
+
|
|
15
|
+
function isLikelyPlainRelativePath(url: string): boolean {
|
|
16
|
+
return !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url) && !url.startsWith("//");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isLikelyLocalPath(url: string): boolean {
|
|
20
|
+
return url.startsWith("./") || url.startsWith("../") || url.startsWith("/") || isLikelyPlainRelativePath(url);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeFileNameFromUrl(url: string): string {
|
|
24
|
+
const trimmed = url.trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (trimmed.startsWith("file://")) {
|
|
30
|
+
const withoutScheme = trimmed.slice("file://".length);
|
|
31
|
+
const segments = withoutScheme.split("/").filter(Boolean);
|
|
32
|
+
return segments.at(-1) ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (isLikelyLocalPath(trimmed)) {
|
|
36
|
+
const segments = trimmed.split(/[\\/]/).filter(Boolean);
|
|
37
|
+
return segments.at(-1) ?? "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const parsed = new URL(trimmed);
|
|
42
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
43
|
+
return segments.at(-1) ?? "";
|
|
44
|
+
} catch {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyMarkdownImageUrl(url: string): MarkdownImageUrlClassification {
|
|
50
|
+
const trimmed = url.trim();
|
|
51
|
+
if (!trimmed) {
|
|
52
|
+
return "unsupported";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (trimmed.startsWith("file://") || isLikelyLocalPath(trimmed)) {
|
|
56
|
+
return "local";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(trimmed);
|
|
61
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
62
|
+
return "unsupported";
|
|
63
|
+
}
|
|
64
|
+
if (PRIVATE_HOST_RE.test(parsed.hostname)) {
|
|
65
|
+
return "local";
|
|
66
|
+
}
|
|
67
|
+
return "public";
|
|
68
|
+
} catch {
|
|
69
|
+
return "unsupported";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildImagePlaceholderText(input: { alt: string; url: string }): string {
|
|
74
|
+
const alt = input.alt.trim();
|
|
75
|
+
if (alt) {
|
|
76
|
+
return `见下图${alt}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fileName = safeFileNameFromUrl(input.url);
|
|
80
|
+
if (fileName) {
|
|
81
|
+
return `见下图${fileName}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return "见下图图片";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function extractMarkdownImageCandidates(text: string): MarkdownImageCandidate[] {
|
|
88
|
+
const candidates: MarkdownImageCandidate[] = [];
|
|
89
|
+
|
|
90
|
+
for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) {
|
|
91
|
+
const raw = match[0] ?? "";
|
|
92
|
+
const alt = match[1] ?? "";
|
|
93
|
+
const url = match[2] ?? "";
|
|
94
|
+
const start = match.index ?? 0;
|
|
95
|
+
candidates.push({
|
|
96
|
+
alt,
|
|
97
|
+
url,
|
|
98
|
+
raw,
|
|
99
|
+
classification: classifyMarkdownImageUrl(url),
|
|
100
|
+
start,
|
|
101
|
+
end: start + raw.length,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return candidates;
|
|
106
|
+
}
|
|
@@ -61,6 +61,8 @@ export function registerCardRun(
|
|
|
61
61
|
agentId: string;
|
|
62
62
|
ownerUserId?: string;
|
|
63
63
|
card?: AICardInstance;
|
|
64
|
+
/** Override registeredAt timestamp (useful for tests). */
|
|
65
|
+
registeredAt?: number;
|
|
64
66
|
},
|
|
65
67
|
): void {
|
|
66
68
|
const trimmed = outTrackId.trim();
|
|
@@ -74,7 +76,7 @@ export function registerCardRun(
|
|
|
74
76
|
agentId: params.agentId,
|
|
75
77
|
ownerUserId: params.ownerUserId,
|
|
76
78
|
card: params.card,
|
|
77
|
-
registeredAt: Date.now(),
|
|
79
|
+
registeredAt: params.registeredAt ?? Date.now(),
|
|
78
80
|
});
|
|
79
81
|
ensureSweepTimer();
|
|
80
82
|
}
|
|
@@ -90,6 +92,57 @@ export function resolveCardRun(outTrackId: string): CardRunRecord | null {
|
|
|
90
92
|
return records.get(outTrackId.trim()) ?? null;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Find the most recently registered card run for a given account + conversation.
|
|
97
|
+
* Uses case-insensitive match of the conversationId within sessionKey.
|
|
98
|
+
*
|
|
99
|
+
* @param accountId - The DingTalk account ID
|
|
100
|
+
* @param conversationId - The target conversation ID (matches within sessionKey)
|
|
101
|
+
* @param options - Optional filtering criteria
|
|
102
|
+
* @param options.ownerUserId - If provided, only return runs owned by this user
|
|
103
|
+
*/
|
|
104
|
+
export function resolveCardRunByConversation(
|
|
105
|
+
accountId: string,
|
|
106
|
+
conversationId: string,
|
|
107
|
+
options?: { ownerUserId?: string },
|
|
108
|
+
): CardRunRecord | null {
|
|
109
|
+
const lowerCid = conversationId.toLowerCase();
|
|
110
|
+
const targetOwner = options?.ownerUserId;
|
|
111
|
+
let latest: CardRunRecord | null = null;
|
|
112
|
+
for (const record of records.values()) {
|
|
113
|
+
if (record.accountId !== accountId) { continue; }
|
|
114
|
+
if (!record.sessionKey.toLowerCase().includes(lowerCid)) { continue; }
|
|
115
|
+
// If ownerUserId filter is specified, only match runs owned by that user
|
|
116
|
+
if (targetOwner !== undefined && record.ownerUserId !== targetOwner) { continue; }
|
|
117
|
+
if (!latest || record.registeredAt > latest.registeredAt) {
|
|
118
|
+
latest = record;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return latest;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find the most recently registered card run for a given account + owner.
|
|
126
|
+
* This is a fallback query when conversationId is unavailable (e.g., sessionKey=-).
|
|
127
|
+
*
|
|
128
|
+
* @param accountId - The DingTalk account ID
|
|
129
|
+
* @param ownerUserId - The DingTalk userId of the card owner
|
|
130
|
+
*/
|
|
131
|
+
export function resolveCardRunByOwner(
|
|
132
|
+
accountId: string,
|
|
133
|
+
ownerUserId: string,
|
|
134
|
+
): CardRunRecord | null {
|
|
135
|
+
let latest: CardRunRecord | null = null;
|
|
136
|
+
for (const record of records.values()) {
|
|
137
|
+
if (record.accountId !== accountId) { continue; }
|
|
138
|
+
if (record.ownerUserId !== ownerUserId) { continue; }
|
|
139
|
+
if (!latest || record.registeredAt > latest.registeredAt) {
|
|
140
|
+
latest = record;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return latest;
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
export function markCardRunStopRequested(outTrackId: string): void {
|
|
94
147
|
const record = records.get(outTrackId.trim());
|
|
95
148
|
if (record && !record.stopRequestedAt) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
-
import {
|
|
3
|
-
import { finishStoppedAICard, hideCardStopButton } from "../card-service";
|
|
2
|
+
import { finalizeStoppedAICard } from "../card-service";
|
|
4
3
|
import { dispatchDingTalkCardStopCommand } from "../command/card-stop-command";
|
|
5
4
|
import type { DingTalkConfig, Logger } from "../types";
|
|
6
5
|
import { AICardStatus } from "../types";
|
|
@@ -31,7 +30,9 @@ export async function stopCardRun(params: {
|
|
|
31
30
|
return { ok: true, status: "already-stopped", lastContent: record.card?.lastStreamedContent };
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
const lastContent = record.card?.lastStreamedContent;
|
|
33
|
+
const lastContent = record.controller?.getRenderedContent?.() || record.card?.lastStreamedContent;
|
|
34
|
+
const lastBlockListJson =
|
|
35
|
+
record.controller?.getRenderedBlocks?.() || record.card?.lastBlockListJson;
|
|
35
36
|
|
|
36
37
|
markCardRunStopRequested(params.outTrackId);
|
|
37
38
|
record.controller?.stop();
|
|
@@ -64,13 +65,14 @@ export async function stopCardRun(params: {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
// --- Phase 2: Finalize card via
|
|
68
|
+
// --- Phase 2: Finalize card via instances API so V2 blockList/content stay consistent ---
|
|
68
69
|
if (record.card) {
|
|
69
|
-
const stoppedContent = lastContent
|
|
70
|
-
? `${lastContent}\n\n---\n*⏹️ 已停止*`
|
|
71
|
-
: "⏹️ 已停止";
|
|
72
70
|
try {
|
|
73
|
-
await
|
|
71
|
+
await finalizeStoppedAICard(record.card, {
|
|
72
|
+
reason: "⏹️ 已停止",
|
|
73
|
+
previousContent: lastContent,
|
|
74
|
+
previousBlockListJson: lastBlockListJson,
|
|
75
|
+
}, params.log);
|
|
74
76
|
} catch (error) {
|
|
75
77
|
params.log?.warn?.(
|
|
76
78
|
`[${params.accountId}] [DingTalk][CardStop] failed to finalize stopped card: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -78,17 +80,5 @@ export async function stopCardRun(params: {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
// --- Phase 3: Hide stop button (with retry, consistent with finishAICard path) ---
|
|
82
|
-
if (params.config) {
|
|
83
|
-
try {
|
|
84
|
-
const token = await getAccessToken(params.config, params.log);
|
|
85
|
-
await hideCardStopButton(params.outTrackId, token, params.config);
|
|
86
|
-
} catch (error) {
|
|
87
|
-
params.log?.debug?.(
|
|
88
|
-
`[${params.accountId}] [DingTalk][CardStop] non-critical: failed to hide stop button: ${error instanceof Error ? error.message : String(error)}`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
83
|
return { ok: true, status: nativeStopStatus ?? "stopped-pending", lastContent };
|
|
94
84
|
}
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
/** Card variable value that shows the stop button. */
|
|
2
|
-
export const STOP_ACTION_VISIBLE =
|
|
2
|
+
export const STOP_ACTION_VISIBLE = true;
|
|
3
3
|
/** Card variable value that hides the stop button. */
|
|
4
|
-
export const STOP_ACTION_HIDDEN =
|
|
4
|
+
export const STOP_ACTION_HIDDEN = false;
|
|
5
5
|
|
|
6
6
|
export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID =
|
|
7
|
-
process.env.DINGTALK_CARD_TEMPLATE_ID || "
|
|
7
|
+
process.env.DINGTALK_CARD_TEMPLATE_ID || "675cde2f-f526-40cb-b828-f5b2b57b8b77.schema";
|
|
8
8
|
export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
|
|
9
|
+
export const BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
|
|
10
|
+
export const BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
|
|
9
11
|
|
|
10
12
|
export interface DingTalkCardTemplateContract {
|
|
11
13
|
templateId: string;
|
|
12
14
|
contentKey: string;
|
|
15
|
+
/** V2: key for the streaming markdown field (same as contentKey). */
|
|
16
|
+
streamingKey: string;
|
|
17
|
+
/** V2: key for the block-list loopArray variable. */
|
|
18
|
+
blockListKey: string;
|
|
19
|
+
/** V2: key for the plain-text copy content variable (String type for card copy action). */
|
|
20
|
+
copyContentKey: string;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/** Frozen singleton — no allocation on every call. */
|
|
16
24
|
export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
|
|
17
25
|
templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
|
|
18
26
|
contentKey: BUILTIN_DINGTALK_CARD_CONTENT_KEY,
|
|
27
|
+
streamingKey: BUILTIN_DINGTALK_CARD_CONTENT_KEY,
|
|
28
|
+
blockListKey: BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY,
|
|
29
|
+
copyContentKey: BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY,
|
|
19
30
|
});
|
|
20
31
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export function formatTokenCount(n: number): string {
|
|
2
|
+
if (n >= 1_000_000) {
|
|
3
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
4
|
+
}
|
|
5
|
+
if (n >= 1_000) {
|
|
6
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
7
|
+
}
|
|
8
|
+
return String(n);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatDuration(seconds: number): string {
|
|
12
|
+
if (seconds < 60) {
|
|
13
|
+
return `${seconds}s`;
|
|
14
|
+
}
|
|
15
|
+
const m = Math.floor(seconds / 60);
|
|
16
|
+
const s = seconds % 60;
|
|
17
|
+
return `${m}m ${s}s`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StatusLineData {
|
|
21
|
+
model?: string;
|
|
22
|
+
effort?: string;
|
|
23
|
+
agent?: string;
|
|
24
|
+
taskTime?: number;
|
|
25
|
+
inputTokens?: number;
|
|
26
|
+
outputTokens?: number;
|
|
27
|
+
cacheRead?: number;
|
|
28
|
+
dapi_usage?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StatusLineConfig {
|
|
32
|
+
cardStatusLine?: {
|
|
33
|
+
model?: boolean;
|
|
34
|
+
effort?: boolean;
|
|
35
|
+
agent?: boolean;
|
|
36
|
+
taskTime?: boolean;
|
|
37
|
+
tokens?: boolean;
|
|
38
|
+
dapiUsage?: boolean;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type SegmentKey = "model" | "effort" | "agent" | "tokens" | "taskTime" | "dapiUsage";
|
|
43
|
+
|
|
44
|
+
interface Segment {
|
|
45
|
+
key: SegmentKey;
|
|
46
|
+
defaultOn: boolean;
|
|
47
|
+
render: (d: StatusLineData) => string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderTokenSegment(data: StatusLineData): string | undefined {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
if (typeof data.inputTokens === "number") {
|
|
53
|
+
let s = `↑${formatTokenCount(data.inputTokens)}`;
|
|
54
|
+
if (typeof data.cacheRead === "number" && data.cacheRead > 0) {
|
|
55
|
+
s += `(C:${formatTokenCount(data.cacheRead)})`;
|
|
56
|
+
}
|
|
57
|
+
parts.push(s);
|
|
58
|
+
}
|
|
59
|
+
if (typeof data.outputTokens === "number") {
|
|
60
|
+
parts.push(`↓${formatTokenCount(data.outputTokens)}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SEGMENTS: Segment[] = [
|
|
66
|
+
{ key: "model", defaultOn: true, render: (d) => d.model || undefined },
|
|
67
|
+
{ key: "effort", defaultOn: true, render: (d) => d.effort || undefined },
|
|
68
|
+
{ key: "agent", defaultOn: true, render: (d) => d.agent || undefined },
|
|
69
|
+
{ key: "tokens", defaultOn: false, render: renderTokenSegment },
|
|
70
|
+
{ key: "taskTime", defaultOn: false, render: (d) => typeof d.taskTime === "number" ? formatDuration(d.taskTime) : undefined },
|
|
71
|
+
{ key: "dapiUsage", defaultOn: false, render: (d) => typeof d.dapi_usage === "number" ? `DAPI+${d.dapi_usage}` : undefined },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const SEGMENTS_PER_LINE = 3;
|
|
75
|
+
|
|
76
|
+
function resolveSegmentEnabled(seg: Segment, config: StatusLineConfig): boolean {
|
|
77
|
+
const value = config.cardStatusLine?.[seg.key];
|
|
78
|
+
return typeof value === "boolean" ? value : seg.defaultOn;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderStatusLine(data: StatusLineData, config: StatusLineConfig): string {
|
|
82
|
+
const rendered = SEGMENTS
|
|
83
|
+
.filter((seg) => resolveSegmentEnabled(seg, config))
|
|
84
|
+
.map((seg) => seg.render(data))
|
|
85
|
+
.filter(Boolean) as string[];
|
|
86
|
+
|
|
87
|
+
if (rendered.length === 0) { return ""; }
|
|
88
|
+
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (let i = 0; i < rendered.length; i += SEGMENTS_PER_LINE) {
|
|
91
|
+
lines.push(rendered.slice(i, i + SEGMENTS_PER_LINE).join(" | "));
|
|
92
|
+
}
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|