diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index dc75513..86240d1 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -96,11 +96,18 @@ export interface CardActionEvent { readonly token?: string; } -type ContactUserResponse = { +interface ContactBasicUser { + readonly name?: string | undefined; + readonly i18n_name?: { + readonly zh_cn?: string | undefined; + readonly en_us?: string | undefined; + readonly ja_jp?: string | undefined; + } | undefined; +} + +type ContactUserBasicBatchResponse = { readonly data?: { - readonly user?: { - readonly name?: string | undefined; - } | undefined; + readonly users?: readonly ContactBasicUser[] | undefined; } | undefined; } | null; @@ -393,13 +400,12 @@ export async function resolveSenderName( const fetchName = async (id: string): Promise => { try { - const res = await rt.client.request({ - method: "GET", - url: `/open-apis/contact/v3/users/${encodeURIComponent(id)}`, + const res = await rt.client.contact.v3.user.basicBatch({ params: { user_id_type: "open_id" }, - }) as ContactUserResponse; - const name = res?.data?.user?.name; - return typeof name === "string" && name !== "" ? name : null; + data: { user_ids: [id] }, + }) as ContactUserBasicBatchResponse; + const user = res?.data?.users?.[0]; + return user === undefined ? null : contactBasicUserName(user); } catch (e) { rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed"); return null; @@ -412,6 +418,19 @@ export async function resolveSenderName( return fetchName(openId); } +function contactBasicUserName(user: ContactBasicUser): string | null { + const candidates = [ + user.name, + user.i18n_name?.zh_cn, + user.i18n_name?.en_us, + user.i18n_name?.ja_jp, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim(); + } + return null; +} + // --- File helpers --- /** Download a file/image from a Feishu message to a local path. */ diff --git a/hub/test/unit/feishu-client.test.ts b/hub/test/unit/feishu-client.test.ts index 75e94b1..e5d672b 100644 --- a/hub/test/unit/feishu-client.test.ts +++ b/hub/test/unit/feishu-client.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { sendFile, sendLongText, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js"; +import { resolveSenderName, sendFile, sendLongText, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js"; +import { SenderNameCache } from "../../src/feishu/senderCache.js"; describe("Feishu client helpers", () => { it("accepts top-level file_key from the Lark SDK file upload response", async () => { @@ -47,14 +48,41 @@ describe("Feishu client helpers", () => { }); expect(sentTexts.map((text) => text.length)).toEqual([8000, 8000, 5]); }); + + it("resolves sender names through the basic contact API and caches hits", async () => { + const contactBasicBatch = vi.fn(async () => ({ data: { users: [{ name: "Jiarong" }] } })); + const request = vi.fn(); + const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), contactBasicBatch, request }); + const cache = new SenderNameCache(); + + await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong"); + await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong"); + + expect(contactBasicBatch).toHaveBeenCalledTimes(1); + expect(contactBasicBatch).toHaveBeenCalledWith({ + params: { user_id_type: "open_id" }, + data: { user_ids: ["ou_user_1"] }, + }); + expect(request).not.toHaveBeenCalled(); + }); }); function mockRuntime(options: { readonly fileCreate: (payload: unknown) => Promise; readonly messageCreate: (payload: unknown) => Promise; + readonly contactBasicBatch?: (payload: unknown) => Promise; + readonly request?: (payload: unknown) => Promise; }): FeishuRuntime { return { client: { + request: options.request, + contact: { + v3: { + user: { + basicBatch: options.contactBasicBatch, + }, + }, + }, im: { v1: { file: { create: options.fileCreate },