@soimy/dingtalk 3.2.0 → 3.4.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
@@ -0,0 +1,75 @@
1
+ type SentenceType = "夸奖" | "责怪" | "命令" | "叙事" | "请求" | "未知";
2
+
3
+ type AckReactionClassifyResult = {
4
+ type: SentenceType;
5
+ emoji: string;
6
+ };
7
+
8
+ type EmojiMap = Record<SentenceType, readonly string[]>;
9
+
10
+ const CATCHPHRASE = "叽 ";
11
+
12
+ const KEYWORDS = {
13
+ praise: ["真棒", "太好了", "厉害", "优秀", "聪明", "好样的", "赞", "牛", "完美", "出色", "真行", "干得漂亮", "天才", "棒极了"],
14
+ blame: ["怎么又", "搞砸", "太差了", "烦死了", "讨厌", "笨", "蠢", "马虎", "不负责任", "乱来", "糟糕", "废物", "气死我了", "错了"],
15
+ command: ["必须", "立刻", "马上", "赶紧", "不准", "不要", "别动", "别说", "别做", "快去", "去做", "给我", "听着", "站住", "闭嘴"],
16
+ request: ["能不能", "可以吗", "好吗", "请", "麻烦", "帮个忙", "帮忙", "劳驾", "能否", "想请你", "能帮我", "借我", "方便吗"],
17
+ } as const;
18
+
19
+ const POLITE_EXCLUSIONS = ["别客气", "别介意", "别见怪", "别担心", "别着急"] as const;
20
+
21
+ const EMOJIS: EmojiMap = {
22
+ // DingTalk `emotion/reply` does not accept every visually valid kaomoji.
23
+ // Keep only the strings that survived repeated direct API retests against
24
+ // the live endpoint using a real message target. Removed as unstable/bad:
25
+ // `٩(๑>◡<๑)۶`.
26
+ "夸奖": ["(๑•̀ㅂ•́)و✧", "(ノ≧∀≦)ノ", "(★▽★)", "(⌒▽⌒)☆", "(*≧ω≦)", "(ง •_•)ง", "ヾ(≧▽≦*)o"],
27
+ "责怪": ["(╬ Ò﹏Ó)", "(╯°□°)╯", "(▼皿▼#)", "(。•́︿•̀。)", "(╥﹏╥)", "ヽ(`Д´)ノ", "(#><)", "(;′⌒`)"],
28
+ // Keep command kaomoji limited to strings that passed local
29
+ // DingTalk emotion/reply verification. Removed as unstable/bad after
30
+ // repeated direct API retests: `┌(┌ *`д´)┐`, `(•̀へ •́ ╮ )`.
31
+ "命令": ["(¬_¬)", "(`ε´)", "(#`Д´)", "(●`∀´●)", "(`д´)", "( ̄ω ̄;)"],
32
+ // Narrative kaomoji must also stay within the subset accepted by
33
+ // DingTalk emotion/reply. Removed as unstable/bad after repeated direct
34
+ // API retests: `(´• ω •`)`.
35
+ "叙事": ["(。・ω・。)", "( ̄▽ ̄)", "(・・?)", "(。_。)", "( ̄ω ̄)", "(´▽`)", "(=_=)"],
36
+ // Request kaomoji must exclude strings that stayed unstable across
37
+ // repeated direct API retests. Removed as unstable/bad:
38
+ // `(づ。◕‿‿◕。)づ`, `(⁄ ⁄•⁄ω⁄•⁄ ⁄)`.
39
+ "请求": ["(っ´∀`)っ", "(๑•̀ω•́๑)✧", "(p≧w≦q)", "(♡˙︶˙♡)", "(´;ω;`)", "(人•ᴗ•✿)"],
40
+ "未知": ["(•̀_•́)", "(;一_一)", "(???)"],
41
+ };
42
+
43
+ function containsAny(text: string, words: readonly string[]): boolean {
44
+ return words.some(word => text.includes(word));
45
+ }
46
+
47
+ export function classifyAckReactionEmoji(sentence: unknown): AckReactionClassifyResult {
48
+ if (!sentence || typeof sentence !== "string") {
49
+ return { type: "未知", emoji: `${CATCHPHRASE}(•̀_•́)` };
50
+ }
51
+
52
+ const s = sentence.trim();
53
+ const isPolitePhrase = POLITE_EXCLUSIONS.some(phrase => s.includes(phrase));
54
+ const isQuestion = /吗|呢|?|\?/.test(s);
55
+ const startsWithPlease = s.startsWith("请");
56
+ const hasExclamation = /[!!]/.test(s);
57
+ const imperativeStart = !isPolitePhrase && /^(快|别|不要|不准|必须|马上|立刻)/.test(s);
58
+
59
+ let type: SentenceType;
60
+ if (containsAny(s, KEYWORDS.request) || (isQuestion && (startsWithPlease || /帮|麻烦/.test(s)))) {
61
+ type = "请求";
62
+ } else if (imperativeStart || containsAny(s, KEYWORDS.command)) {
63
+ type = "命令";
64
+ } else if (containsAny(s, KEYWORDS.praise)) {
65
+ type = "夸奖";
66
+ } else if (containsAny(s, KEYWORDS.blame) || (hasExclamation && /烦|讨厌|笨|蠢|差|气死/.test(s))) {
67
+ type = "责怪";
68
+ } else {
69
+ type = "叙事";
70
+ }
71
+
72
+ const emojiList = EMOJIS[type];
73
+ const emoji = emojiList[Math.floor(Math.random() * emojiList.length)];
74
+ return { type, emoji: `${CATCHPHRASE}${emoji}` };
75
+ }
@@ -0,0 +1,182 @@
1
+ import axios from "axios";
2
+ import { getAccessToken } from "./auth";
3
+ import type { DingTalkConfig } from "./types";
4
+ import { formatDingTalkErrorPayloadLog, getErrorMessage, getProxyBypassOption } from "./utils";
5
+
6
+ // DingTalk currently exposes a dedicated native "thinking" reaction flow rather than
7
+ // a generic arbitrary-emoji reaction API for this plugin path.
8
+ const DINGTALK_NATIVE_ACK_REACTION = "🤔思考中";
9
+ const THINKING_EMOTION_ID = "2659900";
10
+ const THINKING_EMOTION_BACKGROUND_ID = "im_bg_1";
11
+ // DingTalk `emotion/reply` occasionally races with just-arrived inbound
12
+ // messages or returns transient 5xx responses. Keep the first attempt
13
+ // immediate, then retry twice with short backoff windows before giving up.
14
+ const THINKING_REACTION_ATTACH_DELAYS_MS = [0, 400, 1200] as const;
15
+ const THINKING_REACTION_RECALL_DELAYS_MS = [0, 1500, 5000] as const;
16
+
17
+ type AckReactionLogger = {
18
+ debug?: (msg: string) => void;
19
+ info?: (msg: string) => void;
20
+ warn?: (msg: string) => void;
21
+ };
22
+
23
+ type AckReactionTarget = {
24
+ msgId: string;
25
+ conversationId: string;
26
+ robotCode?: string;
27
+ reactionName?: string;
28
+ };
29
+
30
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
31
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
32
+ }
33
+
34
+ function formatAckReactionTarget(data: AckReactionTarget): string {
35
+ return `msgId=${data.msgId || "-"} conversationId=${data.conversationId || "-"} reactionName=${data.reactionName || DINGTALK_NATIVE_ACK_REACTION}`;
36
+ }
37
+
38
+ function resolveAckReactionPayload(config: DingTalkConfig, data: AckReactionTarget): {
39
+ robotCode: string;
40
+ reactionName: string;
41
+ } | null {
42
+ const robotCode = (data.robotCode || config.robotCode || config.clientId || "").trim();
43
+ const reactionName =
44
+ (data.reactionName || DINGTALK_NATIVE_ACK_REACTION).trim() || DINGTALK_NATIVE_ACK_REACTION;
45
+ if (!robotCode || !data.msgId || !data.conversationId) {
46
+ return null;
47
+ }
48
+ return { robotCode, reactionName };
49
+ }
50
+
51
+ async function callEmotionApi(
52
+ config: DingTalkConfig,
53
+ data: AckReactionTarget,
54
+ endpoint: "reply" | "recall",
55
+ successLog: string,
56
+ errorLogPrefix: string,
57
+ errorPayloadKey: "inbound.ackReactionAttach" | "inbound.ackReactionRecall",
58
+ log?: AckReactionLogger,
59
+ ): Promise<{ ok: boolean; error?: unknown }> {
60
+ const payload = resolveAckReactionPayload(config, data);
61
+ if (!payload) {
62
+ return { ok: false };
63
+ }
64
+
65
+ try {
66
+ const token = await getAccessToken(config, log as any);
67
+ await axios.post(
68
+ `https://api.dingtalk.com/v1.0/robot/emotion/${endpoint}`,
69
+ {
70
+ robotCode: payload.robotCode,
71
+ openMsgId: data.msgId,
72
+ openConversationId: data.conversationId,
73
+ emotionType: 2,
74
+ emotionName: payload.reactionName,
75
+ textEmotion: {
76
+ emotionId: THINKING_EMOTION_ID,
77
+ emotionName: payload.reactionName,
78
+ text: payload.reactionName,
79
+ backgroundId: THINKING_EMOTION_BACKGROUND_ID,
80
+ },
81
+ },
82
+ {
83
+ headers: {
84
+ "x-acs-dingtalk-access-token": token,
85
+ "Content-Type": "application/json",
86
+ },
87
+ timeout: 5000,
88
+ ...getProxyBypassOption(config),
89
+ },
90
+ );
91
+ log?.info?.(successLog);
92
+ return { ok: true };
93
+ } catch (err: unknown) {
94
+ const response = asRecord(asRecord(err)?.response);
95
+ log?.warn?.(`${errorLogPrefix}: ${getErrorMessage(err)}`);
96
+ if (response?.data !== undefined) {
97
+ log?.warn?.(formatDingTalkErrorPayloadLog(errorPayloadKey, response.data));
98
+ }
99
+ return { ok: false, error: err };
100
+ }
101
+ }
102
+
103
+ function isRetryableEmotionApiError(err: unknown): boolean {
104
+ const response = asRecord(asRecord(err)?.response);
105
+ const data = asRecord(response?.data);
106
+ const status = Number(response?.status ?? 0);
107
+ const errorCode = String(data?.code || "").trim().toLowerCase();
108
+ if (!response) {
109
+ return true;
110
+ }
111
+ if (status >= 500) {
112
+ return true;
113
+ }
114
+ return errorCode === "system.err";
115
+ }
116
+
117
+ export async function attachNativeAckReaction(
118
+ config: DingTalkConfig,
119
+ data: AckReactionTarget,
120
+ log?: AckReactionLogger,
121
+ ): Promise<boolean> {
122
+ for (let index = 0; index < THINKING_REACTION_ATTACH_DELAYS_MS.length; index += 1) {
123
+ const delayMs = THINKING_REACTION_ATTACH_DELAYS_MS[index];
124
+ if (delayMs > 0) {
125
+ await new Promise(resolve => setTimeout(resolve, delayMs));
126
+ }
127
+ const attempt = index + 1;
128
+ const attemptLabel = `${attempt}/${THINKING_REACTION_ATTACH_DELAYS_MS.length}`;
129
+ const result = await callEmotionApi(
130
+ config,
131
+ data,
132
+ "reply",
133
+ `[DingTalk] Native ack reaction attach succeeded (${formatAckReactionTarget(data)} attempt=${attemptLabel})`,
134
+ `[DingTalk] Native ack reaction attach failed (${formatAckReactionTarget(data)} attempt=${attemptLabel})`,
135
+ "inbound.ackReactionAttach",
136
+ log,
137
+ );
138
+ if (result.ok) {
139
+ return true;
140
+ }
141
+ const shouldRetry = isRetryableEmotionApiError(result.error);
142
+ if (!shouldRetry || attempt === THINKING_REACTION_ATTACH_DELAYS_MS.length) {
143
+ break;
144
+ }
145
+ log?.debug?.(
146
+ `[DingTalk] Retrying native ack reaction attach (${formatAckReactionTarget(data)} nextAttempt=${attempt + 1}/${THINKING_REACTION_ATTACH_DELAYS_MS.length})`,
147
+ );
148
+ }
149
+ return false;
150
+ }
151
+
152
+ async function recallNativeAckReaction(
153
+ config: DingTalkConfig,
154
+ data: AckReactionTarget,
155
+ log?: AckReactionLogger,
156
+ ): Promise<boolean> {
157
+ const result = await callEmotionApi(
158
+ config,
159
+ data,
160
+ "recall",
161
+ "[DingTalk] Native ack reaction recall succeeded",
162
+ "[DingTalk] Native ack reaction recall failed",
163
+ "inbound.ackReactionRecall",
164
+ log,
165
+ );
166
+ return result.ok;
167
+ }
168
+
169
+ export async function recallNativeAckReactionWithRetry(
170
+ config: DingTalkConfig,
171
+ data: AckReactionTarget,
172
+ log?: AckReactionLogger,
173
+ ): Promise<void> {
174
+ for (const delayMs of THINKING_REACTION_RECALL_DELAYS_MS) {
175
+ if (delayMs > 0) {
176
+ await new Promise(resolve => setTimeout(resolve, delayMs));
177
+ }
178
+ if (await recallNativeAckReaction(config, data, log)) {
179
+ return;
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,148 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AttachmentTextSource } from "./types";
4
+
5
+ const MAX_EXTRACTED_TEXT_CHARS = 6000;
6
+ const MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
7
+
8
+ export interface AttachmentTextExtractionInput {
9
+ path: string;
10
+ mimeType?: string;
11
+ fileName?: string;
12
+ }
13
+
14
+ export interface AttachmentTextExtractionResult {
15
+ text: string;
16
+ truncated: boolean;
17
+ sourceType: AttachmentTextSource;
18
+ }
19
+
20
+ async function isFileTooLarge(filePath: string): Promise<boolean> {
21
+ const stat = await fs.stat(filePath);
22
+ return stat.size > MAX_ATTACHMENT_EXTRACT_BYTES;
23
+ }
24
+
25
+ function isTextLikeMimeType(mimeType: string | undefined): boolean {
26
+ if (!mimeType) {
27
+ return false;
28
+ }
29
+ return (
30
+ mimeType.startsWith("text/") ||
31
+ mimeType === "application/json" ||
32
+ mimeType === "application/xml" ||
33
+ mimeType === "application/javascript"
34
+ );
35
+ }
36
+
37
+ function normalizeWhitespace(text: string): string {
38
+ return text
39
+ .replace(/\r\n/g, "\n")
40
+ .split("\u0000").join("")
41
+ .replace(/[ \t]{2,}/g, " ")
42
+ .replace(/[ \t]+\n/g, "\n")
43
+ .replace(/\n{3,}/g, "\n\n")
44
+ .trim();
45
+ }
46
+
47
+ function limitExtractedText(text: string): AttachmentTextExtractionResult | null {
48
+ const normalized = normalizeWhitespace(text);
49
+ if (!normalized) {
50
+ return null;
51
+ }
52
+ const truncated = normalized.length > MAX_EXTRACTED_TEXT_CHARS;
53
+ const limited = truncated ? `${normalized.slice(0, MAX_EXTRACTED_TEXT_CHARS)}\n\n[内容已截断]` : normalized;
54
+ return {
55
+ text: limited,
56
+ truncated,
57
+ sourceType: "text",
58
+ };
59
+ }
60
+
61
+ function stripHtml(html: string): string {
62
+ return html
63
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
64
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
65
+ .replace(/<[^>]+>/g, " ")
66
+ .replace(/&nbsp;/gi, " ")
67
+ .replace(/&lt;/gi, "<")
68
+ .replace(/&gt;/gi, ">")
69
+ .replace(/&amp;/gi, "&")
70
+ .replace(/&#39;/gi, "'")
71
+ .replace(/&quot;/gi, "\"");
72
+ }
73
+
74
+ async function extractTextLikeFile(filePath: string): Promise<AttachmentTextExtractionResult | null> {
75
+ const raw = await fs.readFile(filePath, "utf8");
76
+ return limitExtractedText(raw);
77
+ }
78
+
79
+ async function extractPdf(filePath: string): Promise<AttachmentTextExtractionResult | null> {
80
+ const pdfParseModule = await import("pdf-parse");
81
+ const fileBuffer = await fs.readFile(filePath);
82
+ const parser = new pdfParseModule.PDFParse({ data: new Uint8Array(fileBuffer) });
83
+ try {
84
+ const result = await parser.getText();
85
+ const limited = limitExtractedText(result.text || "");
86
+ return limited ? { ...limited, sourceType: "pdf" } : null;
87
+ } finally {
88
+ await parser.destroy();
89
+ }
90
+ }
91
+
92
+ async function extractDocx(filePath: string): Promise<AttachmentTextExtractionResult | null> {
93
+ const mammothModule = await import("mammoth");
94
+ const result = await mammothModule.extractRawText({ path: filePath });
95
+ const limited = limitExtractedText(result.value || "");
96
+ return limited ? { ...limited, sourceType: "docx" } : null;
97
+ }
98
+
99
+ async function extractHtml(filePath: string): Promise<AttachmentTextExtractionResult | null> {
100
+ const raw = await fs.readFile(filePath, "utf8");
101
+ const limited = limitExtractedText(stripHtml(raw));
102
+ return limited ? { ...limited, sourceType: "html" } : null;
103
+ }
104
+
105
+ export async function extractAttachmentText(
106
+ input: AttachmentTextExtractionInput,
107
+ ): Promise<AttachmentTextExtractionResult | null> {
108
+ const mimeType = input.mimeType?.toLowerCase();
109
+ const fileName = (input.fileName || path.basename(input.path)).toLowerCase();
110
+
111
+ if (mimeType?.startsWith("image/") || mimeType?.startsWith("audio/") || mimeType?.startsWith("video/")) {
112
+ return null;
113
+ }
114
+
115
+ if (await isFileTooLarge(input.path)) {
116
+ return null;
117
+ }
118
+
119
+ if (
120
+ mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
121
+ fileName.endsWith(".docx")
122
+ ) {
123
+ return extractDocx(input.path);
124
+ }
125
+
126
+ if (mimeType === "application/pdf" || fileName.endsWith(".pdf")) {
127
+ return extractPdf(input.path);
128
+ }
129
+
130
+ if (mimeType === "text/html" || mimeType === "application/xhtml+xml" || fileName.endsWith(".html") || fileName.endsWith(".htm")) {
131
+ return extractHtml(input.path);
132
+ }
133
+
134
+ if (
135
+ isTextLikeMimeType(mimeType) ||
136
+ fileName.endsWith(".txt") ||
137
+ fileName.endsWith(".md") ||
138
+ fileName.endsWith(".markdown") ||
139
+ fileName.endsWith(".csv") ||
140
+ fileName.endsWith(".json") ||
141
+ fileName.endsWith(".xml") ||
142
+ fileName.endsWith(".log")
143
+ ) {
144
+ return extractTextLikeFile(input.path);
145
+ }
146
+
147
+ return null;
148
+ }
@@ -0,0 +1,119 @@
1
+ export interface CardCallbackAnalysis {
2
+ summary: string;
3
+ actionId?: string;
4
+ feedbackTarget?: string;
5
+ feedbackAckText?: string;
6
+ userId?: string;
7
+ processQueryKey?: string;
8
+ }
9
+
10
+ function stringifyCandidate(value: unknown): string {
11
+ if (typeof value === "string") {
12
+ return value;
13
+ }
14
+ try {
15
+ return JSON.stringify(value);
16
+ } catch {
17
+ return "[unserializable]";
18
+ }
19
+ }
20
+
21
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
23
+ return undefined;
24
+ }
25
+ return value as Record<string, unknown>;
26
+ }
27
+
28
+ function parseEmbeddedJson(value: unknown): unknown {
29
+ if (typeof value !== "string") {
30
+ return value;
31
+ }
32
+ try {
33
+ return JSON.parse(value);
34
+ } catch {
35
+ return value;
36
+ }
37
+ }
38
+
39
+ export function extractCardActionSummary(data: unknown): string {
40
+ const record = asRecord(data);
41
+ const candidates = [
42
+ record?.action,
43
+ record?.actionType,
44
+ record?.actionValue,
45
+ record?.value,
46
+ record?.eventType,
47
+ record?.operate,
48
+ record?.callbackType,
49
+ record?.cardPrivateData,
50
+ record?.privateData,
51
+ ].filter((value) => value !== undefined && value !== null);
52
+
53
+ if (candidates.length === 0) {
54
+ return "(no action field found)";
55
+ }
56
+
57
+ return candidates.map(stringifyCandidate).join(" | ");
58
+ }
59
+
60
+ export function extractCardActionId(data: unknown): string | undefined {
61
+ const record = asRecord(data);
62
+ const embeddedValue = parseEmbeddedJson(record?.value);
63
+ const embeddedContent = parseEmbeddedJson(record?.content);
64
+
65
+ for (const source of [embeddedValue, embeddedContent, record].filter(Boolean)) {
66
+ const sourceRecord = asRecord(source);
67
+ const cardPrivateData = asRecord(sourceRecord?.cardPrivateData);
68
+ const actionIds = cardPrivateData?.actionIds;
69
+ if (Array.isArray(actionIds) && actionIds.length > 0 && typeof actionIds[0] === "string") {
70
+ return actionIds[0];
71
+ }
72
+ if (typeof sourceRecord?.actionValue === "string" && sourceRecord.actionValue.trim()) {
73
+ return sourceRecord.actionValue.trim();
74
+ }
75
+ if (typeof sourceRecord?.eventKey === "string" && sourceRecord.eventKey.trim()) {
76
+ return sourceRecord.eventKey.trim();
77
+ }
78
+ if (typeof sourceRecord?.value === "string" && sourceRecord.value.trim()) {
79
+ return sourceRecord.value.trim();
80
+ }
81
+ }
82
+
83
+ return undefined;
84
+ }
85
+
86
+ export function analyzeCardCallback(data: unknown): CardCallbackAnalysis {
87
+ const record = asRecord(data);
88
+ const summary = extractCardActionSummary(data);
89
+ const actionId = extractCardActionId(data);
90
+ const embeddedValue = asRecord(parseEmbeddedJson(record?.value));
91
+ const embeddedContent = asRecord(parseEmbeddedJson(record?.content));
92
+ const processQueryKey =
93
+ (typeof record?.processQueryKey === "string" && record.processQueryKey.trim()) ||
94
+ (typeof embeddedValue?.processQueryKey === "string" && embeddedValue.processQueryKey.trim()) ||
95
+ (typeof embeddedContent?.processQueryKey === "string" && embeddedContent.processQueryKey.trim()) ||
96
+ undefined;
97
+
98
+ if (actionId !== "feedback_up" && actionId !== "feedback_down") {
99
+ return { summary, actionId, processQueryKey };
100
+ }
101
+
102
+ const spaceType = typeof record?.spaceType === "string" ? record.spaceType.trim().toLowerCase() : "";
103
+ const spaceId = typeof record?.spaceId === "string" ? record.spaceId.trim() : "";
104
+ const userId = typeof record?.userId === "string" ? record.userId.trim() : "";
105
+ const feedbackTarget = spaceType === "im" ? userId : spaceId;
106
+ const feedbackAckText =
107
+ actionId === "feedback_up"
108
+ ? "✅ 已收到你的点赞(反馈已记录)"
109
+ : "⚠️ 已收到你的点踩(反馈已记录,我会改进)";
110
+
111
+ return {
112
+ summary,
113
+ actionId,
114
+ feedbackTarget: feedbackTarget || undefined,
115
+ feedbackAckText,
116
+ userId: userId || undefined,
117
+ processQueryKey,
118
+ };
119
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Card draft controller for throttled AI Card streaming updates.
3
+ *
4
+ * Wraps {@link createDraftStreamLoop} with a phase-based state machine
5
+ * (idle → reasoning → answer) that manages what content is sent to the
6
+ * DingTalk AI Card during the reply lifecycle.
7
+ *
8
+ * Responsibilities (and non-responsibilities):
9
+ * - DOES manage throttled card preview updates via streamAICard
10
+ * - DOES enforce single-flight, latest-wins, phase-gated semantics
11
+ * - Does NOT handle tool append, finalize, or markdown fallback —
12
+ * those stay in inbound-handler's deliver callback.
13
+ */
14
+
15
+ import { formatContentForCard, streamAICard } from "./card-service";
16
+ import { createDraftStreamLoop } from "./draft-stream-loop";
17
+ import type { AICardInstance, Logger } from "./types";
18
+
19
+ export type CardDraftPhase = "idle" | "reasoning" | "answer";
20
+
21
+ export interface CardDraftController {
22
+ updateAnswer: (text: string) => void;
23
+ updateReasoning: (text: string) => void;
24
+ /** Signal that a new assistant turn has started (e.g. after a tool call). */
25
+ notifyNewAssistantTurn: () => void;
26
+ flush: () => Promise<void>;
27
+ waitForInFlight: () => Promise<void>;
28
+ stop: () => void;
29
+ isFailed: () => boolean;
30
+ /** Last content sent to card (reasoning or answer). */
31
+ getLastContent: () => string;
32
+ /** Last content sent to card during answer phase only. */
33
+ getLastAnswerContent: () => string;
34
+ }
35
+
36
+ export function createCardDraftController(params: {
37
+ card: AICardInstance;
38
+ throttleMs?: number;
39
+ log?: Logger;
40
+ }): CardDraftController {
41
+ let phase: CardDraftPhase = "idle";
42
+ let failed = false;
43
+ let stopped = false;
44
+ let lastSentContent = "";
45
+ let lastAnswerContent = "";
46
+ let answerPrefix = "";
47
+ let turnBoundaryPending = false;
48
+
49
+ const loop = createDraftStreamLoop({
50
+ throttleMs: params.throttleMs ?? 300,
51
+ isStopped: () => stopped || failed,
52
+ sendOrEditStreamMessage: async (content: string) => {
53
+ try {
54
+ await streamAICard(params.card, content, false, params.log);
55
+ lastSentContent = content;
56
+ if (phase === "answer") {
57
+ lastAnswerContent = content;
58
+ }
59
+ } catch (err: unknown) {
60
+ failed = true;
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ params.log?.warn?.(`[DingTalk][AICard] Stream failed: ${message}`);
63
+ }
64
+ },
65
+ });
66
+
67
+ return {
68
+ updateReasoning: (text: string) => {
69
+ if (stopped || failed || phase === "answer") { return; }
70
+ phase = "reasoning";
71
+ const formatted = formatContentForCard(text, "thinking");
72
+ if (formatted) {
73
+ loop.update(formatted);
74
+ }
75
+ },
76
+
77
+ updateAnswer: (text: string) => {
78
+ if (stopped || failed) { return; }
79
+ if (phase !== "answer") {
80
+ params.log?.debug?.(`[DingTalk][Draft] phase ${phase} → answer`);
81
+ phase = "answer";
82
+ if (turnBoundaryPending && lastAnswerContent) {
83
+ answerPrefix = lastAnswerContent + "\n\n";
84
+ }
85
+ turnBoundaryPending = false;
86
+ }
87
+ const trimmed = text?.trimStart();
88
+ if (trimmed) {
89
+ loop.update(answerPrefix + trimmed);
90
+ }
91
+ },
92
+
93
+ notifyNewAssistantTurn: () => {
94
+ if (stopped || failed) { return; }
95
+ turnBoundaryPending = true;
96
+ if (phase === "reasoning") {
97
+ loop.resetPending();
98
+ }
99
+ phase = "idle";
100
+ },
101
+
102
+ flush: () => loop.flush(),
103
+ waitForInFlight: () => loop.waitForInFlight(),
104
+
105
+ stop: () => {
106
+ stopped = true;
107
+ loop.stop();
108
+ },
109
+
110
+ isFailed: () => failed,
111
+ getLastContent: () => lastSentContent,
112
+ getLastAnswerContent: () => lastAnswerContent,
113
+ };
114
+ }