@nextclaw/channel-plugin-feishu 0.2.18 → 0.2.20
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/package.json +1 -1
- package/src/reply-dispatcher.finalization.test.ts +218 -0
- package/src/reply-dispatcher.streaming-placeholder.test.ts +200 -0
- package/src/reply-dispatcher.test.ts +0 -249
- package/src/reply-dispatcher.threading.test.ts +205 -0
- package/src/reply-dispatcher.ts +0 -3
- package/src/sheets-shared.ts +318 -0
- package/src/sheets.ts +11 -340
- package/src/typing.ts +7 -4
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
|
8
|
+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
9
|
+
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
|
10
|
+
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
|
|
12
|
+
const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
13
|
+
const streamingInstances = vi.hoisted(() => [] as any[]);
|
|
14
|
+
|
|
15
|
+
vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
|
|
16
|
+
vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
|
|
17
|
+
vi.mock("./send.js", () => ({
|
|
18
|
+
sendMessageFeishu: sendMessageFeishuMock,
|
|
19
|
+
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
|
|
22
|
+
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
|
|
23
|
+
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
|
|
24
|
+
vi.mock("./typing.js", () => ({
|
|
25
|
+
addTypingIndicator: addTypingIndicatorMock,
|
|
26
|
+
removeTypingIndicator: removeTypingIndicatorMock,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("./streaming-card.js", async () => {
|
|
29
|
+
const actual = await vi.importActual<typeof import("./streaming-card.js")>("./streaming-card.js");
|
|
30
|
+
return {
|
|
31
|
+
mergeStreamingText: actual.mergeStreamingText,
|
|
32
|
+
FeishuStreamingSession: class {
|
|
33
|
+
active = false;
|
|
34
|
+
start = vi.fn(async () => {
|
|
35
|
+
this.active = true;
|
|
36
|
+
});
|
|
37
|
+
update = vi.fn(async () => {});
|
|
38
|
+
close = vi.fn(async () => {
|
|
39
|
+
this.active = false;
|
|
40
|
+
});
|
|
41
|
+
isActive = vi.fn(() => this.active);
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
streamingInstances.push(this);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
51
|
+
|
|
52
|
+
describe("createFeishuReplyDispatcher threading behavior", () => {
|
|
53
|
+
type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
streamingInstances.length = 0;
|
|
58
|
+
sendMediaFeishuMock.mockResolvedValue(undefined);
|
|
59
|
+
|
|
60
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
61
|
+
accountId: "main",
|
|
62
|
+
appId: "app_id",
|
|
63
|
+
appSecret: "app_secret",
|
|
64
|
+
domain: "feishu",
|
|
65
|
+
config: {
|
|
66
|
+
renderMode: "auto",
|
|
67
|
+
streaming: true,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
resolveReceiveIdTypeMock.mockReturnValue("chat_id");
|
|
72
|
+
createFeishuClientMock.mockReturnValue({});
|
|
73
|
+
|
|
74
|
+
createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
|
|
75
|
+
dispatcher: {},
|
|
76
|
+
replyOptions: {},
|
|
77
|
+
markDispatchIdle: vi.fn(),
|
|
78
|
+
_opts: opts,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
getFeishuRuntimeMock.mockReturnValue({
|
|
82
|
+
channel: {
|
|
83
|
+
text: {
|
|
84
|
+
resolveTextChunkLimit: vi.fn(() => 4000),
|
|
85
|
+
resolveChunkMode: vi.fn(() => "line"),
|
|
86
|
+
resolveMarkdownTableMode: vi.fn(() => "preserve"),
|
|
87
|
+
convertMarkdownTables: vi.fn((text) => text),
|
|
88
|
+
chunkTextWithMode: vi.fn((text) => [text]),
|
|
89
|
+
},
|
|
90
|
+
reply: {
|
|
91
|
+
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
|
|
92
|
+
resolveHumanDelayConfig: vi.fn(() => undefined),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function createRuntimeLogger() {
|
|
99
|
+
return { log: vi.fn(), error: vi.fn() } as never;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
|
|
103
|
+
createFeishuReplyDispatcher({
|
|
104
|
+
cfg: {} as never,
|
|
105
|
+
agentId: "agent",
|
|
106
|
+
runtime: {} as never,
|
|
107
|
+
chatId: "oc_chat",
|
|
108
|
+
...overrides,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
|
|
117
|
+
const { options } = createDispatcherHarness({
|
|
118
|
+
replyToMessageId: "om_msg",
|
|
119
|
+
replyInThread: true,
|
|
120
|
+
});
|
|
121
|
+
await options.deliver({ text: "plain text" }, { kind: "final" });
|
|
122
|
+
|
|
123
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
replyToMessageId: "om_msg",
|
|
126
|
+
replyInThread: true,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
|
|
132
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
133
|
+
accountId: "main",
|
|
134
|
+
appId: "app_id",
|
|
135
|
+
appSecret: "app_secret",
|
|
136
|
+
domain: "feishu",
|
|
137
|
+
config: {
|
|
138
|
+
renderMode: "card",
|
|
139
|
+
streaming: false,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const { options } = createDispatcherHarness({
|
|
144
|
+
replyToMessageId: "om_msg",
|
|
145
|
+
replyInThread: true,
|
|
146
|
+
});
|
|
147
|
+
await options.deliver({ text: "card text" }, { kind: "final" });
|
|
148
|
+
|
|
149
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
replyToMessageId: "om_msg",
|
|
152
|
+
replyInThread: true,
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
|
158
|
+
const { options } = createDispatcherHarness({
|
|
159
|
+
runtime: createRuntimeLogger(),
|
|
160
|
+
replyToMessageId: "om_msg",
|
|
161
|
+
replyInThread: true,
|
|
162
|
+
});
|
|
163
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
164
|
+
|
|
165
|
+
expect(streamingInstances).toHaveLength(1);
|
|
166
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
|
167
|
+
replyToMessageId: "om_msg",
|
|
168
|
+
replyInThread: true,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("disables streaming for thread replies and keeps reply metadata", async () => {
|
|
173
|
+
const { options } = createDispatcherHarness({
|
|
174
|
+
runtime: createRuntimeLogger(),
|
|
175
|
+
replyToMessageId: "om_msg",
|
|
176
|
+
replyInThread: false,
|
|
177
|
+
threadReply: true,
|
|
178
|
+
rootId: "om_root_topic",
|
|
179
|
+
});
|
|
180
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
181
|
+
|
|
182
|
+
expect(streamingInstances).toHaveLength(0);
|
|
183
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
184
|
+
expect.objectContaining({
|
|
185
|
+
replyToMessageId: "om_msg",
|
|
186
|
+
replyInThread: true,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("passes replyInThread to media attachments", async () => {
|
|
192
|
+
const { options } = createDispatcherHarness({
|
|
193
|
+
replyToMessageId: "om_msg",
|
|
194
|
+
replyInThread: true,
|
|
195
|
+
});
|
|
196
|
+
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
|
197
|
+
|
|
198
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
replyToMessageId: "om_msg",
|
|
201
|
+
replyInThread: true,
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -266,9 +266,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
266
266
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
267
267
|
onReplyStart: () => {
|
|
268
268
|
deliveredFinalTexts.clear();
|
|
269
|
-
if (streamingEnabled && renderMode === "card") {
|
|
270
|
-
startStreaming();
|
|
271
|
-
}
|
|
272
269
|
void typingCallbacks.onReplyStart?.();
|
|
273
270
|
},
|
|
274
271
|
deliver: async (payload: ReplyPayload, info) => {
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type { createToolContext } from "./user-tool-helpers.js";
|
|
2
|
+
import { assertLarkOk, json } from "./user-tool-helpers.js";
|
|
3
|
+
import { wwwDomain } from "./domains.js";
|
|
4
|
+
|
|
5
|
+
const MAX_READ_ROWS = 200;
|
|
6
|
+
const MAX_WRITE_ROWS = 5000;
|
|
7
|
+
const MAX_WRITE_COLS = 100;
|
|
8
|
+
|
|
9
|
+
type UserToolClient = ReturnType<ReturnType<typeof createToolContext>["toolClient"]>;
|
|
10
|
+
|
|
11
|
+
export type SheetParams = {
|
|
12
|
+
action: "info" | "read" | "write" | "append" | "find" | "create" | "export";
|
|
13
|
+
url?: string;
|
|
14
|
+
spreadsheet_token?: string;
|
|
15
|
+
range?: string;
|
|
16
|
+
sheet_id?: string;
|
|
17
|
+
value_render_option?: "FormattedValue" | "UnformattedValue" | "Formula" | "ToString";
|
|
18
|
+
values?: unknown[][];
|
|
19
|
+
find?: string;
|
|
20
|
+
match_case?: boolean;
|
|
21
|
+
match_entire_cell?: boolean;
|
|
22
|
+
search_by_regex?: boolean;
|
|
23
|
+
include_formulas?: boolean;
|
|
24
|
+
title?: string;
|
|
25
|
+
folder_token?: string;
|
|
26
|
+
headers?: unknown[];
|
|
27
|
+
data?: unknown[][];
|
|
28
|
+
file_extension?: "xlsx" | "csv";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type SheetTokenInfo = {
|
|
32
|
+
token: string;
|
|
33
|
+
sheetId?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function parseSheetUrl(url: string): SheetTokenInfo | null {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = new URL(url);
|
|
39
|
+
const match = parsed.pathname.match(/\/sheets\/([^/?#]+)/);
|
|
40
|
+
if (!match) return null;
|
|
41
|
+
return { token: match[1], sheetId: parsed.searchParams.get("sheet") || undefined };
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveToken(params: SheetParams): SheetTokenInfo | null {
|
|
48
|
+
if (params.spreadsheet_token) return { token: params.spreadsheet_token };
|
|
49
|
+
if (params.url) return parseSheetUrl(params.url);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function colLetter(n: number): string {
|
|
54
|
+
let result = "";
|
|
55
|
+
let value = n;
|
|
56
|
+
while (value > 0) {
|
|
57
|
+
value -= 1;
|
|
58
|
+
result = String.fromCharCode(65 + (value % 26)) + result;
|
|
59
|
+
value = Math.floor(value / 26);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function flattenCellValue(cell: unknown): unknown {
|
|
65
|
+
if (!Array.isArray(cell)) return cell;
|
|
66
|
+
if (cell.length > 0 && cell.every((segment) => segment && typeof segment === "object" && "text" in (segment as object))) {
|
|
67
|
+
return cell.map((segment) => String((segment as { text?: unknown }).text ?? "")).join("");
|
|
68
|
+
}
|
|
69
|
+
return cell;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resolveSheetRange(
|
|
73
|
+
client: UserToolClient,
|
|
74
|
+
token: string,
|
|
75
|
+
range?: string,
|
|
76
|
+
sheetId?: string,
|
|
77
|
+
) {
|
|
78
|
+
if (range) return range;
|
|
79
|
+
if (sheetId) return sheetId;
|
|
80
|
+
const response = await client.invoke(
|
|
81
|
+
"feishu_sheet.info",
|
|
82
|
+
(sdk, opts) => sdk.sheets.spreadsheetSheet.query({ path: { spreadsheet_token: token } }, opts),
|
|
83
|
+
{ as: "user" },
|
|
84
|
+
);
|
|
85
|
+
assertLarkOk(response);
|
|
86
|
+
const firstSheet = (response.data as { sheets?: Array<{ sheet_id?: string }> } | undefined)?.sheets?.[0];
|
|
87
|
+
if (!firstSheet?.sheet_id) {
|
|
88
|
+
throw new Error("Spreadsheet has no worksheets.");
|
|
89
|
+
}
|
|
90
|
+
return firstSheet.sheet_id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function handleCreate(client: UserToolClient, payload: SheetParams) {
|
|
94
|
+
const createResponse = await client.invoke(
|
|
95
|
+
"feishu_sheet.create",
|
|
96
|
+
(sdk, opts) =>
|
|
97
|
+
sdk.sheets.spreadsheet.create(
|
|
98
|
+
{ data: { title: payload.title, folder_token: payload.folder_token } },
|
|
99
|
+
opts,
|
|
100
|
+
),
|
|
101
|
+
{ as: "user" },
|
|
102
|
+
);
|
|
103
|
+
assertLarkOk(createResponse);
|
|
104
|
+
const spreadsheet = (createResponse.data as { spreadsheet?: { spreadsheet_token?: string; title?: string } } | undefined)?.spreadsheet;
|
|
105
|
+
const token = spreadsheet?.spreadsheet_token;
|
|
106
|
+
if (!token) {
|
|
107
|
+
return json({ error: "创建电子表格失败,未返回 spreadsheet_token。" });
|
|
108
|
+
}
|
|
109
|
+
const allRows = [
|
|
110
|
+
...(payload.headers ? [payload.headers] : []),
|
|
111
|
+
...((payload.data as unknown[][] | undefined) ?? []),
|
|
112
|
+
];
|
|
113
|
+
if (allRows.length > 0) {
|
|
114
|
+
const sheetsResponse = await client.invoke(
|
|
115
|
+
"feishu_sheet.create",
|
|
116
|
+
(sdk, opts) => sdk.sheets.spreadsheetSheet.query({ path: { spreadsheet_token: token } }, opts),
|
|
117
|
+
{ as: "user" },
|
|
118
|
+
);
|
|
119
|
+
assertLarkOk(sheetsResponse);
|
|
120
|
+
const firstSheet = (sheetsResponse.data as { sheets?: Array<{ sheet_id?: string }> } | undefined)?.sheets?.[0];
|
|
121
|
+
if (firstSheet?.sheet_id) {
|
|
122
|
+
const numCols = Math.max(...allRows.map((row) => row.length), 1);
|
|
123
|
+
const range = `${firstSheet.sheet_id}!A1:${colLetter(numCols)}${allRows.length}`;
|
|
124
|
+
await client.invokeByPath("feishu_sheet.create", `/open-apis/sheets/v2/spreadsheets/${token}/values`, {
|
|
125
|
+
method: "PUT",
|
|
126
|
+
body: { valueRange: { range, values: allRows } },
|
|
127
|
+
as: "user",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return json({
|
|
132
|
+
spreadsheet_token: token,
|
|
133
|
+
title: spreadsheet?.title ?? payload.title,
|
|
134
|
+
url: `${wwwDomain(client.account.domain)}/sheets/${token}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function handleInfo(client: UserToolClient, token: string) {
|
|
139
|
+
const [spreadsheetRes, sheetsRes] = await Promise.all([
|
|
140
|
+
client.invoke(
|
|
141
|
+
"feishu_sheet.info",
|
|
142
|
+
(sdk, opts) => sdk.sheets.spreadsheet.get({ path: { spreadsheet_token: token } }, opts),
|
|
143
|
+
{ as: "user" },
|
|
144
|
+
),
|
|
145
|
+
client.invoke(
|
|
146
|
+
"feishu_sheet.info",
|
|
147
|
+
(sdk, opts) => sdk.sheets.spreadsheetSheet.query({ path: { spreadsheet_token: token } }, opts),
|
|
148
|
+
{ as: "user" },
|
|
149
|
+
),
|
|
150
|
+
]);
|
|
151
|
+
assertLarkOk(spreadsheetRes);
|
|
152
|
+
assertLarkOk(sheetsRes);
|
|
153
|
+
return json({
|
|
154
|
+
title: (spreadsheetRes.data as { spreadsheet?: { title?: string } } | undefined)?.spreadsheet?.title,
|
|
155
|
+
spreadsheet_token: token,
|
|
156
|
+
url: `${wwwDomain(client.account.domain)}/sheets/${token}`,
|
|
157
|
+
sheets:
|
|
158
|
+
((sheetsRes.data as { sheets?: Array<Record<string, unknown>> } | undefined)?.sheets ?? []).map((sheet) => ({
|
|
159
|
+
sheet_id: sheet.sheet_id,
|
|
160
|
+
title: sheet.title,
|
|
161
|
+
index: sheet.index,
|
|
162
|
+
row_count: (sheet.grid_properties as { row_count?: number } | undefined)?.row_count,
|
|
163
|
+
column_count: (sheet.grid_properties as { column_count?: number } | undefined)?.column_count,
|
|
164
|
+
})),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function handleRead(client: UserToolClient, tokenInfo: SheetTokenInfo, payload: SheetParams) {
|
|
169
|
+
const range = await resolveSheetRange(client, tokenInfo.token, payload.range, payload.sheet_id ?? tokenInfo.sheetId);
|
|
170
|
+
const response = await client.invokeByPath<{
|
|
171
|
+
data?: { valueRange?: { range?: string; values?: unknown[][] } };
|
|
172
|
+
}>("feishu_sheet.read", `/open-apis/sheets/v2/spreadsheets/${tokenInfo.token}/values/${encodeURIComponent(range)}`, {
|
|
173
|
+
method: "GET",
|
|
174
|
+
query: {
|
|
175
|
+
valueRenderOption: payload.value_render_option ?? "ToString",
|
|
176
|
+
dateTimeRenderOption: "FormattedString",
|
|
177
|
+
},
|
|
178
|
+
as: "user",
|
|
179
|
+
});
|
|
180
|
+
const values = (response.data?.valueRange?.values ?? []).map((row) => row.map(flattenCellValue));
|
|
181
|
+
return json({
|
|
182
|
+
range: response.data?.valueRange?.range,
|
|
183
|
+
values: values.slice(0, MAX_READ_ROWS),
|
|
184
|
+
...(values.length > MAX_READ_ROWS
|
|
185
|
+
? {
|
|
186
|
+
truncated: true,
|
|
187
|
+
total_rows: values.length,
|
|
188
|
+
hint: `结果超过 ${MAX_READ_ROWS} 行,已截断。请缩小 range 后重试。`,
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function handleWrite(client: UserToolClient, tokenInfo: SheetTokenInfo, payload: SheetParams) {
|
|
195
|
+
if ((payload.values?.length ?? 0) > MAX_WRITE_ROWS) {
|
|
196
|
+
return json({ error: `写入行数超过限制 ${MAX_WRITE_ROWS}` });
|
|
197
|
+
}
|
|
198
|
+
if ((payload.values ?? []).some((row) => row.length > MAX_WRITE_COLS)) {
|
|
199
|
+
return json({ error: `写入列数超过限制 ${MAX_WRITE_COLS}` });
|
|
200
|
+
}
|
|
201
|
+
const range = await resolveSheetRange(client, tokenInfo.token, payload.range, payload.sheet_id ?? tokenInfo.sheetId);
|
|
202
|
+
const response = await client.invokeByPath<{
|
|
203
|
+
data?: {
|
|
204
|
+
updatedRange?: string;
|
|
205
|
+
updatedRows?: number;
|
|
206
|
+
updatedColumns?: number;
|
|
207
|
+
updatedCells?: number;
|
|
208
|
+
revision?: number;
|
|
209
|
+
updates?: {
|
|
210
|
+
updatedRange?: string;
|
|
211
|
+
updatedRows?: number;
|
|
212
|
+
updatedColumns?: number;
|
|
213
|
+
updatedCells?: number;
|
|
214
|
+
revision?: number;
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
}>(
|
|
218
|
+
payload.action === "write" ? "feishu_sheet.write" : "feishu_sheet.append",
|
|
219
|
+
`/open-apis/sheets/v2/spreadsheets/${tokenInfo.token}/${payload.action === "write" ? "values" : "values_append"}`,
|
|
220
|
+
{
|
|
221
|
+
method: payload.action === "write" ? "PUT" : "POST",
|
|
222
|
+
body: { valueRange: { range, values: payload.values } },
|
|
223
|
+
as: "user",
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
const updates = response.data?.updates ?? response.data;
|
|
227
|
+
return json({
|
|
228
|
+
updated_range: updates?.updatedRange,
|
|
229
|
+
updated_rows: updates?.updatedRows,
|
|
230
|
+
updated_columns: updates?.updatedColumns,
|
|
231
|
+
updated_cells: updates?.updatedCells,
|
|
232
|
+
revision: updates?.revision,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function handleFind(client: UserToolClient, token: string, payload: SheetParams) {
|
|
237
|
+
const response = await client.invoke(
|
|
238
|
+
"feishu_sheet.find",
|
|
239
|
+
(sdk, opts) =>
|
|
240
|
+
sdk.sheets.spreadsheetSheet.find(
|
|
241
|
+
{
|
|
242
|
+
path: { spreadsheet_token: token, sheet_id: payload.sheet_id! },
|
|
243
|
+
data: {
|
|
244
|
+
find: payload.find,
|
|
245
|
+
find_condition: {
|
|
246
|
+
range: payload.range ? `${payload.sheet_id}!${payload.range}` : payload.sheet_id,
|
|
247
|
+
...(payload.match_case !== undefined ? { match_case: !payload.match_case } : {}),
|
|
248
|
+
...(payload.match_entire_cell !== undefined ? { match_entire_cell: payload.match_entire_cell } : {}),
|
|
249
|
+
...(payload.search_by_regex !== undefined ? { search_by_regex: payload.search_by_regex } : {}),
|
|
250
|
+
...(payload.include_formulas !== undefined ? { include_formulas: payload.include_formulas } : {}),
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
opts,
|
|
255
|
+
),
|
|
256
|
+
{ as: "user" },
|
|
257
|
+
);
|
|
258
|
+
assertLarkOk(response);
|
|
259
|
+
return json({
|
|
260
|
+
matched_cells: (response.data as { find_result?: { matched_cells?: unknown[] } } | undefined)?.find_result?.matched_cells ?? [],
|
|
261
|
+
matched_formula_cells:
|
|
262
|
+
(response.data as { find_result?: { matched_formula_cells?: unknown[] } } | undefined)?.find_result?.matched_formula_cells ?? [],
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function handleExport(client: UserToolClient, token: string, payload: SheetParams) {
|
|
267
|
+
const exportCreate = await client.invoke(
|
|
268
|
+
"feishu_sheet.export",
|
|
269
|
+
(sdk, opts) =>
|
|
270
|
+
sdk.drive.exportTask.create(
|
|
271
|
+
{
|
|
272
|
+
data: {
|
|
273
|
+
file_extension: payload.file_extension,
|
|
274
|
+
token,
|
|
275
|
+
type: "sheet",
|
|
276
|
+
sub_id: payload.sheet_id,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
opts,
|
|
280
|
+
),
|
|
281
|
+
{ as: "user" },
|
|
282
|
+
);
|
|
283
|
+
assertLarkOk(exportCreate);
|
|
284
|
+
const ticket = (exportCreate.data as { ticket?: string } | undefined)?.ticket;
|
|
285
|
+
if (!ticket) {
|
|
286
|
+
return json({ error: "导出任务创建失败,未返回 ticket。" });
|
|
287
|
+
}
|
|
288
|
+
for (let i = 0; i < 30; i += 1) {
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
290
|
+
const exportStatus = await client.invoke(
|
|
291
|
+
"feishu_sheet.export",
|
|
292
|
+
(sdk, opts) => sdk.drive.exportTask.get({ path: { ticket }, params: { token } }, opts),
|
|
293
|
+
{ as: "user" },
|
|
294
|
+
);
|
|
295
|
+
assertLarkOk(exportStatus);
|
|
296
|
+
const result = (exportStatus.data as {
|
|
297
|
+
result?: {
|
|
298
|
+
job_status?: number;
|
|
299
|
+
file_token?: string;
|
|
300
|
+
file_name?: string;
|
|
301
|
+
file_size?: number;
|
|
302
|
+
job_error_msg?: string;
|
|
303
|
+
};
|
|
304
|
+
} | undefined)?.result;
|
|
305
|
+
if (result?.job_status === 0) {
|
|
306
|
+
return json({
|
|
307
|
+
file_token: result.file_token,
|
|
308
|
+
file_name: result.file_name,
|
|
309
|
+
file_size: result.file_size,
|
|
310
|
+
file_extension: payload.file_extension,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if ((result?.job_status ?? 0) >= 3) {
|
|
314
|
+
return json({ error: result?.job_error_msg ?? `导出失败 (status=${result?.job_status})` });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return json({ error: "导出任务超时,请稍后重试。" });
|
|
318
|
+
}
|