@chenglu.she/sandy 1.0.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/.env.example +24 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/assets/sandy.png +0 -0
- package/bin/feishu-cursor-bot.js +37 -0
- package/package.json +49 -0
- package/src/ask-question.ts +228 -0
- package/src/config.ts +93 -0
- package/src/cursor-agent.ts +191 -0
- package/src/feishu-docs.ts +117 -0
- package/src/feishu-files.ts +181 -0
- package/src/feishu-markdown.ts +79 -0
- package/src/feishu-tools.ts +262 -0
- package/src/feishu.ts +434 -0
- package/src/index.ts +356 -0
- package/src/pending-store.ts +56 -0
- package/src/session-queue.ts +140 -0
- package/src/session-store.ts +54 -0
- package/src/write-hook-policy.ts +36 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Agent, CursorAgentError, type SDKCustomTool } from "@cursor/sdk";
|
|
2
|
+
import {
|
|
3
|
+
isAskQuestionToolName,
|
|
4
|
+
parseAskQuestionArgs,
|
|
5
|
+
type ParsedAskQuestion,
|
|
6
|
+
} from "./ask-question.js";
|
|
7
|
+
import { config, localAgentOptions } from "./config.js";
|
|
8
|
+
import type { SessionStore } from "./session-store.js";
|
|
9
|
+
|
|
10
|
+
export type AgentFinished = {
|
|
11
|
+
type: "finished";
|
|
12
|
+
text: string;
|
|
13
|
+
agentId: string;
|
|
14
|
+
runId?: string;
|
|
15
|
+
status: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AgentNeedsInput = {
|
|
19
|
+
type: "needs_input";
|
|
20
|
+
agentId: string;
|
|
21
|
+
runId?: string;
|
|
22
|
+
ask: ParsedAskQuestion;
|
|
23
|
+
partialText?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type AgentOutcome = AgentFinished | AgentNeedsInput;
|
|
27
|
+
|
|
28
|
+
export type RunCursorAgentOptions = {
|
|
29
|
+
/** Per-turn Feishu tools (reply target changes each message). */
|
|
30
|
+
customTools?: Record<string, SDKCustomTool>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const RESET_COMMANDS = new Set(["/new", "/reset", "重置", "新对话"]);
|
|
34
|
+
|
|
35
|
+
export function isResetCommand(text: string): boolean {
|
|
36
|
+
return RESET_COMMANDS.has(text.trim().toLowerCase()) || RESET_COMMANDS.has(text.trim());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runCursorAgent(
|
|
40
|
+
sessionStore: SessionStore,
|
|
41
|
+
sessionKey: string,
|
|
42
|
+
prompt: string,
|
|
43
|
+
options?: RunCursorAgentOptions,
|
|
44
|
+
): Promise<AgentOutcome> {
|
|
45
|
+
const existing = sessionStore.get(sessionKey);
|
|
46
|
+
let agent;
|
|
47
|
+
const customTools = options?.customTools;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (existing?.agentId) {
|
|
51
|
+
agent = await Agent.resume(existing.agentId, {
|
|
52
|
+
apiKey: config.cursorApiKey,
|
|
53
|
+
model: { id: config.cursorModel },
|
|
54
|
+
name: config.agentName,
|
|
55
|
+
local: {
|
|
56
|
+
...localAgentOptions(),
|
|
57
|
+
...(customTools ? { customTools } : {}),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
console.log(`[cursor] resumed agent=${existing.agentId} session=${sessionKey}`);
|
|
61
|
+
} else {
|
|
62
|
+
agent = await Agent.create({
|
|
63
|
+
apiKey: config.cursorApiKey,
|
|
64
|
+
model: { id: config.cursorModel },
|
|
65
|
+
name: config.agentName,
|
|
66
|
+
local: {
|
|
67
|
+
...localAgentOptions(),
|
|
68
|
+
...(customTools ? { customTools } : {}),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
sessionStore.set(sessionKey, agent.agentId);
|
|
72
|
+
console.log(`[cursor] created agent=${agent.agentId} session=${sessionKey}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const run = await agent.send(
|
|
76
|
+
prompt,
|
|
77
|
+
customTools ? { local: { customTools } } : undefined,
|
|
78
|
+
);
|
|
79
|
+
console.log(`[cursor] run=${run.id} agent=${agent.agentId}`);
|
|
80
|
+
|
|
81
|
+
let partialText = "";
|
|
82
|
+
let pendingAsk: ParsedAskQuestion | undefined;
|
|
83
|
+
let sawAskQuestion = false;
|
|
84
|
+
|
|
85
|
+
for await (const event of run.stream()) {
|
|
86
|
+
if (event.type === "assistant") {
|
|
87
|
+
for (const block of event.message.content) {
|
|
88
|
+
if (block.type === "text" && block.text) {
|
|
89
|
+
partialText += block.text;
|
|
90
|
+
}
|
|
91
|
+
if (block.type === "tool_use" && isAskQuestionToolName(block.name)) {
|
|
92
|
+
const parsed = parseAskQuestionArgs(block.input);
|
|
93
|
+
if (parsed) {
|
|
94
|
+
pendingAsk = parsed;
|
|
95
|
+
sawAskQuestion = true;
|
|
96
|
+
console.log(
|
|
97
|
+
`[cursor] askQuestion via tool_use questions=${parsed.questions.length}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (event.type === "tool_call" && isAskQuestionToolName(event.name)) {
|
|
105
|
+
const parsed = parseAskQuestionArgs(event.args);
|
|
106
|
+
if (parsed) {
|
|
107
|
+
pendingAsk = parsed;
|
|
108
|
+
sawAskQuestion = true;
|
|
109
|
+
console.log(
|
|
110
|
+
`[cursor] askQuestion tool_call status=${event.status} questions=${parsed.questions.length}`,
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
console.warn(
|
|
114
|
+
"[cursor] askQuestion tool_call with unparsable args:",
|
|
115
|
+
JSON.stringify(event.args)?.slice(0, 500),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Plan A: stop this run and wait for Feishu selection as the next turn.
|
|
120
|
+
if (event.status === "running" && pendingAsk) {
|
|
121
|
+
if (run.supports("cancel")) {
|
|
122
|
+
await run.cancel();
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (event.type === "request") {
|
|
129
|
+
console.log(`[cursor] request event request_id=${event.request_id}`);
|
|
130
|
+
// If we already parsed askQuestion args, cancel & hand off to Feishu.
|
|
131
|
+
if (pendingAsk && run.supports("cancel")) {
|
|
132
|
+
await run.cancel();
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (sawAskQuestion && pendingAsk) {
|
|
139
|
+
// Ensure run settles if cancel didn't already.
|
|
140
|
+
try {
|
|
141
|
+
await run.wait();
|
|
142
|
+
} catch {
|
|
143
|
+
// cancelled runs may reject; ignore
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
type: "needs_input",
|
|
148
|
+
agentId: agent.agentId,
|
|
149
|
+
runId: run.id,
|
|
150
|
+
ask: pendingAsk,
|
|
151
|
+
partialText: partialText.trim() || undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const result = await run.wait();
|
|
156
|
+
const text =
|
|
157
|
+
typeof result.result === "string" && result.result.trim()
|
|
158
|
+
? result.result.trim()
|
|
159
|
+
: partialText.trim()
|
|
160
|
+
? partialText.trim()
|
|
161
|
+
: result.status === "finished"
|
|
162
|
+
? "(agent 已完成,但没有返回文本)"
|
|
163
|
+
: `agent 运行状态: ${result.status}`;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
type: "finished",
|
|
167
|
+
text,
|
|
168
|
+
agentId: agent.agentId,
|
|
169
|
+
runId: run.id,
|
|
170
|
+
status: result.status,
|
|
171
|
+
};
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err instanceof CursorAgentError) {
|
|
174
|
+
if (existing?.agentId) {
|
|
175
|
+
sessionStore.delete(sessionKey);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Cursor agent 启动失败: ${err.message} (retryable=${err.isRetryable})`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
throw err;
|
|
182
|
+
} finally {
|
|
183
|
+
if (agent) {
|
|
184
|
+
await agent[Symbol.asyncDispose]();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function resetSession(sessionStore: SessionStore, sessionKey: string): void {
|
|
190
|
+
sessionStore.delete(sessionKey);
|
|
191
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
|
|
3
|
+
/** Extract docx document_id from a Feishu URL or bare id. */
|
|
4
|
+
export function parseDocumentId(input: string): string {
|
|
5
|
+
const trimmed = input.trim();
|
|
6
|
+
const m = trimmed.match(/\/docx\/([A-Za-z0-9]+)/);
|
|
7
|
+
if (m?.[1]) return m[1];
|
|
8
|
+
if (/^[A-Za-z0-9_-]{10,}$/.test(trimmed)) return trimmed;
|
|
9
|
+
throw new Error(
|
|
10
|
+
`invalid document id/url: ${input} (expect …/docx/<id> or bare document_id)`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readDocumentText(
|
|
15
|
+
client: Lark.Client,
|
|
16
|
+
documentIdOrUrl: string,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const document_id = parseDocumentId(documentIdOrUrl);
|
|
19
|
+
const res = await client.docx.v1.document.rawContent({
|
|
20
|
+
path: { document_id },
|
|
21
|
+
});
|
|
22
|
+
if (res.code && res.code !== 0) {
|
|
23
|
+
throw new Error(`read doc failed: code=${res.code} msg=${res.msg}`);
|
|
24
|
+
}
|
|
25
|
+
return res.data?.content ?? "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function createDocument(
|
|
29
|
+
client: Lark.Client,
|
|
30
|
+
options: { title: string; folderToken?: string },
|
|
31
|
+
): Promise<{ documentId: string; title: string }> {
|
|
32
|
+
const res = await client.docx.v1.document.create({
|
|
33
|
+
data: {
|
|
34
|
+
title: options.title,
|
|
35
|
+
...(options.folderToken ? { folder_token: options.folderToken } : {}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (res.code && res.code !== 0) {
|
|
39
|
+
throw new Error(`create doc failed: code=${res.code} msg=${res.msg}`);
|
|
40
|
+
}
|
|
41
|
+
const documentId = res.data?.document?.document_id;
|
|
42
|
+
if (!documentId) {
|
|
43
|
+
throw new Error(`create doc returned no document_id: ${JSON.stringify(res)}`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
documentId,
|
|
47
|
+
title: res.data?.document?.title || options.title,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert markdown and insert as nested blocks under the page root
|
|
53
|
+
* (block_id === document_id for docx).
|
|
54
|
+
*/
|
|
55
|
+
export async function appendMarkdownToDocument(
|
|
56
|
+
client: Lark.Client,
|
|
57
|
+
documentIdOrUrl: string,
|
|
58
|
+
markdown: string,
|
|
59
|
+
): Promise<{ blockCount: number }> {
|
|
60
|
+
const document_id = parseDocumentId(documentIdOrUrl);
|
|
61
|
+
const md = markdown.trim();
|
|
62
|
+
if (!md) throw new Error("markdown content is empty");
|
|
63
|
+
|
|
64
|
+
const converted = await client.docx.v1.document.convert({
|
|
65
|
+
data: {
|
|
66
|
+
content_type: "markdown",
|
|
67
|
+
content: md,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
if (converted.code && converted.code !== 0) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`convert markdown failed: code=${converted.code} msg=${converted.msg}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const childrenId = converted.data?.first_level_block_ids ?? [];
|
|
77
|
+
const descendants = converted.data?.blocks ?? [];
|
|
78
|
+
if (childrenId.length === 0 || descendants.length === 0) {
|
|
79
|
+
throw new Error("convert markdown produced no blocks");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Insert in chunks of <= 1000 blocks if needed; convert already returns tree.
|
|
83
|
+
const res = await client.docx.v1.documentBlockDescendant.create({
|
|
84
|
+
path: {
|
|
85
|
+
document_id,
|
|
86
|
+
block_id: document_id,
|
|
87
|
+
},
|
|
88
|
+
data: {
|
|
89
|
+
children_id: childrenId,
|
|
90
|
+
// convert() returns a loose block tree; the create payload is generated and huge.
|
|
91
|
+
descendants: descendants as never,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
if (res.code && res.code !== 0) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`append blocks failed: code=${res.code} msg=${res.msg} — 确认机器人已是文档协作者且有编辑权限`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { blockCount: descendants.length };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function createDocumentWithMarkdown(
|
|
104
|
+
client: Lark.Client,
|
|
105
|
+
options: { title: string; markdown: string; folderToken?: string },
|
|
106
|
+
): Promise<{ documentId: string; title: string; blockCount: number }> {
|
|
107
|
+
const doc = await createDocument(client, {
|
|
108
|
+
title: options.title,
|
|
109
|
+
folderToken: options.folderToken,
|
|
110
|
+
});
|
|
111
|
+
const { blockCount } = await appendMarkdownToDocument(
|
|
112
|
+
client,
|
|
113
|
+
doc.documentId,
|
|
114
|
+
options.markdown,
|
|
115
|
+
);
|
|
116
|
+
return { ...doc, blockCount };
|
|
117
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
4
|
+
|
|
5
|
+
export type FeishuUploadFileType =
|
|
6
|
+
| "opus"
|
|
7
|
+
| "mp4"
|
|
8
|
+
| "pdf"
|
|
9
|
+
| "doc"
|
|
10
|
+
| "xls"
|
|
11
|
+
| "ppt"
|
|
12
|
+
| "stream";
|
|
13
|
+
|
|
14
|
+
const EXT_TO_TYPE: Record<string, FeishuUploadFileType> = {
|
|
15
|
+
".opus": "opus",
|
|
16
|
+
".mp4": "mp4",
|
|
17
|
+
".pdf": "pdf",
|
|
18
|
+
".doc": "doc",
|
|
19
|
+
".docx": "doc",
|
|
20
|
+
".xls": "xls",
|
|
21
|
+
".xlsx": "xls",
|
|
22
|
+
".ppt": "ppt",
|
|
23
|
+
".pptx": "ppt",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function guessFileType(fileName: string): FeishuUploadFileType {
|
|
27
|
+
return EXT_TO_TYPE[path.extname(fileName).toLowerCase()] ?? "stream";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Upload a local file; returns file_key for sending as a message. */
|
|
31
|
+
export async function uploadFile(
|
|
32
|
+
client: Lark.Client,
|
|
33
|
+
filePath: string,
|
|
34
|
+
options?: { fileName?: string; fileType?: FeishuUploadFileType },
|
|
35
|
+
): Promise<{ fileKey: string; fileName: string }> {
|
|
36
|
+
const fileName = options?.fileName || path.basename(filePath);
|
|
37
|
+
const fileType = options?.fileType || guessFileType(fileName);
|
|
38
|
+
const buf = fs.readFileSync(filePath);
|
|
39
|
+
if (buf.length === 0) throw new Error(`empty file: ${filePath}`);
|
|
40
|
+
if (buf.length > 30 * 1024 * 1024) {
|
|
41
|
+
throw new Error(`file too large (>30MB): ${filePath}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const res = await client.im.v1.file.create({
|
|
45
|
+
data: {
|
|
46
|
+
file_type: fileType,
|
|
47
|
+
file_name: fileName,
|
|
48
|
+
file: buf,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const fileKey = res?.file_key;
|
|
53
|
+
if (!fileKey) {
|
|
54
|
+
throw new Error(`upload file failed: ${JSON.stringify(res)}`);
|
|
55
|
+
}
|
|
56
|
+
return { fileKey, fileName };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Upload a local image; returns image_key. */
|
|
60
|
+
export async function uploadImage(
|
|
61
|
+
client: Lark.Client,
|
|
62
|
+
filePath: string,
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
const buf = fs.readFileSync(filePath);
|
|
65
|
+
if (buf.length === 0) throw new Error(`empty image: ${filePath}`);
|
|
66
|
+
if (buf.length > 10 * 1024 * 1024) {
|
|
67
|
+
throw new Error(`image too large (>10MB): ${filePath}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const res = await client.im.v1.image.create({
|
|
71
|
+
data: {
|
|
72
|
+
image_type: "message",
|
|
73
|
+
image: buf,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const imageKey = res?.image_key;
|
|
78
|
+
if (!imageKey) {
|
|
79
|
+
throw new Error(`upload image failed: ${JSON.stringify(res)}`);
|
|
80
|
+
}
|
|
81
|
+
return imageKey;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Reply with a file message. */
|
|
85
|
+
export async function replyFile(
|
|
86
|
+
client: Lark.Client,
|
|
87
|
+
messageId: string,
|
|
88
|
+
filePath: string,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const { fileKey } = await uploadFile(client, filePath);
|
|
91
|
+
await client.im.v1.message.reply({
|
|
92
|
+
path: { message_id: messageId },
|
|
93
|
+
data: {
|
|
94
|
+
msg_type: "file",
|
|
95
|
+
content: JSON.stringify({ file_key: fileKey }),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Reply with an image message. */
|
|
101
|
+
export async function replyImage(
|
|
102
|
+
client: Lark.Client,
|
|
103
|
+
messageId: string,
|
|
104
|
+
filePath: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const imageKey = await uploadImage(client, filePath);
|
|
107
|
+
await client.im.v1.message.reply({
|
|
108
|
+
path: { message_id: messageId },
|
|
109
|
+
data: {
|
|
110
|
+
msg_type: "image",
|
|
111
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const IMAGE_EXTS = new Set([
|
|
117
|
+
".png",
|
|
118
|
+
".jpg",
|
|
119
|
+
".jpeg",
|
|
120
|
+
".gif",
|
|
121
|
+
".webp",
|
|
122
|
+
".bmp",
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
export function isImagePath(filePath: string): boolean {
|
|
126
|
+
return IMAGE_EXTS.has(path.extname(filePath).toLowerCase());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function replyLocalPath(
|
|
130
|
+
client: Lark.Client,
|
|
131
|
+
messageId: string,
|
|
132
|
+
filePath: string,
|
|
133
|
+
): Promise<{ kind: "file" | "image"; fileName: string }> {
|
|
134
|
+
const fileName = path.basename(filePath);
|
|
135
|
+
if (isImagePath(filePath)) {
|
|
136
|
+
await replyImage(client, messageId, filePath);
|
|
137
|
+
return { kind: "image", fileName };
|
|
138
|
+
}
|
|
139
|
+
await replyFile(client, messageId, filePath);
|
|
140
|
+
return { kind: "file", fileName };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Download a resource attached to an incoming Feishu message. */
|
|
144
|
+
export async function downloadMessageResource(
|
|
145
|
+
client: Lark.Client,
|
|
146
|
+
messageId: string,
|
|
147
|
+
fileKey: string,
|
|
148
|
+
type: "file" | "image" | "media",
|
|
149
|
+
destPath: string,
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
152
|
+
const resp = await client.im.v1.messageResource.get({
|
|
153
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
154
|
+
params: { type },
|
|
155
|
+
});
|
|
156
|
+
await resp.writeFile(destPath);
|
|
157
|
+
return destPath;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseIncomingFileContent(content: string): {
|
|
161
|
+
fileKey?: string;
|
|
162
|
+
imageKey?: string;
|
|
163
|
+
fileName?: string;
|
|
164
|
+
} {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
167
|
+
return {
|
|
168
|
+
fileKey: typeof parsed.file_key === "string" ? parsed.file_key : undefined,
|
|
169
|
+
imageKey:
|
|
170
|
+
typeof parsed.image_key === "string" ? parsed.image_key : undefined,
|
|
171
|
+
fileName:
|
|
172
|
+
typeof parsed.file_name === "string"
|
|
173
|
+
? parsed.file_name
|
|
174
|
+
: typeof parsed.name === "string"
|
|
175
|
+
? parsed.name
|
|
176
|
+
: undefined,
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Heuristic: does this look like markdown worth rendering? */
|
|
2
|
+
export function looksLikeMarkdown(text: string): boolean {
|
|
3
|
+
if (!text || text.trim().length < 2) return false;
|
|
4
|
+
|
|
5
|
+
const t = text.trim();
|
|
6
|
+
if (/```[\s\S]*?```/.test(t)) return true;
|
|
7
|
+
if (/`[^`\n]+`/.test(t)) return true;
|
|
8
|
+
if (/^#{1,6}\s+/m.test(t)) return true;
|
|
9
|
+
if (/\*\*[^*\n]+\*\*/.test(t)) return true;
|
|
10
|
+
if (/(^|\s)\*[^*\n]+\*(\s|$)/.test(t)) return true;
|
|
11
|
+
if (/^[-*+]\s+/m.test(t)) return true;
|
|
12
|
+
if (/^\d+\.\s+/m.test(t)) return true;
|
|
13
|
+
if (/\[[^\]]+\]\([^)]+\)/.test(t)) return true;
|
|
14
|
+
if (/^\|.+\|/m.test(t)) return true;
|
|
15
|
+
if (/^>\s+/m.test(t)) return true;
|
|
16
|
+
if (/~~[^~]+~~/.test(t)) return true;
|
|
17
|
+
|
|
18
|
+
// Multi-paragraph agent replies often contain markdown even if subtle
|
|
19
|
+
if (t.includes("\n") && t.length > 120) return true;
|
|
20
|
+
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Agent replies: prefer rendered markdown whenever plausible. */
|
|
25
|
+
export function shouldRenderAgentReply(text: string): boolean {
|
|
26
|
+
return looksLikeMarkdown(text) || text.includes("\n\n") || text.length > 400;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Feishu post message with md tag (CommonMark + GFM). */
|
|
30
|
+
export function buildPostMdContent(text: string): string {
|
|
31
|
+
return JSON.stringify({
|
|
32
|
+
zh_cn: {
|
|
33
|
+
content: [[{ tag: "md", text: optimizeMarkdownForFeishu(text) }]],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Feishu post renders H1/H2 oversized — demote headings slightly. */
|
|
39
|
+
function optimizeMarkdownForFeishu(text: string): string {
|
|
40
|
+
if (!/^#{1,3} /m.test(text)) return text;
|
|
41
|
+
let r = text.replace(/^#{2,6} (.+)$/gm, "##### $1");
|
|
42
|
+
r = r.replace(/^# (.+)$/gm, "#### $1");
|
|
43
|
+
return r.replace(/\n{3,}/g, "\n\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Interactive card 2.0 with markdown element (fallback). */
|
|
47
|
+
export function buildMarkdownCard(text: string): Record<string, unknown> {
|
|
48
|
+
return {
|
|
49
|
+
schema: "2.0",
|
|
50
|
+
config: { wide_screen_mode: true },
|
|
51
|
+
body: {
|
|
52
|
+
elements: [
|
|
53
|
+
{
|
|
54
|
+
tag: "markdown",
|
|
55
|
+
content: text,
|
|
56
|
+
text_align: "left",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Split long replies; post/card limit ~30KB, keep headroom. */
|
|
64
|
+
export function splitReplyChunks(text: string, markdown: boolean): string[] {
|
|
65
|
+
const max = markdown ? 28_000 : 3_500;
|
|
66
|
+
if (text.length <= max) return [text];
|
|
67
|
+
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
let rest = text;
|
|
70
|
+
while (rest.length > max) {
|
|
71
|
+
let cut = rest.lastIndexOf("\n\n", max);
|
|
72
|
+
if (cut < max * 0.4) cut = rest.lastIndexOf("\n", max);
|
|
73
|
+
if (cut < max * 0.4) cut = max;
|
|
74
|
+
parts.push(rest.slice(0, cut));
|
|
75
|
+
rest = rest.slice(cut).trimStart();
|
|
76
|
+
}
|
|
77
|
+
if (rest) parts.push(rest);
|
|
78
|
+
return parts;
|
|
79
|
+
}
|