Skip to content

Commit bd42bed

Browse files
committed
fix: support true Lark thread replies and retrieval
Use the official reply endpoint with reply_in_thread for thread messages and improve thread history lookup using thread_id-aware filtering so replies appear in Lark threads correctly.
1 parent 35841bf commit bd42bed

File tree

3 files changed

+283
-71
lines changed

3 files changed

+283
-71
lines changed

packages/ims/lark/api.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,38 @@ describe("handleLarkActionPayload", () => {
4949
expect(result.result).toEqual({ messageId: "om_xxx", channelId: "oc_123" });
5050
});
5151

52+
it("posts a thread reply via reply endpoint", async () => {
53+
process.env.LARK_APP_ID = "cli_app";
54+
process.env.LARK_APP_SECRET = "secret";
55+
56+
const fetchMock = mock(async (url: string, init?: RequestInit) => {
57+
if (url.includes("tenant_access_token")) {
58+
return new Response(
59+
JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }),
60+
{ status: 200, headers: { "content-type": "application/json" } }
61+
);
62+
}
63+
expect(url).toContain("/im/v1/messages/om_root/reply");
64+
const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : {};
65+
expect(body.reply_in_thread).toBe(true);
66+
return new Response(
67+
JSON.stringify({ code: 0, data: { message_id: "om_reply" } }),
68+
{ status: 200, headers: { "content-type": "application/json" } }
69+
);
70+
});
71+
globalThis.fetch = fetchMock as unknown as typeof fetch;
72+
73+
const result = await handleLarkActionPayload({
74+
action: "post_message",
75+
channelId: "oc_123",
76+
threadId: "om_root",
77+
text: "reply",
78+
});
79+
80+
expect(result.ok).toBe(true);
81+
expect(result.result).toEqual({ messageId: "om_reply", channelId: "oc_123" });
82+
});
83+
5284
it("updates a message via Lark API", async () => {
5385
process.env.LARK_APP_ID = "cli_app";
5486
process.env.LARK_APP_SECRET = "secret";
@@ -130,6 +162,48 @@ describe("handleLarkActionPayload", () => {
130162
expect(result.result).toEqual({ channels: [{ chat_id: "oc_1", name: "dev" }] });
131163
});
132164

165+
it("filters thread messages from chat list", async () => {
166+
process.env.LARK_APP_ID = "cli_app";
167+
process.env.LARK_APP_SECRET = "secret";
168+
169+
const fetchMock = mock(async (url: string) => {
170+
if (url.includes("tenant_access_token")) {
171+
return new Response(
172+
JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }),
173+
{ status: 200, headers: { "content-type": "application/json" } }
174+
);
175+
}
176+
return new Response(
177+
JSON.stringify({
178+
code: 0,
179+
data: {
180+
items: [
181+
{ message_id: "om_root", root_id: "" },
182+
{ message_id: "om_reply", root_id: "om_root" },
183+
{ message_id: "om_other", root_id: "om_other" },
184+
],
185+
},
186+
}),
187+
{ status: 200, headers: { "content-type": "application/json" } }
188+
);
189+
});
190+
globalThis.fetch = fetchMock as unknown as typeof fetch;
191+
192+
const result = await handleLarkActionPayload({
193+
action: "get_thread_messages",
194+
channelId: "oc_123",
195+
threadId: "om_root",
196+
});
197+
198+
expect(result.ok).toBe(true);
199+
expect(result.result).toEqual({
200+
messages: [
201+
{ message_id: "om_root", root_id: "" },
202+
{ message_id: "om_reply", root_id: "om_root" },
203+
],
204+
});
205+
});
206+
133207
it("uploads a file via Lark API", async () => {
134208
process.env.LARK_APP_ID = "cli_app";
135209
process.env.LARK_APP_SECRET = "secret";

packages/ims/lark/api.ts

Lines changed: 153 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ type LarkResponse<T> = {
3838
data?: T;
3939
};
4040

41+
const threadMessageCache = new Map<string, string[]>();
42+
43+
function rememberThreadMessage(threadId: string, messageId: string): void {
44+
if (!threadId || !messageId) return;
45+
const existing = threadMessageCache.get(threadId) ?? [];
46+
if (!existing.includes(messageId)) {
47+
existing.push(messageId);
48+
threadMessageCache.set(threadId, existing.slice(-50));
49+
}
50+
}
51+
4152
function requireString(value: unknown, label: string): string {
4253
if (!value || typeof value !== "string") {
4354
throw new Error(`${label} is required`);
@@ -89,7 +100,7 @@ function getLarkCredentials(payload: LarkActionRequest): { appId: string; appSec
89100
}
90101

91102
async function larkRequest<T>(
92-
method: "GET" | "POST" | "PATCH",
103+
method: "GET" | "POST" | "PATCH" | "PUT",
93104
path: string,
94105
token: string,
95106
body?: Record<string, unknown>
@@ -104,7 +115,18 @@ async function larkRequest<T>(
104115
});
105116

106117
if (!response.ok) {
107-
throw new Error(`Lark API ${response.status} ${response.statusText}`);
118+
let detail = "";
119+
try {
120+
const errorPayload = await response.json() as { code?: number; msg?: string };
121+
if (typeof errorPayload.msg === "string" && errorPayload.msg.trim().length > 0) {
122+
detail = `: ${errorPayload.msg}`;
123+
} else if (typeof errorPayload.code === "number") {
124+
detail = `: code ${errorPayload.code}`;
125+
}
126+
} catch {
127+
// ignore malformed error body
128+
}
129+
throw new Error(`Lark API ${response.status} ${response.statusText}${detail}`);
108130
}
109131

110132
const payload = await response.json() as LarkResponse<T>;
@@ -142,23 +164,43 @@ async function postTextMessage(params: {
142164
text: string;
143165
threadId?: string;
144166
}): Promise<{ messageId: string; channelId: string }> {
145-
const data = await larkRequest<{ message_id?: string }>(
146-
"POST",
147-
"/open-apis/im/v1/messages?receive_id_type=chat_id",
148-
params.token,
149-
{
150-
receive_id: params.channelId,
151-
msg_type: "text",
152-
content: JSON.stringify({ text: params.text }),
153-
...(params.threadId ? { root_id: params.threadId } : {}),
154-
}
155-
);
167+
const data = params.threadId
168+
? await larkRequest<{ message_id?: string }>(
169+
"POST",
170+
`/open-apis/im/v1/messages/${encodeURIComponent(params.threadId)}/reply`,
171+
params.token,
172+
{
173+
msg_type: "text",
174+
content: JSON.stringify({ text: params.text }),
175+
reply_in_thread: true,
176+
}
177+
)
178+
: await larkRequest<{ message_id?: string }>(
179+
"POST",
180+
"/open-apis/im/v1/messages?receive_id_type=chat_id",
181+
params.token,
182+
{
183+
receive_id: params.channelId,
184+
msg_type: "text",
185+
content: JSON.stringify({ text: params.text }),
186+
}
187+
);
156188
return {
157189
messageId: data.message_id ?? "",
158190
channelId: params.channelId,
159191
};
160192
}
161193

194+
async function getMessageById(token: string, messageId: string): Promise<Record<string, unknown> | null> {
195+
const data = await larkRequest<{ items?: Array<Record<string, unknown>> }>(
196+
"GET",
197+
`/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
198+
token
199+
);
200+
const item = Array.isArray(data.items) ? data.items[0] : null;
201+
return item ?? null;
202+
}
203+
162204
async function handleLarkAction(payload: LarkActionRequest): Promise<unknown> {
163205
const { appId, appSecret } = getLarkCredentials(payload);
164206
const token = await getTenantAccessToken(appId, appSecret);
@@ -182,26 +224,45 @@ async function handleLarkAction(payload: LarkActionRequest): Promise<unknown> {
182224
case "post_message": {
183225
const channelId = requireString(payload.channelId, "channelId");
184226
const text = requireString(payload.text, "text");
185-
return postTextMessage({
227+
const message = await postTextMessage({
186228
token,
187229
channelId,
188230
text,
189231
threadId: payload.threadId,
190232
});
233+
if (payload.threadId && message.messageId) {
234+
rememberThreadMessage(payload.threadId, message.messageId);
235+
}
236+
return message;
191237
}
192238

193239
case "update_message": {
194240
const messageId = requireString(payload.messageId, "messageId");
195241
const text = requireString(payload.text, "text");
196-
await larkRequest<Record<string, unknown>>(
197-
"PATCH",
198-
`/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
199-
token,
200-
{
201-
msg_type: "text",
202-
content: JSON.stringify({ text }),
242+
const body = {
243+
msg_type: "text",
244+
content: JSON.stringify({ text }),
245+
};
246+
try {
247+
await larkRequest<Record<string, unknown>>(
248+
"PATCH",
249+
`/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
250+
token,
251+
body
252+
);
253+
} catch (patchError) {
254+
const patchMessage = patchError instanceof Error ? patchError.message : String(patchError);
255+
if (!patchMessage.includes("400")) {
256+
throw patchError;
203257
}
204-
);
258+
259+
await larkRequest<Record<string, unknown>>(
260+
"PUT",
261+
`/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
262+
token,
263+
body
264+
);
265+
}
205266
return {
206267
status: "message_updated",
207268
messageId,
@@ -229,12 +290,54 @@ async function handleLarkAction(payload: LarkActionRequest): Promise<unknown> {
229290

230291
case "get_thread_messages": {
231292
const threadId = requireString(payload.threadId, "threadId");
232-
const data = await larkRequest<{ items?: unknown[] }>(
233-
"GET",
234-
`/open-apis/im/v1/messages/${encodeURIComponent(threadId)}/replies?page_size=${Math.min(Math.max(payload.limit ?? 20, 1), 50)}`,
235-
token
236-
);
237-
return { messages: data.items ?? [] };
293+
const channelId = payload.channelId?.trim();
294+
const limit = Math.min(Math.max(payload.limit ?? 20, 1), 50);
295+
296+
let threadConversationId = "";
297+
try {
298+
const root = await getMessageById(token, threadId);
299+
threadConversationId = typeof root?.thread_id === "string" ? root.thread_id : "";
300+
} catch {
301+
threadConversationId = "";
302+
}
303+
304+
if (channelId) {
305+
const data = await larkRequest<{
306+
items?: Array<Record<string, unknown>>;
307+
}>(
308+
"GET",
309+
`/open-apis/im/v1/messages?container_id_type=chat&container_id=${encodeURIComponent(channelId)}&page_size=50`,
310+
token
311+
);
312+
const messages = (data.items ?? [])
313+
.filter((item) => {
314+
const messageId = typeof item.message_id === "string" ? item.message_id : "";
315+
const rootId = typeof item.root_id === "string" ? item.root_id : "";
316+
const parentId = typeof item.parent_id === "string" ? item.parent_id : "";
317+
const itemThreadId = typeof item.thread_id === "string" ? item.thread_id : "";
318+
if (threadConversationId) {
319+
return itemThreadId === threadConversationId;
320+
}
321+
return messageId === threadId || rootId === threadId || parentId === threadId;
322+
})
323+
.slice(-limit);
324+
if (messages.length > 0) {
325+
return { messages };
326+
}
327+
}
328+
329+
const cachedIds = threadMessageCache.get(threadId) ?? [];
330+
const uniqueIds = [threadId, ...cachedIds].filter((id, index, arr) => arr.indexOf(id) === index);
331+
const messages: Array<Record<string, unknown>> = [];
332+
for (const id of uniqueIds.slice(-limit)) {
333+
try {
334+
const item = await getMessageById(token, id);
335+
if (item) messages.push(item);
336+
} catch {
337+
// ignore single message lookup failures
338+
}
339+
}
340+
return { messages };
238341
}
239342

240343
case "get_user_info": {
@@ -314,26 +417,40 @@ async function handleLarkAction(payload: LarkActionRequest): Promise<unknown> {
314417

315418
const threadId = payload.threadId?.trim();
316419
if (payload.initialComment?.trim()) {
317-
await postTextMessage({
420+
const comment = await postTextMessage({
318421
token,
319422
channelId,
320423
text: payload.initialComment.trim(),
321424
threadId,
322425
});
426+
if (threadId && comment.messageId) {
427+
rememberThreadMessage(threadId, comment.messageId);
428+
}
323429
}
324430

325431
const message = await larkRequest<{ message_id?: string }>(
326432
"POST",
327-
"/open-apis/im/v1/messages?receive_id_type=chat_id",
433+
threadId
434+
? `/open-apis/im/v1/messages/${encodeURIComponent(threadId)}/reply`
435+
: "/open-apis/im/v1/messages?receive_id_type=chat_id",
328436
token,
329-
{
330-
receive_id: channelId,
331-
msg_type: "file",
332-
content: JSON.stringify({ file_key: uploadPayload.data.file_key }),
333-
...(threadId ? { root_id: threadId } : {}),
334-
}
437+
threadId
438+
? {
439+
msg_type: "file",
440+
content: JSON.stringify({ file_key: uploadPayload.data.file_key }),
441+
reply_in_thread: true,
442+
}
443+
: {
444+
receive_id: channelId,
445+
msg_type: "file",
446+
content: JSON.stringify({ file_key: uploadPayload.data.file_key }),
447+
}
335448
);
336449

450+
if (threadId && message.message_id) {
451+
rememberThreadMessage(threadId, message.message_id);
452+
}
453+
337454
return {
338455
status: "file_uploaded",
339456
messageId: message.message_id ?? "",

0 commit comments

Comments
 (0)