@soimy/dingtalk 3.2.0 → 3.3.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/LICENSE +21 -0
- package/README.md +657 -32
- package/index.ts +62 -0
- package/package.json +3 -1
- package/src/access-control.ts +18 -0
- package/src/ack-reaction-classifier.ts +62 -0
- package/src/ack-reaction-service.ts +135 -0
- package/src/attachment-text-extractor.ts +147 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +776 -24
- package/src/channel.ts +408 -135
- package/src/config-schema.ts +40 -4
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1100 -159
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +38 -0
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quote-journal.ts +242 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/quoted-msg-cache.ts +226 -0
- package/src/send-service.ts +177 -43
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +152 -24
- package/src/utils.ts +231 -12
package/index.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import * as pluginSdk from "openclaw/plugin-sdk";
|
|
2
3
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
4
|
import { dingtalkPlugin } from "./src/channel";
|
|
5
|
+
import { getConfig } from "./src/config";
|
|
6
|
+
import { appendToDoc, createDoc, DocCreateAppendError, listDocs, searchDocs } from "./src/docs-service";
|
|
4
7
|
import { setDingTalkRuntime } from "./src/runtime";
|
|
5
8
|
import type { DingtalkPluginModule } from "./src/types";
|
|
6
9
|
|
|
10
|
+
type GatewayMethodContext = Pick<
|
|
11
|
+
Parameters<Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]>[0],
|
|
12
|
+
"params" | "respond"
|
|
13
|
+
>;
|
|
14
|
+
|
|
7
15
|
const plugin: DingtalkPluginModule = {
|
|
8
16
|
id: "dingtalk",
|
|
9
17
|
name: "DingTalk Channel",
|
|
@@ -12,6 +20,60 @@ const plugin: DingtalkPluginModule = {
|
|
|
12
20
|
register(api: OpenClawPluginApi): void {
|
|
13
21
|
setDingTalkRuntime(api.runtime);
|
|
14
22
|
api.registerChannel({ plugin: dingtalkPlugin });
|
|
23
|
+
api.registerGatewayMethod("dingtalk.docs.create", async ({ respond, params }: GatewayMethodContext) => {
|
|
24
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
25
|
+
const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
|
|
26
|
+
const title = pluginSdk.readStringParam(params, "title", { required: true });
|
|
27
|
+
const content = pluginSdk.readStringParam(params, "content", { allowEmpty: true });
|
|
28
|
+
const parentId = pluginSdk.readStringParam(params, "parentId");
|
|
29
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
30
|
+
try {
|
|
31
|
+
const doc = await createDoc(
|
|
32
|
+
config,
|
|
33
|
+
spaceId,
|
|
34
|
+
title,
|
|
35
|
+
content ?? undefined,
|
|
36
|
+
api.logger,
|
|
37
|
+
parentId ?? undefined,
|
|
38
|
+
);
|
|
39
|
+
return respond(true, doc);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof DocCreateAppendError) {
|
|
42
|
+
return respond(true, {
|
|
43
|
+
partialSuccess: true,
|
|
44
|
+
initContentAppended: false,
|
|
45
|
+
docId: error.doc.docId,
|
|
46
|
+
doc: error.doc,
|
|
47
|
+
appendError: error.message,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
api.registerGatewayMethod("dingtalk.docs.append", async ({ respond, params }: GatewayMethodContext) => {
|
|
54
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
55
|
+
const docId = pluginSdk.readStringParam(params, "docId", { required: true });
|
|
56
|
+
const content = pluginSdk.readStringParam(params, "content", { required: true, allowEmpty: false });
|
|
57
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
58
|
+
const result = await appendToDoc(config, docId, content, api.logger);
|
|
59
|
+
return respond(true, result);
|
|
60
|
+
});
|
|
61
|
+
api.registerGatewayMethod("dingtalk.docs.search", async ({ respond, params }: GatewayMethodContext) => {
|
|
62
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
63
|
+
const keyword = pluginSdk.readStringParam(params, "keyword", { required: true });
|
|
64
|
+
const spaceId = pluginSdk.readStringParam(params, "spaceId");
|
|
65
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
66
|
+
const docs = await searchDocs(config, keyword, spaceId, api.logger);
|
|
67
|
+
return respond(true, { docs });
|
|
68
|
+
});
|
|
69
|
+
api.registerGatewayMethod("dingtalk.docs.list", async ({ respond, params }: GatewayMethodContext) => {
|
|
70
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
71
|
+
const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
|
|
72
|
+
const parentId = pluginSdk.readStringParam(params, "parentId");
|
|
73
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
74
|
+
const docs = await listDocs(config, spaceId, parentId, api.logger);
|
|
75
|
+
return respond(true, { docs });
|
|
76
|
+
});
|
|
15
77
|
},
|
|
16
78
|
};
|
|
17
79
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soimy/dingtalk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "DingTalk (钉钉) channel plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
"axios": "^1.6.0",
|
|
45
45
|
"dingtalk-stream": "^2.1.4",
|
|
46
46
|
"form-data": "^4.0.0",
|
|
47
|
+
"mammoth": "^1.12.0",
|
|
48
|
+
"pdf-parse": "^2.4.5",
|
|
47
49
|
"zod": "^4.3.6"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
package/src/access-control.ts
CHANGED
|
@@ -53,3 +53,21 @@ export function isSenderGroupAllowed(params: {
|
|
|
53
53
|
}
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
export function isSenderOwner(params: {
|
|
58
|
+
allow: NormalizedAllowFrom;
|
|
59
|
+
senderId?: string;
|
|
60
|
+
rawSenderId?: string;
|
|
61
|
+
}): boolean {
|
|
62
|
+
const { allow, senderId, rawSenderId } = params;
|
|
63
|
+
if (!allow.hasEntries) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (rawSenderId && allow.entriesLower.includes(rawSenderId.toLowerCase())) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
"夸奖": ["(๑•̀ㅂ•́)و✧", "(ノ≧∀≦)ノ", "٩(๑>◡<๑)۶", "(★▽★)", "(⌒▽⌒)☆", "(*≧ω≦)", "(ง •_•)ง", "ヾ(≧▽≦*)o"],
|
|
23
|
+
"责怪": ["(╬ Ò﹏Ó)", "(╯°□°)╯", "(▼皿▼#)", "(。•́︿•̀。)", "(╥﹏╥)", "ヽ(`Д´)ノ", "(#><)", "(;′⌒`)"],
|
|
24
|
+
"命令": ["(¬_¬)", "(`ε´)", "(#`Д´)", "(●`∀´●)", "┌(┌ *`д´)┐", "(`д´)", "(•̀へ •́ ╮ )", "( ̄ω ̄;)"],
|
|
25
|
+
"叙事": ["(。・ω・。)", "( ̄▽ ̄)", "(´• ω •`)", "(・・?)", "(。_。)", "( ̄ω ̄)", "(´▽`)", "(=_=)"],
|
|
26
|
+
"请求": ["(っ´∀`)っ", "(๑•̀ω•́๑)✧", "(づ。◕‿‿◕。)づ", "(p≧w≦q)", "(♡˙︶˙♡)", "(⁄ ⁄•⁄ω⁄•⁄ ⁄)", "(´;ω;`)", "(人•ᴗ•✿)"],
|
|
27
|
+
"未知": ["(•̀_•́)", "(;一_一)", "(???)"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function containsAny(text: string, words: readonly string[]): boolean {
|
|
31
|
+
return words.some(word => text.includes(word));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function classifyAckReactionEmoji(sentence: unknown): AckReactionClassifyResult {
|
|
35
|
+
if (!sentence || typeof sentence !== "string") {
|
|
36
|
+
return { type: "未知", emoji: `${CATCHPHRASE}(•̀_•́)` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const s = sentence.trim();
|
|
40
|
+
const isPolitePhrase = POLITE_EXCLUSIONS.some(phrase => s.includes(phrase));
|
|
41
|
+
const isQuestion = /吗|呢|?|\?/.test(s);
|
|
42
|
+
const startsWithPlease = s.startsWith("请");
|
|
43
|
+
const hasExclamation = /[!!]/.test(s);
|
|
44
|
+
const imperativeStart = !isPolitePhrase && /^(快|别|不要|不准|必须|马上|立刻)/.test(s);
|
|
45
|
+
|
|
46
|
+
let type: SentenceType;
|
|
47
|
+
if (containsAny(s, KEYWORDS.request) || (isQuestion && (startsWithPlease || /帮|麻烦/.test(s)))) {
|
|
48
|
+
type = "请求";
|
|
49
|
+
} else if (imperativeStart || containsAny(s, KEYWORDS.command)) {
|
|
50
|
+
type = "命令";
|
|
51
|
+
} else if (containsAny(s, KEYWORDS.praise)) {
|
|
52
|
+
type = "夸奖";
|
|
53
|
+
} else if (containsAny(s, KEYWORDS.blame) || (hasExclamation && /烦|讨厌|笨|蠢|差|气死/.test(s))) {
|
|
54
|
+
type = "责怪";
|
|
55
|
+
} else {
|
|
56
|
+
type = "叙事";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const emojiList = EMOJIS[type];
|
|
60
|
+
const emoji = emojiList[Math.floor(Math.random() * emojiList.length)];
|
|
61
|
+
return { type, emoji: `${CATCHPHRASE}${emoji}` };
|
|
62
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { getAccessToken } from "./auth";
|
|
3
|
+
import type { DingTalkConfig } from "./types";
|
|
4
|
+
import { formatDingTalkErrorPayloadLog, 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
|
+
const THINKING_REACTION_RECALL_DELAYS_MS = [0, 1500, 5000] as const;
|
|
12
|
+
|
|
13
|
+
type AckReactionLogger = {
|
|
14
|
+
debug?: (msg: string) => void;
|
|
15
|
+
info?: (msg: string) => void;
|
|
16
|
+
warn?: (msg: string) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type AckReactionTarget = {
|
|
20
|
+
msgId: string;
|
|
21
|
+
conversationId: string;
|
|
22
|
+
robotCode?: string;
|
|
23
|
+
reactionName?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function resolveAckReactionPayload(config: DingTalkConfig, data: AckReactionTarget): {
|
|
27
|
+
robotCode: string;
|
|
28
|
+
reactionName: string;
|
|
29
|
+
} | null {
|
|
30
|
+
const robotCode = (data.robotCode || config.robotCode || config.clientId || "").trim();
|
|
31
|
+
const reactionName =
|
|
32
|
+
(data.reactionName || DINGTALK_NATIVE_ACK_REACTION).trim() || DINGTALK_NATIVE_ACK_REACTION;
|
|
33
|
+
if (!robotCode || !data.msgId || !data.conversationId) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return { robotCode, reactionName };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function callEmotionApi(
|
|
40
|
+
config: DingTalkConfig,
|
|
41
|
+
data: AckReactionTarget,
|
|
42
|
+
endpoint: "reply" | "recall",
|
|
43
|
+
successLog: string,
|
|
44
|
+
errorLogPrefix: string,
|
|
45
|
+
errorPayloadKey: "inbound.ackReactionAttach" | "inbound.ackReactionRecall",
|
|
46
|
+
log?: AckReactionLogger,
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
const payload = resolveAckReactionPayload(config, data);
|
|
49
|
+
if (!payload) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const token = await getAccessToken(config, log as any);
|
|
55
|
+
await axios.post(
|
|
56
|
+
`https://api.dingtalk.com/v1.0/robot/emotion/${endpoint}`,
|
|
57
|
+
{
|
|
58
|
+
robotCode: payload.robotCode,
|
|
59
|
+
openMsgId: data.msgId,
|
|
60
|
+
openConversationId: data.conversationId,
|
|
61
|
+
emotionType: 2,
|
|
62
|
+
emotionName: payload.reactionName,
|
|
63
|
+
textEmotion: {
|
|
64
|
+
emotionId: THINKING_EMOTION_ID,
|
|
65
|
+
emotionName: payload.reactionName,
|
|
66
|
+
text: payload.reactionName,
|
|
67
|
+
backgroundId: THINKING_EMOTION_BACKGROUND_ID,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
headers: {
|
|
72
|
+
"x-acs-dingtalk-access-token": token,
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
},
|
|
75
|
+
timeout: 5000,
|
|
76
|
+
...getProxyBypassOption(config),
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
log?.info?.(successLog);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (err: any) {
|
|
82
|
+
log?.warn?.(`${errorLogPrefix}: ${err.message}`);
|
|
83
|
+
if (err?.response?.data !== undefined) {
|
|
84
|
+
log?.warn?.(formatDingTalkErrorPayloadLog(errorPayloadKey, err.response.data));
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function attachNativeAckReaction(
|
|
91
|
+
config: DingTalkConfig,
|
|
92
|
+
data: AckReactionTarget,
|
|
93
|
+
log?: AckReactionLogger,
|
|
94
|
+
): Promise<boolean> {
|
|
95
|
+
return callEmotionApi(
|
|
96
|
+
config,
|
|
97
|
+
data,
|
|
98
|
+
"reply",
|
|
99
|
+
"[DingTalk] Native ack reaction attach succeeded",
|
|
100
|
+
"[DingTalk] Native ack reaction attach failed",
|
|
101
|
+
"inbound.ackReactionAttach",
|
|
102
|
+
log,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function recallNativeAckReaction(
|
|
107
|
+
config: DingTalkConfig,
|
|
108
|
+
data: AckReactionTarget,
|
|
109
|
+
log?: AckReactionLogger,
|
|
110
|
+
): Promise<boolean> {
|
|
111
|
+
return callEmotionApi(
|
|
112
|
+
config,
|
|
113
|
+
data,
|
|
114
|
+
"recall",
|
|
115
|
+
"[DingTalk] Native ack reaction recall succeeded",
|
|
116
|
+
"[DingTalk] Native ack reaction recall failed",
|
|
117
|
+
"inbound.ackReactionRecall",
|
|
118
|
+
log,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function recallNativeAckReactionWithRetry(
|
|
123
|
+
config: DingTalkConfig,
|
|
124
|
+
data: AckReactionTarget,
|
|
125
|
+
log?: AckReactionLogger,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
for (const delayMs of THINKING_REACTION_RECALL_DELAYS_MS) {
|
|
128
|
+
if (delayMs > 0) {
|
|
129
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
130
|
+
}
|
|
131
|
+
if (await recallNativeAckReaction(config, data, log)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const MAX_EXTRACTED_TEXT_CHARS = 6000;
|
|
5
|
+
const MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export interface AttachmentTextExtractionInput {
|
|
8
|
+
path: string;
|
|
9
|
+
mimeType?: string;
|
|
10
|
+
fileName?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AttachmentTextExtractionResult {
|
|
14
|
+
text: string;
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
sourceType: "text" | "html" | "pdf" | "docx";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function isFileTooLarge(filePath: string): Promise<boolean> {
|
|
20
|
+
const stat = await fs.stat(filePath);
|
|
21
|
+
return stat.size > MAX_ATTACHMENT_EXTRACT_BYTES;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isTextLikeMimeType(mimeType: string | undefined): boolean {
|
|
25
|
+
if (!mimeType) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
mimeType.startsWith("text/") ||
|
|
30
|
+
mimeType === "application/json" ||
|
|
31
|
+
mimeType === "application/xml" ||
|
|
32
|
+
mimeType === "application/javascript"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeWhitespace(text: string): string {
|
|
37
|
+
return text
|
|
38
|
+
.replace(/\r\n/g, "\n")
|
|
39
|
+
.split("\u0000").join("")
|
|
40
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
41
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
42
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function limitExtractedText(text: string): AttachmentTextExtractionResult | null {
|
|
47
|
+
const normalized = normalizeWhitespace(text);
|
|
48
|
+
if (!normalized) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const truncated = normalized.length > MAX_EXTRACTED_TEXT_CHARS;
|
|
52
|
+
const limited = truncated ? `${normalized.slice(0, MAX_EXTRACTED_TEXT_CHARS)}\n\n[内容已截断]` : normalized;
|
|
53
|
+
return {
|
|
54
|
+
text: limited,
|
|
55
|
+
truncated,
|
|
56
|
+
sourceType: "text",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stripHtml(html: string): string {
|
|
61
|
+
return html
|
|
62
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
63
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
64
|
+
.replace(/<[^>]+>/g, " ")
|
|
65
|
+
.replace(/ /gi, " ")
|
|
66
|
+
.replace(/</gi, "<")
|
|
67
|
+
.replace(/>/gi, ">")
|
|
68
|
+
.replace(/&/gi, "&")
|
|
69
|
+
.replace(/'/gi, "'")
|
|
70
|
+
.replace(/"/gi, "\"");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function extractTextLikeFile(filePath: string): Promise<AttachmentTextExtractionResult | null> {
|
|
74
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
75
|
+
return limitExtractedText(raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function extractPdf(filePath: string): Promise<AttachmentTextExtractionResult | null> {
|
|
79
|
+
const pdfParseModule = await import("pdf-parse");
|
|
80
|
+
const fileBuffer = await fs.readFile(filePath);
|
|
81
|
+
const parser = new pdfParseModule.PDFParse({ data: new Uint8Array(fileBuffer) });
|
|
82
|
+
try {
|
|
83
|
+
const result = await parser.getText();
|
|
84
|
+
const limited = limitExtractedText(result.text || "");
|
|
85
|
+
return limited ? { ...limited, sourceType: "pdf" } : null;
|
|
86
|
+
} finally {
|
|
87
|
+
await parser.destroy();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function extractDocx(filePath: string): Promise<AttachmentTextExtractionResult | null> {
|
|
92
|
+
const mammothModule = await import("mammoth");
|
|
93
|
+
const result = await mammothModule.extractRawText({ path: filePath });
|
|
94
|
+
const limited = limitExtractedText(result.value || "");
|
|
95
|
+
return limited ? { ...limited, sourceType: "docx" } : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function extractHtml(filePath: string): Promise<AttachmentTextExtractionResult | null> {
|
|
99
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
100
|
+
const limited = limitExtractedText(stripHtml(raw));
|
|
101
|
+
return limited ? { ...limited, sourceType: "html" } : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function extractAttachmentText(
|
|
105
|
+
input: AttachmentTextExtractionInput,
|
|
106
|
+
): Promise<AttachmentTextExtractionResult | null> {
|
|
107
|
+
const mimeType = input.mimeType?.toLowerCase();
|
|
108
|
+
const fileName = (input.fileName || path.basename(input.path)).toLowerCase();
|
|
109
|
+
|
|
110
|
+
if (mimeType?.startsWith("image/") || mimeType?.startsWith("audio/") || mimeType?.startsWith("video/")) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (await isFileTooLarge(input.path)) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
|
120
|
+
fileName.endsWith(".docx")
|
|
121
|
+
) {
|
|
122
|
+
return extractDocx(input.path);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (mimeType === "application/pdf" || fileName.endsWith(".pdf")) {
|
|
126
|
+
return extractPdf(input.path);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (mimeType === "text/html" || mimeType === "application/xhtml+xml" || fileName.endsWith(".html") || fileName.endsWith(".htm")) {
|
|
130
|
+
return extractHtml(input.path);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
isTextLikeMimeType(mimeType) ||
|
|
135
|
+
fileName.endsWith(".txt") ||
|
|
136
|
+
fileName.endsWith(".md") ||
|
|
137
|
+
fileName.endsWith(".markdown") ||
|
|
138
|
+
fileName.endsWith(".csv") ||
|
|
139
|
+
fileName.endsWith(".json") ||
|
|
140
|
+
fileName.endsWith(".xml") ||
|
|
141
|
+
fileName.endsWith(".log")
|
|
142
|
+
) {
|
|
143
|
+
return extractTextLikeFile(input.path);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
@@ -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
|
+
}
|