@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,262 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
4
|
+
import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
|
|
5
|
+
import { config } from "./config.js";
|
|
6
|
+
import {
|
|
7
|
+
appendMarkdownToDocument,
|
|
8
|
+
createDocument,
|
|
9
|
+
createDocumentWithMarkdown,
|
|
10
|
+
readDocumentText,
|
|
11
|
+
} from "./feishu-docs.js";
|
|
12
|
+
import { replyLocalPath } from "./feishu-files.js";
|
|
13
|
+
|
|
14
|
+
function asString(v: SDKJsonValue | undefined, name: string): string {
|
|
15
|
+
if (typeof v !== "string" || !v.trim()) {
|
|
16
|
+
throw new Error(`missing string arg: ${name}`);
|
|
17
|
+
}
|
|
18
|
+
return v.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveAgentPath(raw: string): string {
|
|
22
|
+
const expanded = raw.startsWith("~/")
|
|
23
|
+
? path.join(process.env.HOME || "", raw.slice(2))
|
|
24
|
+
: raw;
|
|
25
|
+
const abs = path.isAbsolute(expanded)
|
|
26
|
+
? expanded
|
|
27
|
+
: path.resolve(config.agentCwd, expanded);
|
|
28
|
+
if (!fs.existsSync(abs)) {
|
|
29
|
+
throw new Error(`file not found: ${abs}`);
|
|
30
|
+
}
|
|
31
|
+
return abs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type FeishuToolContext = {
|
|
35
|
+
client: Lark.Client;
|
|
36
|
+
replyToMessageId: string;
|
|
37
|
+
chatId: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** In-process tools exposed to the Cursor agent as custom-user-tools. */
|
|
41
|
+
export function buildFeishuCustomTools(
|
|
42
|
+
ctx: FeishuToolContext,
|
|
43
|
+
): Record<string, SDKCustomTool> {
|
|
44
|
+
return {
|
|
45
|
+
feishu_send_file: {
|
|
46
|
+
description:
|
|
47
|
+
"Send a local file or image to the current Feishu chat as a reply. " +
|
|
48
|
+
"Pass an absolute path or a path relative to AGENT_CWD. Images (.png/.jpg/…) " +
|
|
49
|
+
"are sent as image messages; other types as file messages.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
path: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Local file path to send",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ["path"],
|
|
59
|
+
},
|
|
60
|
+
async execute(args) {
|
|
61
|
+
try {
|
|
62
|
+
const filePath = resolveAgentPath(asString(args.path, "path"));
|
|
63
|
+
const result = await replyLocalPath(
|
|
64
|
+
ctx.client,
|
|
65
|
+
ctx.replyToMessageId,
|
|
66
|
+
filePath,
|
|
67
|
+
);
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text",
|
|
72
|
+
text: JSON.stringify({
|
|
73
|
+
ok: true,
|
|
74
|
+
kind: result.kind,
|
|
75
|
+
fileName: result.fileName,
|
|
76
|
+
path: filePath,
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: `feishu_send_file failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
isError: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
feishu_doc_read: {
|
|
96
|
+
description:
|
|
97
|
+
"Read a Feishu docx document as plain text. Pass a document_id or a full " +
|
|
98
|
+
"https://…/docx/<id> URL. The bot must be a collaborator with read access.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
document: {
|
|
103
|
+
type: "string",
|
|
104
|
+
description: "Document id or Feishu docx URL",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ["document"],
|
|
108
|
+
},
|
|
109
|
+
async execute(args) {
|
|
110
|
+
try {
|
|
111
|
+
const content = await readDocumentText(
|
|
112
|
+
ctx.client,
|
|
113
|
+
asString(args.document, "document"),
|
|
114
|
+
);
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: content || "(empty document)" }],
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `feishu_doc_read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
isError: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
feishu_doc_create: {
|
|
133
|
+
description:
|
|
134
|
+
"Create a new Feishu docx with a title and optional markdown body. " +
|
|
135
|
+
"Optional folder_token places it in a Drive folder (or set FEISHU_DOCS_FOLDER). " +
|
|
136
|
+
"Returns document_id. Bot must have create permission and folder access.",
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
title: { type: "string", description: "Document title" },
|
|
141
|
+
markdown: {
|
|
142
|
+
type: "string",
|
|
143
|
+
description: "Initial markdown content (optional)",
|
|
144
|
+
},
|
|
145
|
+
folder_token: {
|
|
146
|
+
type: "string",
|
|
147
|
+
description: "Optional Drive folder token",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
required: ["title"],
|
|
151
|
+
},
|
|
152
|
+
async execute(args) {
|
|
153
|
+
try {
|
|
154
|
+
const title = asString(args.title, "title");
|
|
155
|
+
const markdown =
|
|
156
|
+
typeof args.markdown === "string" ? args.markdown : "";
|
|
157
|
+
const folderToken =
|
|
158
|
+
(typeof args.folder_token === "string" && args.folder_token.trim()) ||
|
|
159
|
+
config.feishuDocsFolder ||
|
|
160
|
+
undefined;
|
|
161
|
+
|
|
162
|
+
if (!markdown.trim()) {
|
|
163
|
+
const doc = await createDocument(ctx.client, {
|
|
164
|
+
title,
|
|
165
|
+
folderToken,
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
content: [
|
|
169
|
+
{
|
|
170
|
+
type: "text",
|
|
171
|
+
text: JSON.stringify({
|
|
172
|
+
ok: true,
|
|
173
|
+
documentId: doc.documentId,
|
|
174
|
+
title: doc.title,
|
|
175
|
+
urlHint: `docx/${doc.documentId}`,
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const doc = await createDocumentWithMarkdown(ctx.client, {
|
|
183
|
+
title,
|
|
184
|
+
markdown,
|
|
185
|
+
folderToken,
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
content: [
|
|
189
|
+
{
|
|
190
|
+
type: "text",
|
|
191
|
+
text: JSON.stringify({
|
|
192
|
+
ok: true,
|
|
193
|
+
documentId: doc.documentId,
|
|
194
|
+
title: doc.title,
|
|
195
|
+
blockCount: doc.blockCount,
|
|
196
|
+
urlHint: `docx/${doc.documentId}`,
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return {
|
|
203
|
+
content: [
|
|
204
|
+
{
|
|
205
|
+
type: "text",
|
|
206
|
+
text: `feishu_doc_create failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
isError: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
feishu_doc_append: {
|
|
216
|
+
description:
|
|
217
|
+
"Append markdown content to an existing Feishu docx. Pass document_id or URL. " +
|
|
218
|
+
"Bot must be a collaborator with edit permission.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: "object",
|
|
221
|
+
properties: {
|
|
222
|
+
document: {
|
|
223
|
+
type: "string",
|
|
224
|
+
description: "Document id or Feishu docx URL",
|
|
225
|
+
},
|
|
226
|
+
markdown: {
|
|
227
|
+
type: "string",
|
|
228
|
+
description: "Markdown to append",
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
required: ["document", "markdown"],
|
|
232
|
+
},
|
|
233
|
+
async execute(args) {
|
|
234
|
+
try {
|
|
235
|
+
const result = await appendMarkdownToDocument(
|
|
236
|
+
ctx.client,
|
|
237
|
+
asString(args.document, "document"),
|
|
238
|
+
asString(args.markdown, "markdown"),
|
|
239
|
+
);
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: JSON.stringify({ ok: true, ...result }),
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return {
|
|
250
|
+
content: [
|
|
251
|
+
{
|
|
252
|
+
type: "text",
|
|
253
|
+
text: `feishu_doc_append failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
isError: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
package/src/feishu.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import type { AskQuestion, ParsedAskQuestion } from "./ask-question.js";
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
import {
|
|
5
|
+
buildMarkdownCard,
|
|
6
|
+
buildPostMdContent,
|
|
7
|
+
looksLikeMarkdown,
|
|
8
|
+
splitReplyChunks,
|
|
9
|
+
} from "./feishu-markdown.js";
|
|
10
|
+
|
|
11
|
+
export type IncomingMessage = {
|
|
12
|
+
chatId: string;
|
|
13
|
+
chatType: string;
|
|
14
|
+
messageId: string;
|
|
15
|
+
messageType: string;
|
|
16
|
+
content: string;
|
|
17
|
+
senderId?: string;
|
|
18
|
+
senderType?: string;
|
|
19
|
+
mentions: Array<{
|
|
20
|
+
key: string;
|
|
21
|
+
id?: { open_id?: string; user_id?: string };
|
|
22
|
+
name?: string;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function createFeishuClients(options?: {
|
|
27
|
+
onWsError?: (error: unknown) => void;
|
|
28
|
+
}) {
|
|
29
|
+
const baseConfig = {
|
|
30
|
+
appId: config.feishuAppId,
|
|
31
|
+
appSecret: config.feishuAppSecret,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const client = new Lark.Client(baseConfig);
|
|
35
|
+
const wsClient = new Lark.WSClient({
|
|
36
|
+
...baseConfig,
|
|
37
|
+
loggerLevel: Lark.LoggerLevel.info,
|
|
38
|
+
handshakeTimeoutMs: 20_000,
|
|
39
|
+
onError: (error: unknown) => {
|
|
40
|
+
console.error("[ws] terminal error:", error);
|
|
41
|
+
options?.onWsError?.(error);
|
|
42
|
+
},
|
|
43
|
+
onReconnecting: () => {
|
|
44
|
+
console.warn("[ws] reconnecting…");
|
|
45
|
+
},
|
|
46
|
+
onReconnected: () => {
|
|
47
|
+
console.log("[ws] reconnected");
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return { client, wsClient, Lark };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let cachedBotOpenId: string | undefined;
|
|
55
|
+
|
|
56
|
+
export async function getBotOpenId(client: Lark.Client): Promise<string> {
|
|
57
|
+
if (cachedBotOpenId) return cachedBotOpenId;
|
|
58
|
+
|
|
59
|
+
const res = await client.request({
|
|
60
|
+
url: "/open-apis/bot/v3/info",
|
|
61
|
+
method: "GET",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const openId = res?.bot?.open_id ?? res?.data?.bot?.open_id;
|
|
65
|
+
if (!openId || typeof openId !== "string") {
|
|
66
|
+
throw new Error(`Failed to resolve bot open_id: ${JSON.stringify(res)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
cachedBotOpenId = openId;
|
|
70
|
+
return openId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseIncomingMessage(data: {
|
|
74
|
+
message: {
|
|
75
|
+
chat_id: string;
|
|
76
|
+
chat_type: string;
|
|
77
|
+
message_id: string;
|
|
78
|
+
message_type: string;
|
|
79
|
+
content: string;
|
|
80
|
+
mentions?: IncomingMessage["mentions"];
|
|
81
|
+
};
|
|
82
|
+
sender?: {
|
|
83
|
+
sender_id?: { open_id?: string };
|
|
84
|
+
sender_type?: string;
|
|
85
|
+
};
|
|
86
|
+
}): IncomingMessage {
|
|
87
|
+
return {
|
|
88
|
+
chatId: data.message.chat_id,
|
|
89
|
+
chatType: data.message.chat_type,
|
|
90
|
+
messageId: data.message.message_id,
|
|
91
|
+
messageType: data.message.message_type,
|
|
92
|
+
content: data.message.content,
|
|
93
|
+
senderId: data.sender?.sender_id?.open_id,
|
|
94
|
+
senderType: data.sender?.sender_type,
|
|
95
|
+
mentions: data.message.mentions ?? [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Extract plain text and whether the bot was @mentioned. */
|
|
100
|
+
export function extractText(
|
|
101
|
+
msg: IncomingMessage,
|
|
102
|
+
botOpenId: string,
|
|
103
|
+
): { text: string; mentionedBot: boolean } {
|
|
104
|
+
let text = "";
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(msg.content) as { text?: string };
|
|
107
|
+
text = typeof parsed.text === "string" ? parsed.text : "";
|
|
108
|
+
} catch {
|
|
109
|
+
text = msg.content;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const mentionedBot = msg.mentions.some(
|
|
113
|
+
(m) => m.id?.open_id === botOpenId,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Strip Feishu mention placeholders like @_user_1
|
|
117
|
+
for (const mention of msg.mentions) {
|
|
118
|
+
if (mention.key) {
|
|
119
|
+
text = text.replaceAll(mention.key, "");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { text: text.replace(/\s+/g, " ").trim(), mentionedBot };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const HANDLE_MESSAGE_TYPES = new Set(["text", "file", "image", "media"]);
|
|
127
|
+
|
|
128
|
+
export function shouldHandleMessage(
|
|
129
|
+
msg: IncomingMessage,
|
|
130
|
+
mentionedBot: boolean,
|
|
131
|
+
): boolean {
|
|
132
|
+
if (msg.senderType === "app") return false;
|
|
133
|
+
if (!HANDLE_MESSAGE_TYPES.has(msg.messageType)) return false;
|
|
134
|
+
// p2p: always; group/topic: only when @bot
|
|
135
|
+
if (msg.chatType === "p2p") return true;
|
|
136
|
+
return mentionedBot;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function replyText(
|
|
140
|
+
client: Lark.Client,
|
|
141
|
+
messageId: string,
|
|
142
|
+
text: string,
|
|
143
|
+
options?: { preferMarkdown?: boolean },
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
await sendReplyContent(client, messageId, text, options?.preferMarkdown ?? false);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Agent replies: always try Feishu post(md) first — plain text still renders fine. */
|
|
149
|
+
export async function replyAgentText(
|
|
150
|
+
client: Lark.Client,
|
|
151
|
+
messageId: string,
|
|
152
|
+
text: string,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
await sendReplyContent(client, messageId, text, true);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function sendReplyContent(
|
|
158
|
+
client: Lark.Client,
|
|
159
|
+
messageId: string,
|
|
160
|
+
text: string,
|
|
161
|
+
preferMarkdown: boolean,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const useMarkdown = preferMarkdown || looksLikeMarkdown(text);
|
|
164
|
+
const chunks = splitReplyChunks(text, useMarkdown);
|
|
165
|
+
|
|
166
|
+
for (const chunk of chunks) {
|
|
167
|
+
const chunkMarkdown = useMarkdown || looksLikeMarkdown(chunk);
|
|
168
|
+
if (chunkMarkdown) {
|
|
169
|
+
try {
|
|
170
|
+
await client.im.v1.message.reply({
|
|
171
|
+
path: { message_id: messageId },
|
|
172
|
+
data: {
|
|
173
|
+
content: buildPostMdContent(chunk),
|
|
174
|
+
msg_type: "post",
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.warn("[feishu] post(md) failed, try interactive card:", err);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await client.im.v1.message.reply({
|
|
184
|
+
path: { message_id: messageId },
|
|
185
|
+
data: {
|
|
186
|
+
content: JSON.stringify(buildMarkdownCard(chunk)),
|
|
187
|
+
msg_type: "interactive",
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.warn("[feishu] interactive markdown failed, fallback text:", err);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const plain of splitFeishuText(chunk)) {
|
|
197
|
+
await client.im.v1.message.reply({
|
|
198
|
+
path: { message_id: messageId },
|
|
199
|
+
data: {
|
|
200
|
+
content: JSON.stringify({ text: plain }),
|
|
201
|
+
msg_type: "text",
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Ack a user message with a reaction emoji (no chat spam). Returns reaction_id if created. */
|
|
209
|
+
export async function reactToMessage(
|
|
210
|
+
client: Lark.Client,
|
|
211
|
+
messageId: string,
|
|
212
|
+
emojiType = "OnIt",
|
|
213
|
+
): Promise<string | undefined> {
|
|
214
|
+
const res = await client.im.v1.messageReaction.create({
|
|
215
|
+
path: { message_id: messageId },
|
|
216
|
+
data: {
|
|
217
|
+
reaction_type: { emoji_type: emojiType },
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return res?.data?.reaction_id;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Remove a reaction the bot previously added (requires reaction_id from create). */
|
|
224
|
+
export async function removeReaction(
|
|
225
|
+
client: Lark.Client,
|
|
226
|
+
messageId: string,
|
|
227
|
+
reactionId: string,
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
await client.im.v1.messageReaction.delete({
|
|
230
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const REACTION_QUEUED = "OneSecond";
|
|
235
|
+
export const REACTION_WORKING = "OnIt";
|
|
236
|
+
|
|
237
|
+
/** Feishu text messages are safest under ~4k chars; keep headroom. */
|
|
238
|
+
function splitFeishuText(text: string, max = 3500): string[] {
|
|
239
|
+
if (text.length <= max) return [text];
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
let rest = text;
|
|
242
|
+
while (rest.length > max) {
|
|
243
|
+
let cut = rest.lastIndexOf("\n", max);
|
|
244
|
+
if (cut < max * 0.5) cut = max;
|
|
245
|
+
parts.push(rest.slice(0, cut));
|
|
246
|
+
rest = rest.slice(cut).trimStart();
|
|
247
|
+
}
|
|
248
|
+
if (rest) parts.push(rest);
|
|
249
|
+
return parts;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type CardActionValue = {
|
|
253
|
+
kind: "askq";
|
|
254
|
+
sk: string; // sessionKey
|
|
255
|
+
qid: string;
|
|
256
|
+
oid: string;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export function buildAskQuestionCard(
|
|
260
|
+
sessionKey: string,
|
|
261
|
+
ask: ParsedAskQuestion,
|
|
262
|
+
): Record<string, unknown> {
|
|
263
|
+
const title = ask.title?.trim() || "需要你的选择";
|
|
264
|
+
const elements: unknown[] = [];
|
|
265
|
+
|
|
266
|
+
const useButtons =
|
|
267
|
+
ask.questions.length === 1 && !ask.questions[0]!.allowMultiple;
|
|
268
|
+
|
|
269
|
+
for (const [qi, q] of ask.questions.entries()) {
|
|
270
|
+
elements.push({
|
|
271
|
+
tag: "markdown",
|
|
272
|
+
content: `**${qi + 1}. ${q.prompt}**${q.allowMultiple ? "(可多选)" : ""}`,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (useButtons) {
|
|
276
|
+
elements.push({
|
|
277
|
+
tag: "action",
|
|
278
|
+
actions: q.options.map((opt, oi) => ({
|
|
279
|
+
tag: "button",
|
|
280
|
+
text: { tag: "plain_text", content: truncate(opt.label, 40) },
|
|
281
|
+
type: oi === 0 ? "primary" : "default",
|
|
282
|
+
value: {
|
|
283
|
+
kind: "askq",
|
|
284
|
+
sk: sessionKey,
|
|
285
|
+
qid: q.id,
|
|
286
|
+
oid: opt.id,
|
|
287
|
+
} satisfies CardActionValue,
|
|
288
|
+
})),
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
const lines = q.options.map((opt, oi) => `${oi + 1}. ${opt.label}`);
|
|
292
|
+
elements.push({
|
|
293
|
+
tag: "markdown",
|
|
294
|
+
content: lines.join("\n"),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!useButtons) {
|
|
300
|
+
elements.push({
|
|
301
|
+
tag: "markdown",
|
|
302
|
+
content:
|
|
303
|
+
ask.questions.length === 1
|
|
304
|
+
? "请直接回复选项编号(多选如 `1,3`),或回复选项原文。"
|
|
305
|
+
: "请按题号回复,例如:`1:2; 2:1`(题号:选项编号)。",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
schema: "2.0",
|
|
311
|
+
config: { wide_screen_mode: true },
|
|
312
|
+
header: {
|
|
313
|
+
template: "blue",
|
|
314
|
+
title: { tag: "plain_text", content: truncate(title, 50) },
|
|
315
|
+
},
|
|
316
|
+
body: { elements },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function formatAskQuestionFallbackText(ask: ParsedAskQuestion): string {
|
|
321
|
+
const lines = [`【需要选择】${ask.title?.trim() || ""}`.trim()];
|
|
322
|
+
for (const [qi, q] of ask.questions.entries()) {
|
|
323
|
+
lines.push("");
|
|
324
|
+
lines.push(`${qi + 1}. ${q.prompt}${q.allowMultiple ? "(可多选)" : ""}`);
|
|
325
|
+
for (const [oi, opt] of q.options.entries()) {
|
|
326
|
+
lines.push(` ${oi + 1}) ${opt.label}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lines.push("");
|
|
330
|
+
lines.push(
|
|
331
|
+
ask.questions.length === 1
|
|
332
|
+
? "回复编号继续(多选如 1,3),也可点卡片按钮。"
|
|
333
|
+
: "回复格式如 1:2; 2:1,也可点卡片按钮(单题单选)。",
|
|
334
|
+
);
|
|
335
|
+
return lines.join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function replyAskQuestionCard(
|
|
339
|
+
client: Lark.Client,
|
|
340
|
+
messageId: string,
|
|
341
|
+
sessionKey: string,
|
|
342
|
+
ask: ParsedAskQuestion,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
const card = buildAskQuestionCard(sessionKey, ask);
|
|
345
|
+
try {
|
|
346
|
+
await client.im.v1.message.reply({
|
|
347
|
+
path: { message_id: messageId },
|
|
348
|
+
data: {
|
|
349
|
+
content: JSON.stringify(card),
|
|
350
|
+
msg_type: "interactive",
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.warn("[feishu] interactive card failed, falling back to text:", err);
|
|
355
|
+
await replyText(client, messageId, formatAskQuestionFallbackText(ask));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function parseCardActionValue(raw: unknown): CardActionValue | undefined {
|
|
360
|
+
const obj =
|
|
361
|
+
typeof raw === "string"
|
|
362
|
+
? (JSON.parse(raw) as Record<string, unknown>)
|
|
363
|
+
: raw && typeof raw === "object"
|
|
364
|
+
? (raw as Record<string, unknown>)
|
|
365
|
+
: undefined;
|
|
366
|
+
if (!obj || obj.kind !== "askq") return undefined;
|
|
367
|
+
if (
|
|
368
|
+
typeof obj.sk !== "string" ||
|
|
369
|
+
typeof obj.qid !== "string" ||
|
|
370
|
+
typeof obj.oid !== "string"
|
|
371
|
+
) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
return { kind: "askq", sk: obj.sk, qid: obj.qid, oid: obj.oid };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function extractCardAction(data: unknown): {
|
|
378
|
+
value?: CardActionValue;
|
|
379
|
+
chatId?: string;
|
|
380
|
+
messageId?: string;
|
|
381
|
+
openId?: string;
|
|
382
|
+
} {
|
|
383
|
+
const root = data && typeof data === "object" ? (data as Record<string, unknown>) : {};
|
|
384
|
+
const event =
|
|
385
|
+
root.event && typeof root.event === "object"
|
|
386
|
+
? (root.event as Record<string, unknown>)
|
|
387
|
+
: root;
|
|
388
|
+
const action =
|
|
389
|
+
event.action && typeof event.action === "object"
|
|
390
|
+
? (event.action as Record<string, unknown>)
|
|
391
|
+
: undefined;
|
|
392
|
+
const context =
|
|
393
|
+
event.context && typeof event.context === "object"
|
|
394
|
+
? (event.context as Record<string, unknown>)
|
|
395
|
+
: undefined;
|
|
396
|
+
const operator =
|
|
397
|
+
event.operator && typeof event.operator === "object"
|
|
398
|
+
? (event.operator as Record<string, unknown>)
|
|
399
|
+
: undefined;
|
|
400
|
+
|
|
401
|
+
let value: CardActionValue | undefined;
|
|
402
|
+
try {
|
|
403
|
+
value = parseCardActionValue(action?.value);
|
|
404
|
+
} catch {
|
|
405
|
+
value = undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
value,
|
|
410
|
+
chatId:
|
|
411
|
+
(typeof context?.open_chat_id === "string" && context.open_chat_id) ||
|
|
412
|
+
(typeof event.open_chat_id === "string" && event.open_chat_id) ||
|
|
413
|
+
undefined,
|
|
414
|
+
messageId:
|
|
415
|
+
(typeof context?.open_message_id === "string" && context.open_message_id) ||
|
|
416
|
+
undefined,
|
|
417
|
+
openId:
|
|
418
|
+
(typeof operator?.open_id === "string" && operator.open_id) || undefined,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function truncate(text: string, max: number): string {
|
|
423
|
+
if (text.length <= max) return text;
|
|
424
|
+
return `${text.slice(0, max - 1)}…`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function listOptionsText(questions: AskQuestion[]): string {
|
|
428
|
+
return questions
|
|
429
|
+
.map((q, qi) => {
|
|
430
|
+
const opts = q.options.map((o, oi) => ` ${oi + 1}. ${o.label}`).join("\n");
|
|
431
|
+
return `${qi + 1}) ${q.prompt}\n${opts}`;
|
|
432
|
+
})
|
|
433
|
+
.join("\n\n");
|
|
434
|
+
}
|