mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 15:01:41 +03:00
🤖 Feishu: expand channel support
What: - add post parsing, doc link extraction, routing, replies, reactions, typing, and user lookup - fix media download/send flows and make doc fetches domain-aware - update Feishu docs and clawtributor credits Why: - raise Feishu parity with other channels and avoid dropped group messages - keep replies threaded while supporting Lark domains - document new configuration and credit the contributor Tests: - pnpm build - pnpm check - pnpm test (gateway suite timed out; reran pnpm vitest run --config vitest.gateway.config.ts) Co-authored-by: 九灵云 <server@jiulingyun.cn>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractDocRefsFromText, extractDocRefsFromPost } from "./docs.js";
|
||||
|
||||
describe("extractDocRefsFromText", () => {
|
||||
it("should extract docx URL", () => {
|
||||
const text = "Check this document https://example.feishu.cn/docx/B4EPdAYx8oi8HRxgPQQb";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docToken).toBe("B4EPdAYx8oi8HRxgPQQb");
|
||||
expect(refs[0].docType).toBe("docx");
|
||||
});
|
||||
|
||||
it("should extract wiki URL", () => {
|
||||
const text = "Wiki link: https://company.feishu.cn/wiki/WikiTokenExample123";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("wiki");
|
||||
expect(refs[0].docToken).toBe("WikiTokenExample123");
|
||||
});
|
||||
|
||||
it("should extract sheet URL", () => {
|
||||
const text = "Sheet URL https://open.larksuite.com/sheets/SheetToken1234567890";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("sheet");
|
||||
});
|
||||
|
||||
it("should extract bitable/base URL", () => {
|
||||
const text = "Bitable https://abc.feishu.cn/base/BitableToken1234567890";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("bitable");
|
||||
});
|
||||
|
||||
it("should extract multiple URLs", () => {
|
||||
const text = `
|
||||
Doc 1: https://example.feishu.cn/docx/Doc1Token12345678901
|
||||
Doc 2: https://example.feishu.cn/wiki/Wiki1Token12345678901
|
||||
`;
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should deduplicate same token", () => {
|
||||
const text = `
|
||||
https://example.feishu.cn/docx/SameToken123456789012
|
||||
https://example.feishu.cn/docx/SameToken123456789012
|
||||
`;
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return empty array for text without URLs", () => {
|
||||
const text = "This is plain text without any document links";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDocRefsFromPost", () => {
|
||||
it("should extract URL from link element", () => {
|
||||
const content = {
|
||||
title: "Test rich text",
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "a",
|
||||
text: "API Documentation",
|
||||
href: "https://example.feishu.cn/docx/ApiDocToken123456789",
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].title).toBe("API Documentation");
|
||||
expect(refs[0].docToken).toBe("ApiDocToken123456789");
|
||||
});
|
||||
|
||||
it("should extract URL from title", () => {
|
||||
const content = {
|
||||
title: "See https://example.feishu.cn/docx/TitleDocToken1234567",
|
||||
content: [],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should extract URL from text element", () => {
|
||||
const content = {
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "text",
|
||||
text: "Visit https://example.feishu.cn/wiki/TextWikiToken12345678",
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("wiki");
|
||||
});
|
||||
|
||||
it("should handle stringified JSON", () => {
|
||||
const content = JSON.stringify({
|
||||
title: "Document Share",
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "a",
|
||||
text: "Click to view",
|
||||
href: "https://example.feishu.cn/docx/JsonDocToken123456789",
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return empty array for post without doc links", () => {
|
||||
const content = {
|
||||
title: "Normal title",
|
||||
content: [
|
||||
[
|
||||
{ tag: "text", text: "Normal text" },
|
||||
{ tag: "a", text: "Normal link", href: "https://example.com" },
|
||||
],
|
||||
],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { resolveFeishuApiBase } from "./domain.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-docs" });
|
||||
|
||||
type FeishuApiResponse<T> = {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
type FeishuRequestClient = {
|
||||
request: <T>(params: {
|
||||
method: string;
|
||||
url: string;
|
||||
params?: Record<string, unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<FeishuApiResponse<T>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Document token info extracted from a Feishu/Lark document URL or message
|
||||
*/
|
||||
export type FeishuDocRef = {
|
||||
docToken: string;
|
||||
docType: "docx" | "doc" | "sheet" | "bitable" | "wiki" | "mindnote" | "file" | "slide";
|
||||
url: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Regex patterns to extract doc_token from various Feishu/Lark URLs
|
||||
*
|
||||
* Supported URL formats:
|
||||
* - https://xxx.feishu.cn/docx/xxxxx
|
||||
* - https://xxx.feishu.cn/wiki/xxxxx
|
||||
* - https://xxx.feishu.cn/sheets/xxxxx
|
||||
* - https://xxx.feishu.cn/base/xxxxx (bitable)
|
||||
* - https://xxx.larksuite.com/docx/xxxxx
|
||||
* etc.
|
||||
*/
|
||||
/* eslint-disable no-useless-escape */
|
||||
const DOC_URL_PATTERNS = [
|
||||
// docx (new version document) - token is typically 22-27 chars
|
||||
/https?:\/\/[^\/]+\/(docx)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// doc (legacy document)
|
||||
/https?:\/\/[^\/]+\/(doc)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// wiki
|
||||
/https?:\/\/[^\/]+\/(wiki)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// sheets
|
||||
/https?:\/\/[^\/]+\/(sheets?)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// bitable (base)
|
||||
/https?:\/\/[^\/]+\/(base|bitable)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// mindnote
|
||||
/https?:\/\/[^\/]+\/(mindnote)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// file
|
||||
/https?:\/\/[^\/]+\/(file)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// slide
|
||||
/https?:\/\/[^\/]+\/(slides?)\/([A-Za-z0-9_-]{15,35})/,
|
||||
];
|
||||
/* eslint-enable no-useless-escape */
|
||||
|
||||
/**
|
||||
* Extract document references from text content
|
||||
* Looks for Feishu/Lark document URLs and extracts doc tokens
|
||||
*/
|
||||
export function extractDocRefsFromText(text: string): FeishuDocRef[] {
|
||||
const refs: FeishuDocRef[] = [];
|
||||
const seenTokens = new Set<string>();
|
||||
|
||||
for (const pattern of DOC_URL_PATTERNS) {
|
||||
const regex = new RegExp(pattern, "g");
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const [url, typeStr, token] = match;
|
||||
const docType = normalizeDocType(typeStr);
|
||||
|
||||
if (!seenTokens.has(token)) {
|
||||
seenTokens.add(token);
|
||||
refs.push({
|
||||
docToken: token,
|
||||
docType,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract document references from a rich text (post) message content
|
||||
*/
|
||||
export function extractDocRefsFromPost(content: unknown): FeishuDocRef[] {
|
||||
const refs: FeishuDocRef[] = [];
|
||||
const seenTokens = new Set<string>();
|
||||
|
||||
try {
|
||||
// Post content structure: { title, content: [[{tag, ...}]] }
|
||||
const postContent = typeof content === "string" ? JSON.parse(content) : content;
|
||||
|
||||
// Check title for links
|
||||
if (postContent.title) {
|
||||
const titleRefs = extractDocRefsFromText(postContent.title);
|
||||
for (const ref of titleRefs) {
|
||||
if (!seenTokens.has(ref.docToken)) {
|
||||
seenTokens.add(ref.docToken);
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check content elements
|
||||
if (Array.isArray(postContent.content)) {
|
||||
for (const line of postContent.content) {
|
||||
if (!Array.isArray(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of line) {
|
||||
// Check hyperlinks
|
||||
if (element.tag === "a" && element.href) {
|
||||
const linkRefs = extractDocRefsFromText(element.href);
|
||||
for (const ref of linkRefs) {
|
||||
if (!seenTokens.has(ref.docToken)) {
|
||||
seenTokens.add(ref.docToken);
|
||||
// Use the link text as title if available
|
||||
ref.title = element.text || undefined;
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check text content for inline URLs
|
||||
if (element.tag === "text" && element.text) {
|
||||
const textRefs = extractDocRefsFromText(element.text);
|
||||
for (const ref of textRefs) {
|
||||
if (!seenTokens.has(ref.docToken)) {
|
||||
seenTokens.add(ref.docToken);
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.debug(`Failed to parse post content: ${String(err)}`);
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function normalizeDocType(
|
||||
typeStr: string,
|
||||
): "docx" | "doc" | "sheet" | "bitable" | "wiki" | "mindnote" | "file" | "slide" {
|
||||
switch (typeStr.toLowerCase()) {
|
||||
case "docx":
|
||||
return "docx";
|
||||
case "doc":
|
||||
return "doc";
|
||||
case "sheet":
|
||||
case "sheets":
|
||||
return "sheet";
|
||||
case "base":
|
||||
case "bitable":
|
||||
return "bitable";
|
||||
case "wiki":
|
||||
return "wiki";
|
||||
case "mindnote":
|
||||
return "mindnote";
|
||||
case "file":
|
||||
return "file";
|
||||
case "slide":
|
||||
case "slides":
|
||||
return "slide";
|
||||
default:
|
||||
return "docx";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wiki node info to resolve the actual document token
|
||||
*
|
||||
* Wiki documents have a node_token that needs to be resolved to the actual obj_token
|
||||
*
|
||||
* API: GET https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node
|
||||
* Required permission: wiki:wiki:readonly or wiki:wiki
|
||||
*/
|
||||
async function resolveWikiNode(
|
||||
client: Client,
|
||||
nodeToken: string,
|
||||
apiBase: string,
|
||||
): Promise<{ objToken: string; objType: string; title?: string } | null> {
|
||||
try {
|
||||
logger.debug(`Resolving wiki node: ${nodeToken}`);
|
||||
|
||||
const response = await (client as FeishuRequestClient).request<{
|
||||
node?: { obj_token?: string; obj_type?: string; title?: string };
|
||||
}>({
|
||||
method: "GET",
|
||||
url: `${apiBase}/wiki/v2/spaces/get_node`,
|
||||
params: {
|
||||
token: nodeToken,
|
||||
obj_type: "wiki",
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.code !== 0) {
|
||||
const errMsg = response?.msg || "Unknown error";
|
||||
logger.warn(`Failed to resolve wiki node: ${errMsg} (code: ${response?.code})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = response.data?.node;
|
||||
if (!node?.obj_token || !node?.obj_type) {
|
||||
logger.warn(`Wiki node response missing obj_token or obj_type`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
objToken: node.obj_token,
|
||||
objType: node.obj_type,
|
||||
title: node.title,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Error resolving wiki node: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the content of a Feishu document
|
||||
*
|
||||
* Supports:
|
||||
* - docx (new version documents) - direct content fetch
|
||||
* - wiki (knowledge base nodes) - first resolve to actual document, then fetch
|
||||
*
|
||||
* Other document types return a placeholder message.
|
||||
*
|
||||
* API: GET https://open.feishu.cn/open-apis/docs/v1/content
|
||||
* Docs: https://open.feishu.cn/document/server-docs/docs/content/get
|
||||
*
|
||||
* Required permissions:
|
||||
* - docs:document.content:read (for docx)
|
||||
* - wiki:wiki:readonly or wiki:wiki (for wiki)
|
||||
*/
|
||||
export async function fetchFeishuDocContent(
|
||||
client: Client,
|
||||
docRef: FeishuDocRef,
|
||||
options: {
|
||||
maxLength?: number;
|
||||
lang?: "zh" | "en" | "ja";
|
||||
apiBase?: string;
|
||||
} = {},
|
||||
): Promise<{ content: string; truncated: boolean } | null> {
|
||||
const { maxLength = 50000, lang = "zh", apiBase } = options;
|
||||
const resolvedApiBase = apiBase ?? resolveFeishuApiBase();
|
||||
|
||||
// For wiki type, first resolve the node to get the actual document token
|
||||
let targetToken = docRef.docToken;
|
||||
let targetType = docRef.docType;
|
||||
let resolvedTitle = docRef.title;
|
||||
|
||||
if (docRef.docType === "wiki") {
|
||||
const wikiNode = await resolveWikiNode(client, docRef.docToken, resolvedApiBase);
|
||||
if (!wikiNode) {
|
||||
return {
|
||||
content: `[Feishu Wiki Document: ${docRef.title || docRef.docToken}]\nLink: ${docRef.url}\n\n(Unable to access wiki node info. Please ensure the bot has been added as a wiki space member)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
targetToken = wikiNode.objToken;
|
||||
targetType = wikiNode.objType as FeishuDocRef["docType"];
|
||||
resolvedTitle = wikiNode.title || docRef.title;
|
||||
|
||||
logger.debug(`Wiki node resolved: ${docRef.docToken} -> ${targetToken} (${targetType})`);
|
||||
}
|
||||
|
||||
// Only docx is supported for content fetching
|
||||
if (targetType !== "docx") {
|
||||
logger.debug(`Document type ${targetType} is not supported for content fetching`);
|
||||
return {
|
||||
content: `[Feishu ${getDocTypeName(targetType)} Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(This document type does not support content extraction. Please access the link directly)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Fetching document content: ${targetToken} (${targetType})`);
|
||||
|
||||
// Use native HTTP request since SDK may not have this endpoint
|
||||
// The API endpoint is: GET /open-apis/docs/v1/content
|
||||
const response = await (client as FeishuRequestClient).request<{
|
||||
content?: string;
|
||||
}>({
|
||||
method: "GET",
|
||||
url: `${resolvedApiBase}/docs/v1/content`,
|
||||
params: {
|
||||
doc_token: targetToken,
|
||||
doc_type: "docx",
|
||||
content_type: "markdown",
|
||||
lang,
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.code !== 0) {
|
||||
const errMsg = response?.msg || "Unknown error";
|
||||
logger.warn(`Failed to fetch document content: ${errMsg} (code: ${response?.code})`);
|
||||
|
||||
// Check for common errors
|
||||
if (response?.code === 2889902) {
|
||||
return {
|
||||
content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(No permission to access this document. Please ensure the bot has been added as a document collaborator)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(Failed to fetch document content: ${errMsg})`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
let content = response.data?.content || "";
|
||||
let truncated = false;
|
||||
|
||||
// Truncate if too long
|
||||
if (content.length > maxLength) {
|
||||
content = content.substring(0, maxLength) + "\n\n... (Content truncated due to length)";
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// Add document header
|
||||
const header = resolvedTitle
|
||||
? `[Feishu Document: ${resolvedTitle}]\nLink: ${docRef.url}\n\n---\n\n`
|
||||
: `[Feishu Document]\nLink: ${docRef.url}\n\n---\n\n`;
|
||||
|
||||
return {
|
||||
content: header + content,
|
||||
truncated,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Error fetching document content: ${String(err)}`);
|
||||
return {
|
||||
content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(Error occurred while fetching document content)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getDocTypeName(docType: FeishuDocRef["docType"]): string {
|
||||
switch (docType) {
|
||||
case "docx":
|
||||
case "doc":
|
||||
return "";
|
||||
case "sheet":
|
||||
return "Sheet";
|
||||
case "bitable":
|
||||
return "Bitable";
|
||||
case "wiki":
|
||||
return "Wiki";
|
||||
case "mindnote":
|
||||
return "Mindnote";
|
||||
case "file":
|
||||
return "File";
|
||||
case "slide":
|
||||
return "Slide";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve document content from a message
|
||||
* Extracts document links and fetches their content
|
||||
*
|
||||
* @returns Combined document content string, or null if no documents found
|
||||
*/
|
||||
export async function resolveFeishuDocsFromMessage(
|
||||
client: Client,
|
||||
message: { message_type?: string; content?: string },
|
||||
options: {
|
||||
maxDocsPerMessage?: number;
|
||||
maxTotalLength?: number;
|
||||
domain?: string;
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
const { maxDocsPerMessage = 3, maxTotalLength = 100000 } = options;
|
||||
const apiBase = resolveFeishuApiBase(options.domain);
|
||||
|
||||
const msgType = message.message_type;
|
||||
let docRefs: FeishuDocRef[] = [];
|
||||
|
||||
try {
|
||||
const content = JSON.parse(message.content ?? "{}");
|
||||
|
||||
if (msgType === "text" && content.text) {
|
||||
// Extract from plain text
|
||||
docRefs = extractDocRefsFromText(content.text);
|
||||
} else if (msgType === "post") {
|
||||
// Extract from rich text - handle locale wrapper
|
||||
let postData = content;
|
||||
if (content.post && typeof content.post === "object") {
|
||||
const localeKey = Object.keys(content.post).find(
|
||||
(key) => content.post[key]?.content || content.post[key]?.title,
|
||||
);
|
||||
if (localeKey) {
|
||||
postData = content.post[localeKey];
|
||||
}
|
||||
}
|
||||
docRefs = extractDocRefsFromPost(postData);
|
||||
}
|
||||
// TODO: Handle interactive (card) messages with document links
|
||||
} catch (err: unknown) {
|
||||
logger.debug(`Failed to parse message content for document extraction: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (docRefs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Limit number of documents to process
|
||||
const refsToProcess = docRefs.slice(0, maxDocsPerMessage);
|
||||
|
||||
logger.debug(`Found ${docRefs.length} document(s), processing ${refsToProcess.length}`);
|
||||
|
||||
const contents: string[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (const ref of refsToProcess) {
|
||||
const result = await fetchFeishuDocContent(client, ref, {
|
||||
maxLength: Math.min(50000, maxTotalLength - totalLength),
|
||||
apiBase,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
contents.push(result.content);
|
||||
totalLength += result.content.length;
|
||||
|
||||
if (totalLength >= maxTotalLength) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contents.join("\n\n---\n\n");
|
||||
}
|
||||
+100
-6
@@ -21,13 +21,15 @@ type FeishuMessagePayload = {
|
||||
* Download a resource from a user message using messageResource.get
|
||||
* This is the correct API for downloading resources from messages sent by users.
|
||||
*
|
||||
* @param type - Resource type: "image", "file", "audio", or "video"
|
||||
* @param type - Resource type: "image" or "file" only (per Feishu API docs)
|
||||
* Audio/video must use type="file" despite being different media types.
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message/get-2
|
||||
*/
|
||||
export async function downloadFeishuMessageResource(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
fileKey: string,
|
||||
type: "image" | "file" | "audio" | "video",
|
||||
type: "image" | "file",
|
||||
maxBytes: number = 30 * 1024 * 1024,
|
||||
): Promise<FeishuMediaRef> {
|
||||
logger.debug(`Downloading Feishu ${type}: messageId=${messageId}, fileKey=${fileKey}`);
|
||||
@@ -148,27 +150,41 @@ export async function resolveFeishuMedia(
|
||||
}
|
||||
} else if (msgType === "audio") {
|
||||
// Audio message: content = { file_key: "..." }
|
||||
// Note: Feishu API only supports type="image" or type="file" for messageResource.get
|
||||
// Audio must be downloaded using type="file" per official docs:
|
||||
// https://open.feishu.cn/document/server-docs/im-v1/message/get-2
|
||||
const content = JSON.parse(rawContent);
|
||||
if (content.file_key) {
|
||||
return await downloadFeishuMessageResource(
|
||||
const result = await downloadFeishuMessageResource(
|
||||
client,
|
||||
messageId,
|
||||
content.file_key,
|
||||
"audio",
|
||||
"file", // Use "file" type for audio download (API limitation)
|
||||
maxBytes,
|
||||
);
|
||||
// Override placeholder to indicate audio content
|
||||
return {
|
||||
...result,
|
||||
placeholder: "<media:audio>",
|
||||
};
|
||||
}
|
||||
} else if (msgType === "media") {
|
||||
// Video message: content = { file_key: "...", image_key: "..." (thumbnail) }
|
||||
// Note: Video must also be downloaded using type="file" per Feishu API docs
|
||||
const content = JSON.parse(rawContent);
|
||||
if (content.file_key) {
|
||||
return await downloadFeishuMessageResource(
|
||||
const result = await downloadFeishuMessageResource(
|
||||
client,
|
||||
messageId,
|
||||
content.file_key,
|
||||
"video",
|
||||
"file", // Use "file" type for video download (API limitation)
|
||||
maxBytes,
|
||||
);
|
||||
// Override placeholder to indicate video content
|
||||
return {
|
||||
...result,
|
||||
placeholder: "<media:video>",
|
||||
};
|
||||
}
|
||||
} else if (msgType === "sticker") {
|
||||
// Sticker - not supported for download via messageResource API
|
||||
@@ -181,3 +197,81 @@ export async function resolveFeishuMedia(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image keys from post (rich text) message content
|
||||
* Post content structure: { post: { locale: { content: [[{ tag: "img", image_key: "..." }]] } } }
|
||||
*/
|
||||
export function extractPostImageKeys(content: unknown): string[] {
|
||||
const imageKeys: string[] = [];
|
||||
|
||||
if (!content || typeof content !== "object") {
|
||||
return imageKeys;
|
||||
}
|
||||
|
||||
const obj = content as Record<string, unknown>;
|
||||
|
||||
// Handle locale-wrapped format: { post: { zh_cn: { content: [...] } } }
|
||||
let postData = obj;
|
||||
if (obj.post && typeof obj.post === "object") {
|
||||
const post = obj.post as Record<string, unknown>;
|
||||
const localeKey = Object.keys(post).find((key) => post[key] && typeof post[key] === "object");
|
||||
if (localeKey) {
|
||||
postData = post[localeKey] as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract image_key from content elements
|
||||
const contentArray = postData.content;
|
||||
if (!Array.isArray(contentArray)) {
|
||||
return imageKeys;
|
||||
}
|
||||
|
||||
for (const line of contentArray) {
|
||||
if (!Array.isArray(line)) {
|
||||
continue;
|
||||
}
|
||||
for (const element of line) {
|
||||
if (
|
||||
element &&
|
||||
typeof element === "object" &&
|
||||
(element as Record<string, unknown>).tag === "img" &&
|
||||
typeof (element as Record<string, unknown>).image_key === "string"
|
||||
) {
|
||||
imageKeys.push((element as Record<string, unknown>).image_key as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imageKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download embedded images from a post (rich text) message
|
||||
*/
|
||||
export async function downloadPostImages(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
imageKeys: string[],
|
||||
maxBytes: number = 30 * 1024 * 1024,
|
||||
maxImages: number = 5,
|
||||
): Promise<FeishuMediaRef[]> {
|
||||
const results: FeishuMediaRef[] = [];
|
||||
|
||||
for (const imageKey of imageKeys.slice(0, maxImages)) {
|
||||
try {
|
||||
const media = await downloadFeishuMessageResource(
|
||||
client,
|
||||
messageId,
|
||||
imageKey,
|
||||
"image",
|
||||
maxBytes,
|
||||
);
|
||||
results.push(media);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to download post image ${imageKey}: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
+194
-13
@@ -7,6 +7,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { isSenderAllowed, normalizeAllowFromWithStore, resolveSenderAllowMatch } from "./access.js";
|
||||
import {
|
||||
resolveFeishuConfig,
|
||||
@@ -14,10 +15,18 @@ import {
|
||||
resolveFeishuGroupEnabled,
|
||||
type ResolvedFeishuConfig,
|
||||
} from "./config.js";
|
||||
import { resolveFeishuMedia, type FeishuMediaRef } from "./download.js";
|
||||
import { resolveFeishuDocsFromMessage } from "./docs.js";
|
||||
import {
|
||||
downloadPostImages,
|
||||
extractPostImageKeys,
|
||||
resolveFeishuMedia,
|
||||
type FeishuMediaRef,
|
||||
} from "./download.js";
|
||||
import { readFeishuAllowFromStore, upsertFeishuPairingRequest } from "./pairing-store.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { FeishuStreamingSession } from "./streaming-card.js";
|
||||
import { createTypingIndicatorCallbacks } from "./typing.js";
|
||||
import { getFeishuUserDisplayName } from "./user.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-message" });
|
||||
|
||||
@@ -31,6 +40,12 @@ type FeishuSender = {
|
||||
|
||||
type FeishuMention = {
|
||||
key?: string;
|
||||
id?: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type FeishuMessage = {
|
||||
@@ -41,6 +56,8 @@ type FeishuMessage = {
|
||||
mentions?: FeishuMention[];
|
||||
create_time?: string | number;
|
||||
message_id?: string;
|
||||
parent_id?: string;
|
||||
root_id?: string;
|
||||
};
|
||||
|
||||
type FeishuEventPayload = {
|
||||
@@ -54,7 +71,7 @@ type FeishuEventPayload = {
|
||||
};
|
||||
|
||||
// Supported message types for processing
|
||||
const SUPPORTED_MSG_TYPES = new Set(["text", "image", "file", "audio", "media", "sticker"]);
|
||||
const SUPPORTED_MSG_TYPES = new Set(["text", "post", "image", "file", "audio", "media", "sticker"]);
|
||||
|
||||
export type ProcessFeishuMessageOptions = {
|
||||
cfg?: OpenClawConfig;
|
||||
@@ -64,6 +81,8 @@ export type ProcessFeishuMessageOptions = {
|
||||
credentials?: { appId: string; appSecret: string; domain?: string };
|
||||
/** Bot name for streaming card title (optional, defaults to no title) */
|
||||
botName?: string;
|
||||
/** Bot's open_id for detecting bot mentions in groups */
|
||||
botOpenId?: string;
|
||||
};
|
||||
|
||||
export async function processFeishuMessage(
|
||||
@@ -98,6 +117,17 @@ export async function processFeishuMessage(
|
||||
const senderUnionId = sender?.sender_id?.union_id;
|
||||
const maxMediaBytes = feishuCfg.mediaMaxMb * 1024 * 1024;
|
||||
|
||||
// Resolve agent route for multi-agent support
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? chatId : senderId,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if this is a supported message type
|
||||
if (!msgType || !SUPPORTED_MSG_TYPES.has(msgType)) {
|
||||
logger.debug(`Skipping unsupported message type: ${msgType ?? "unknown"}`);
|
||||
@@ -216,7 +246,11 @@ export async function processFeishuMessage(
|
||||
|
||||
// Handle @mentions for group chats
|
||||
const mentions = message.mentions ?? payload.mentions ?? [];
|
||||
const wasMentioned = mentions.length > 0;
|
||||
// Check if the bot itself was mentioned, not just any user
|
||||
const botOpenId = options.botOpenId?.trim();
|
||||
const wasMentioned = botOpenId
|
||||
? mentions.some((m) => m.id?.open_id === botOpenId || m.id?.user_id === botOpenId)
|
||||
: mentions.length > 0;
|
||||
|
||||
// In group chat, check requireMention setting
|
||||
if (isGroup) {
|
||||
@@ -239,6 +273,58 @@ export async function processFeishuMessage(
|
||||
} catch (err) {
|
||||
logger.error(`Failed to parse text message content: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
} else if (msgType === "post") {
|
||||
// Post (rich text) message parsing
|
||||
// Feishu post content can have two formats:
|
||||
// Format 1: { post: { zh_cn: { title, content } } } (locale-wrapped)
|
||||
// Format 2: { title, content } (direct)
|
||||
try {
|
||||
const content = JSON.parse(message.content ?? "{}");
|
||||
const parts: string[] = [];
|
||||
|
||||
// Try to find the actual post content
|
||||
let postData = content;
|
||||
if (content.post && typeof content.post === "object") {
|
||||
// Find the first locale key (zh_cn, en_us, etc.)
|
||||
const localeKey = Object.keys(content.post).find(
|
||||
(key) => content.post[key]?.content || content.post[key]?.title,
|
||||
);
|
||||
if (localeKey) {
|
||||
postData = content.post[localeKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Include title if present
|
||||
if (postData.title) {
|
||||
parts.push(postData.title);
|
||||
}
|
||||
|
||||
// Extract text from content elements
|
||||
if (Array.isArray(postData.content)) {
|
||||
for (const line of postData.content) {
|
||||
if (!Array.isArray(line)) {
|
||||
continue;
|
||||
}
|
||||
const lineParts: string[] = [];
|
||||
for (const element of line) {
|
||||
if (element.tag === "text" && element.text) {
|
||||
lineParts.push(element.text);
|
||||
} else if (element.tag === "a" && element.text) {
|
||||
lineParts.push(element.text);
|
||||
} else if (element.tag === "at" && element.user_name) {
|
||||
lineParts.push(`@${element.user_name}`);
|
||||
}
|
||||
}
|
||||
if (lineParts.length > 0) {
|
||||
parts.push(lineParts.join(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text = parts.join("\n");
|
||||
} catch (err) {
|
||||
logger.error(`Failed to parse post message content: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove @mention placeholders from text
|
||||
@@ -250,7 +336,29 @@ export async function processFeishuMessage(
|
||||
|
||||
// Resolve media if present
|
||||
let media: FeishuMediaRef | null = null;
|
||||
if (msgType !== "text") {
|
||||
let postImages: FeishuMediaRef[] = [];
|
||||
|
||||
if (msgType === "post") {
|
||||
// Extract and download embedded images from post message
|
||||
try {
|
||||
const content = JSON.parse(message.content ?? "{}");
|
||||
const imageKeys = extractPostImageKeys(content);
|
||||
if (imageKeys.length > 0 && message.message_id) {
|
||||
postImages = await downloadPostImages(
|
||||
client,
|
||||
message.message_id,
|
||||
imageKeys,
|
||||
maxMediaBytes,
|
||||
5, // max 5 images per post
|
||||
);
|
||||
logger.debug(
|
||||
`Downloaded ${postImages.length}/${imageKeys.length} images from post message`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to download post images: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
} else if (msgType !== "text") {
|
||||
try {
|
||||
media = await resolveFeishuMedia(client, message, maxMediaBytes);
|
||||
} catch (err) {
|
||||
@@ -258,19 +366,43 @@ export async function processFeishuMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve document content if message contains Feishu doc links
|
||||
let docContent: string | null = null;
|
||||
if (msgType === "text" || msgType === "post") {
|
||||
try {
|
||||
docContent = await resolveFeishuDocsFromMessage(client, message, {
|
||||
maxDocsPerMessage: 3,
|
||||
maxTotalLength: 100000,
|
||||
domain: options.credentials?.domain,
|
||||
});
|
||||
if (docContent) {
|
||||
logger.debug(`Resolved ${docContent.length} chars of document content`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to resolve document content: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build body text
|
||||
let bodyText = text;
|
||||
if (!bodyText && media) {
|
||||
bodyText = media.placeholder;
|
||||
}
|
||||
|
||||
// Append document content if available
|
||||
if (docContent) {
|
||||
bodyText = bodyText ? `${bodyText}\n\n${docContent}` : docContent;
|
||||
}
|
||||
|
||||
// Skip if no content
|
||||
if (!bodyText && !media) {
|
||||
if (!bodyText && !media && postImages.length === 0) {
|
||||
logger.debug(`Empty message after processing, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const senderName = sender?.sender_id?.user_id || "unknown";
|
||||
// Get sender display name (try to fetch from contact API, fallback to user_id)
|
||||
const fallbackName = sender?.sender_id?.user_id || "unknown";
|
||||
const senderName = await getFeishuUserDisplayName(client, senderId, fallbackName);
|
||||
|
||||
// Streaming mode support
|
||||
const streamingEnabled = (feishuCfg.streaming ?? true) && Boolean(options.credentials);
|
||||
@@ -281,12 +413,24 @@ export async function processFeishuMessage(
|
||||
let streamingStarted = false;
|
||||
let lastPartialText = "";
|
||||
|
||||
// Typing indicator callbacks (for non-streaming mode)
|
||||
const typingCallbacks = createTypingIndicatorCallbacks(client, message.message_id);
|
||||
|
||||
// Use first post image as primary media if no other media
|
||||
const primaryMedia = media ?? (postImages.length > 0 ? postImages[0] : null);
|
||||
const additionalMediaPaths = postImages.length > 1 ? postImages.slice(1).map((m) => m.path) : [];
|
||||
|
||||
// Reply/Thread metadata for inbound messages
|
||||
const replyToId = message.parent_id ?? message.root_id;
|
||||
const messageThreadId = message.root_id ?? undefined;
|
||||
|
||||
// Context construction
|
||||
const ctx = {
|
||||
Body: bodyText,
|
||||
RawBody: text || media?.placeholder || "",
|
||||
RawBody: text || primaryMedia?.placeholder || "",
|
||||
From: senderId,
|
||||
To: chatId,
|
||||
SessionKey: route.sessionKey,
|
||||
SenderId: senderId,
|
||||
SenderName: senderName,
|
||||
ChatType: isGroup ? "group" : "dm",
|
||||
@@ -294,14 +438,21 @@ export async function processFeishuMessage(
|
||||
Surface: "feishu",
|
||||
Timestamp: Number(message.create_time),
|
||||
MessageSid: message.message_id,
|
||||
AccountId: accountId,
|
||||
AccountId: route.accountId,
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: chatId,
|
||||
// Media fields (similar to Telegram)
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
MediaPath: primaryMedia?.path,
|
||||
MediaType: primaryMedia?.contentType,
|
||||
MediaUrl: primaryMedia?.path,
|
||||
// Additional images from post messages
|
||||
MediaUrls: additionalMediaPaths.length > 0 ? additionalMediaPaths : undefined,
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
// Reply/thread metadata when the inbound message is a reply
|
||||
MessageThreadId: messageThreadId,
|
||||
ReplyToId: replyToId,
|
||||
// Command authorization - if message reached here, sender passed access control
|
||||
CommandAuthorized: true,
|
||||
};
|
||||
|
||||
const agentId = resolveSessionAgentId({ config: cfg });
|
||||
@@ -361,6 +512,8 @@ export async function processFeishuMessage(
|
||||
{
|
||||
mediaUrl,
|
||||
receiveIdType: "chat_id",
|
||||
// Only reply to the first media item to avoid spamming quote replies
|
||||
replyToMessageId: i === 0 ? payload.replyToId : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -374,19 +527,37 @@ export async function processFeishuMessage(
|
||||
{
|
||||
msgType: "text",
|
||||
receiveIdType: "chat_id",
|
||||
replyToMessageId: payload.replyToId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error(`Reply error: ${formatErrorMessage(err)}`);
|
||||
const msg = formatErrorMessage(err);
|
||||
if (
|
||||
msg.includes("permission") ||
|
||||
msg.includes("forbidden") ||
|
||||
msg.includes("code: 99991660")
|
||||
) {
|
||||
logger.error(
|
||||
`Reply error: ${msg} (Check if "im:message" or "im:resource" permissions are enabled in Feishu Console)`,
|
||||
);
|
||||
} else {
|
||||
logger.error(`Reply error: ${msg}`);
|
||||
}
|
||||
// Clean up streaming session on error
|
||||
if (streamingSession?.isActive()) {
|
||||
streamingSession.close().catch(() => {});
|
||||
}
|
||||
// Clean up typing indicator on error
|
||||
typingCallbacks.onIdle().catch(() => {});
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
// Add typing indicator reaction (for non-streaming fallback)
|
||||
if (!streamingSession) {
|
||||
await typingCallbacks.onReplyStart();
|
||||
}
|
||||
// Start streaming card when reply generation begins
|
||||
if (streamingSession && !streamingStarted) {
|
||||
try {
|
||||
@@ -394,7 +565,14 @@ export async function processFeishuMessage(
|
||||
streamingStarted = true;
|
||||
logger.debug(`Started streaming card for chat ${chatId}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to start streaming card: ${formatErrorMessage(err)}`);
|
||||
const msg = formatErrorMessage(err);
|
||||
if (msg.includes("permission") || msg.includes("forbidden")) {
|
||||
logger.warn(
|
||||
`Failed to start streaming card: ${msg} (Check if "im:resource:msg:send" or card permissions are enabled)`,
|
||||
);
|
||||
} else {
|
||||
logger.warn(`Failed to start streaming card: ${msg}`);
|
||||
}
|
||||
// Continue without streaming
|
||||
}
|
||||
}
|
||||
@@ -435,4 +613,7 @@ export async function processFeishuMessage(
|
||||
if (streamingSession?.isActive()) {
|
||||
await streamingSession.close();
|
||||
}
|
||||
|
||||
// Clean up typing indicator
|
||||
await typingCallbacks.onIdle();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { resolveFeishuConfig } from "./config.js";
|
||||
import { normalizeFeishuDomain } from "./domain.js";
|
||||
import { processFeishuMessage } from "./message.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-monitor" });
|
||||
|
||||
@@ -70,6 +71,13 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
|
||||
},
|
||||
});
|
||||
|
||||
// Get bot's open_id for detecting mentions in group chats
|
||||
const probeResult = await probeFeishu(appId, appSecret, 5000, domain);
|
||||
const botOpenId = probeResult.bot?.openId ?? undefined;
|
||||
if (!botOpenId) {
|
||||
logger.warn(`Could not get bot open_id, group mention detection may not work correctly`);
|
||||
}
|
||||
|
||||
// Create event dispatcher
|
||||
const eventDispatcher = new Lark.EventDispatcher({}).register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
@@ -81,6 +89,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
|
||||
resolvedConfig: feishuCfg,
|
||||
credentials: { appId, appSecret, domain },
|
||||
botName: account.name,
|
||||
botOpenId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Error processing Feishu message: ${String(err)}`);
|
||||
|
||||
@@ -12,6 +12,7 @@ export type FeishuProbe = {
|
||||
appId?: string | null;
|
||||
appName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
openId?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -107,6 +108,7 @@ export async function probeFeishu(
|
||||
appId: appId,
|
||||
appName: botJson.bot?.app_name ?? null,
|
||||
avatarUrl: botJson.bot?.avatar_url ?? null,
|
||||
openId: botJson.bot?.open_id ?? null,
|
||||
};
|
||||
result.elapsedMs = Date.now() - started;
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
|
||||
/**
|
||||
* Reaction info returned from Feishu API
|
||||
*/
|
||||
export type FeishuReaction = {
|
||||
reactionId: string;
|
||||
emojiType: string;
|
||||
operatorType: "app" | "user";
|
||||
operatorId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a reaction (emoji) to a message.
|
||||
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART", "Typing"
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
*/
|
||||
export async function addReactionFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
emojiType: string,
|
||||
): Promise<{ reactionId: string }> {
|
||||
const response = (await client.im.messageReaction.create({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
reaction_type: {
|
||||
emoji_type: emojiType,
|
||||
},
|
||||
},
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: { reaction_id?: string };
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
const reactionId = response.data?.reaction_id;
|
||||
if (!reactionId) {
|
||||
throw new Error("Feishu add reaction failed: no reaction_id returned");
|
||||
}
|
||||
|
||||
return { reactionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message.
|
||||
*/
|
||||
export async function removeReactionFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
reactionId: string,
|
||||
): Promise<void> {
|
||||
const response = (await client.im.messageReaction.delete({
|
||||
path: {
|
||||
message_id: messageId,
|
||||
reaction_id: reactionId,
|
||||
},
|
||||
})) as { code?: number; msg?: string };
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all reactions for a message.
|
||||
*/
|
||||
export async function listReactionsFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
emojiType?: string,
|
||||
): Promise<FeishuReaction[]> {
|
||||
const response = (await client.im.messageReaction.list({
|
||||
path: { message_id: messageId },
|
||||
params: emojiType ? { reaction_type: emojiType } : undefined,
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
items?: Array<{
|
||||
reaction_id?: string;
|
||||
reaction_type?: { emoji_type?: string };
|
||||
operator_type?: string;
|
||||
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
const items = response.data?.items ?? [];
|
||||
return items.map((item) => ({
|
||||
reactionId: item.reaction_id ?? "",
|
||||
emojiType: item.reaction_type?.emoji_type ?? "",
|
||||
operatorType: item.operator_type === "app" ? "app" : "user",
|
||||
operatorId:
|
||||
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Common Feishu emoji types for convenience.
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
*/
|
||||
export const FeishuEmoji = {
|
||||
// Common reactions
|
||||
THUMBSUP: "THUMBSUP",
|
||||
THUMBSDOWN: "THUMBSDOWN",
|
||||
HEART: "HEART",
|
||||
SMILE: "SMILE",
|
||||
GRINNING: "GRINNING",
|
||||
LAUGHING: "LAUGHING",
|
||||
CRY: "CRY",
|
||||
ANGRY: "ANGRY",
|
||||
SURPRISED: "SURPRISED",
|
||||
THINKING: "THINKING",
|
||||
CLAP: "CLAP",
|
||||
OK: "OK",
|
||||
FIST: "FIST",
|
||||
PRAY: "PRAY",
|
||||
FIRE: "FIRE",
|
||||
PARTY: "PARTY",
|
||||
CHECK: "CHECK",
|
||||
CROSS: "CROSS",
|
||||
QUESTION: "QUESTION",
|
||||
EXCLAMATION: "EXCLAMATION",
|
||||
// Special typing indicator
|
||||
TYPING: "Typing",
|
||||
} as const;
|
||||
|
||||
export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji];
|
||||
+66
-11
@@ -18,6 +18,10 @@ export type FeishuSendOpts = {
|
||||
maxBytes?: number;
|
||||
/** Whether to auto-convert Markdown to rich text (post). Default: true */
|
||||
autoRichText?: boolean;
|
||||
/** Message ID to reply to (uses reply API instead of create) */
|
||||
replyToMessageId?: string;
|
||||
/** Whether to reply in thread mode. Default: false */
|
||||
replyInThread?: boolean;
|
||||
};
|
||||
|
||||
export type FeishuSendResult = {
|
||||
@@ -230,18 +234,25 @@ export async function sendMessageFeishu(
|
||||
// First send the media, then send text as a follow-up
|
||||
if (typeof contentText === "string" && contentText.trim()) {
|
||||
// Send media first
|
||||
const mediaRes = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: msgType,
|
||||
content: JSON.stringify(finalContent),
|
||||
},
|
||||
});
|
||||
const mediaContent = JSON.stringify(finalContent);
|
||||
if (opts.replyToMessageId) {
|
||||
await replyMessageFeishu(client, opts.replyToMessageId, mediaContent, msgType, {
|
||||
replyInThread: opts.replyInThread,
|
||||
});
|
||||
} else {
|
||||
const mediaRes = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: msgType,
|
||||
content: mediaContent,
|
||||
},
|
||||
});
|
||||
|
||||
if (mediaRes.code !== 0) {
|
||||
logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`);
|
||||
throw new Error(`Feishu API Error: ${mediaRes.msg}`);
|
||||
if (mediaRes.code !== 0) {
|
||||
logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`);
|
||||
throw new Error(`Feishu API Error: ${mediaRes.msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Then send text
|
||||
@@ -297,6 +308,13 @@ export async function sendMessageFeishu(
|
||||
|
||||
const contentStr = typeof finalContent === "string" ? finalContent : JSON.stringify(finalContent);
|
||||
|
||||
// Use reply API if replyToMessageId is provided
|
||||
if (opts.replyToMessageId) {
|
||||
return replyMessageFeishu(client, opts.replyToMessageId, contentStr, msgType, {
|
||||
replyInThread: opts.replyInThread,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
@@ -317,3 +335,40 @@ export async function sendMessageFeishu(
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type FeishuReplyOpts = {
|
||||
/** Whether to reply in thread mode. Default: false */
|
||||
replyInThread?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reply to a specific message in Feishu
|
||||
* Uses the Feishu reply API: POST /open-apis/im/v1/messages/:message_id/reply
|
||||
*/
|
||||
export async function replyMessageFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
content: string,
|
||||
msgType: FeishuMsgType,
|
||||
opts: FeishuReplyOpts = {},
|
||||
): Promise<FeishuSendResult | null> {
|
||||
try {
|
||||
const res = await client.im.message.reply({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
msg_type: msgType,
|
||||
content: content,
|
||||
reply_in_thread: opts.replyInThread ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.code !== 0) {
|
||||
logger.error(`Feishu reply failed: ${res.code} - ${res.msg}`);
|
||||
throw new Error(`Feishu API Error: ${res.msg}`);
|
||||
}
|
||||
return res.data ?? null;
|
||||
} catch (err) {
|
||||
logger.error(`Feishu reply error: ${formatErrorMessage(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { addReactionFeishu, removeReactionFeishu, FeishuEmoji } from "./reactions.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-typing" });
|
||||
|
||||
/**
|
||||
* Typing indicator state
|
||||
*/
|
||||
export type TypingIndicatorState = {
|
||||
messageId: string;
|
||||
reactionId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a typing indicator (reaction) to a message.
|
||||
*
|
||||
* Feishu doesn't have a native typing indicator API, so we use emoji reactions
|
||||
* as a visual substitute. The "Typing" emoji provides immediate feedback to users.
|
||||
*
|
||||
* Requires permission: im:message.reaction:read_write
|
||||
*/
|
||||
export async function addTypingIndicator(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
): Promise<TypingIndicatorState> {
|
||||
try {
|
||||
const { reactionId } = await addReactionFeishu(client, messageId, FeishuEmoji.TYPING);
|
||||
logger.debug(`Added typing indicator reaction: ${reactionId}`);
|
||||
return { messageId, reactionId };
|
||||
} catch (err) {
|
||||
// Silently fail - typing indicator is not critical
|
||||
logger.debug(`Failed to add typing indicator: ${formatErrorMessage(err)}`);
|
||||
return { messageId, reactionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a typing indicator (reaction) from a message.
|
||||
*/
|
||||
export async function removeTypingIndicator(
|
||||
client: Client,
|
||||
state: TypingIndicatorState,
|
||||
): Promise<void> {
|
||||
if (!state.reactionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeReactionFeishu(client, state.messageId, state.reactionId);
|
||||
logger.debug(`Removed typing indicator reaction: ${state.reactionId}`);
|
||||
} catch (err) {
|
||||
// Silently fail - cleanup is not critical
|
||||
logger.debug(`Failed to remove typing indicator: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create typing indicator callbacks for use with reply dispatchers.
|
||||
* These callbacks automatically manage the typing indicator lifecycle.
|
||||
*/
|
||||
export function createTypingIndicatorCallbacks(
|
||||
client: Client,
|
||||
messageId: string | undefined,
|
||||
): {
|
||||
state: { current: TypingIndicatorState | null };
|
||||
onReplyStart: () => Promise<void>;
|
||||
onIdle: () => Promise<void>;
|
||||
} {
|
||||
const state: { current: TypingIndicatorState | null } = { current: null };
|
||||
|
||||
return {
|
||||
state,
|
||||
onReplyStart: async () => {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
state.current = await addTypingIndicator(client, messageId);
|
||||
},
|
||||
onIdle: async () => {
|
||||
if (!state.current) {
|
||||
return;
|
||||
}
|
||||
await removeTypingIndicator(client, state.current);
|
||||
state.current = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-user" });
|
||||
|
||||
export type FeishuUserInfo = {
|
||||
openId: string;
|
||||
name?: string;
|
||||
enName?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
// Simple in-memory cache for user info (expires after 1 hour)
|
||||
const userCache = new Map<string, { info: FeishuUserInfo; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/**
|
||||
* Get user information from Feishu
|
||||
* Uses the contact API: GET /open-apis/contact/v3/users/:user_id
|
||||
* Requires permission: contact:user.base:readonly or contact:contact:readonly_as_app
|
||||
*/
|
||||
export async function getFeishuUserInfo(
|
||||
client: Client,
|
||||
openId: string,
|
||||
): Promise<FeishuUserInfo | null> {
|
||||
// Check cache first
|
||||
const cached = userCache.get(openId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.info;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await client.contact.user.get({
|
||||
path: { user_id: openId },
|
||||
params: { user_id_type: "open_id" },
|
||||
});
|
||||
|
||||
if (res.code !== 0) {
|
||||
logger.debug(`Failed to get user info for ${openId}: ${res.code} - ${res.msg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = res.data?.user;
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info: FeishuUserInfo = {
|
||||
openId,
|
||||
name: user.name,
|
||||
enName: user.en_name,
|
||||
avatar: user.avatar?.avatar_240,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
userCache.set(openId, {
|
||||
info,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
return info;
|
||||
} catch (err) {
|
||||
// Gracefully handle permission errors - just log and return null
|
||||
logger.debug(`Error getting user info for ${openId}: ${formatErrorMessage(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a user
|
||||
* Falls back to openId if name is not available
|
||||
*/
|
||||
export async function getFeishuUserDisplayName(
|
||||
client: Client,
|
||||
openId: string,
|
||||
fallback?: string,
|
||||
): Promise<string> {
|
||||
const info = await getFeishuUserInfo(client, openId);
|
||||
return info?.name || info?.enName || fallback || openId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear expired entries from the cache
|
||||
*/
|
||||
export function cleanupUserCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of userCache) {
|
||||
if (value.expiresAt < now) {
|
||||
userCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user