feat: improve Feishu markdown rendering and processing status feedback

- Add buildOutboundPayload: detect markdown format, isolate code blocks,
  force plain text for tables, fallback from post to text on API rejection
- Add addReaction/removeReaction for processing status lifecycle
- Replace THUMBSUP with Typing→(remove on success | CrossMark on failure)
- Add unit tests for markdown rendering (15) and reactions (6)
This commit is contained in:
2026-07-08 16:31:07 +08:00
parent a00e4f9871
commit 6227e1931b
5 changed files with 693 additions and 9 deletions
+223 -6
View File
@@ -2,7 +2,7 @@
* Feishu lark client + long-connection event dispatcher.
*
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
* message sending (text-as-card for patchability), streaming patch, emoji
* message sending, streaming patch via interactive cards, emoji
* reactions, file download/upload, and the card action callback.
*/
import * as lark from "@larksuiteoapi/node-sdk";
@@ -21,6 +21,11 @@ export interface FeishuRuntime {
readonly logger: FastifyBaseLogger;
}
export interface OutboundPayload {
readonly msgType: string;
readonly content: string;
}
/** Raw `im.message.receive_v1` event payload. */
export interface MessageReceiveEvent {
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
@@ -64,19 +69,92 @@ export interface CardActionEvent {
// --- Message helpers ---
/** Send a text-as-card message (looks like text, but patchable). */
export function buildOutboundPayload(content: string): OutboundPayload {
if (containsMarkdownTable(content)) {
return { msgType: "text", content: JSON.stringify({ text: content }) };
}
if (containsMarkdownSyntax(content)) {
return {
msgType: "post",
content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }),
};
}
return { msgType: "text", content: JSON.stringify({ text: content }) };
}
export function _buildMarkdownPostRows(content: string): unknown[][] {
if (!content.includes("```")) {
return [[{ tag: "md", text: content }]];
}
const rows: unknown[][] = [];
const codeBlockPattern = /```[\s\S]*?```/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = codeBlockPattern.exec(content)) !== null) {
if (match.index > cursor) {
rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]);
}
rows.push([{ tag: "md", text: match[0] }]);
cursor = match.index + match[0].length;
}
if (rows.length === 0) {
return [[{ tag: "md", text: content }]];
}
if (cursor < content.length) {
rows.push([{ tag: "md", text: content.slice(cursor) }]);
}
return rows;
}
export function _stripMarkdownToPlainText(text: string): string {
return text
.replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1")
.replace(/^ {0,3}#{1,6}\s+/gm, "")
.replace(/^ {0,3}>\s?/gm, "")
.replace(/^(\s*)[-*+]\s+/gm, "$1")
.replace(/^(\s*)\d+[.)]\s+/gm, "$1")
.replace(/\*\*([^*\n]+)\*\*/g, "$1")
.replace(/__([^_\n]+)__/g, "$1")
.replace(/~~([^~\n]+)~~/g, "$1")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2")
.replace(/(^|[^_])_([^_\n]+)_/g, "$1$2");
}
/** Send a text message using the best Feishu message type for the content. */
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
const payload = buildOutboundPayload(text);
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
});
return messageIdFromResponse(res);
} catch { return null; }
} catch (e) {
if (payload.msgType === "post" && isPostContentFormatError(e)) {
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }),
},
});
return messageIdFromResponse(res);
} catch {
return null;
}
}
return null;
}
}
/** Send a plain text message (fire-and-forget). */
@@ -86,7 +164,8 @@ export async function sendText(rt: FeishuRuntime, chatId: string, text: string):
/** Patch a text-as-card message in-place (streaming updates). */
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
const payload = buildOutboundPayload(text);
const card = buildInteractiveCardFromOutboundPayload(payload);
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
@@ -114,6 +193,39 @@ export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji
}
}
/** Add a reaction to a message, returning the reaction_id (needed for later removal). */
export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise<string | null> {
try {
const res = await rt.client.request({
method: "POST",
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
data: { reaction_type: { emoji_type: emojiType } },
}) as ReactionCreateResponse;
const reactionId = reactionIdFromResponse(res);
if (reactionId === null) {
rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id");
}
return reactionId;
} catch (e) {
rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed");
return null;
}
}
/** Remove a previously added reaction by its reaction_id. */
export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise<boolean> {
try {
await rt.client.request({
method: "DELETE",
url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`,
});
return true;
} catch (e) {
rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed");
return false;
}
}
// --- File helpers ---
/** Download a file/image from a Feishu message to a local path. */
@@ -180,6 +292,11 @@ type FileCreateResponse = {
readonly data?: { readonly file_key?: string | undefined } | undefined;
} | null;
type ReactionCreateResponse = {
readonly reaction_id?: string | undefined;
readonly data?: { readonly reaction_id?: string | undefined } | undefined;
} | null;
function messageIdFromResponse(res: MessageCreateResponse): string | null {
return res?.data?.message_id ?? res?.message_id ?? null;
}
@@ -188,6 +305,106 @@ function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
return res?.file_key ?? res?.data?.file_key;
}
function reactionIdFromResponse(res: ReactionCreateResponse): string | null {
return res?.data?.reaction_id ?? res?.reaction_id ?? null;
}
function containsMarkdownSyntax(content: string): boolean {
const patterns = [
/^ {0,3}#{1,6}\s+\S/m,
/^ {0,3}>\s+\S/m,
/^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m,
/```/,
/`[^`\n]+`/,
/\*\*[^*\n]+\*\*/,
/__[^_\n]+__/,
/~~[^~\n]+~~/,
/(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/,
/(^|[^_])_[^_\s][^_\n]*_(?!_)/,
/\[[^\]\n]+\]\([^)]+\)/,
];
return patterns.some((pattern) => pattern.test(content));
}
function containsMarkdownTable(content: string): boolean {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length - 1; i += 1) {
const current = lines[i];
const next = lines[i + 1];
if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) {
return true;
}
}
return false;
}
function isTableHeaderLine(line: string): boolean {
const trimmed = line.trim();
return trimmed.startsWith("|") && trimmed.includes("|", 1);
}
function isTableSeparatorLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.startsWith("|")) return false;
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|");
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } {
return {
elements: rowsFromOutboundPayload(payload).map((row) => ({
tag: "div",
text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") },
})),
};
}
function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] {
if (payload.msgType !== "post") {
return [[{ tag: "md", text: textFromTextPayload(payload.content) }]];
}
try {
const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } };
return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]];
} catch {
return [[{ tag: "md", text: "" }]];
}
}
function textFromTextPayload(content: string): string {
try {
const parsed = JSON.parse(content) as { text?: unknown };
return typeof parsed.text === "string" ? parsed.text : content;
} catch {
return content;
}
}
function markdownTextFromPostElement(element: unknown): string {
if (typeof element !== "object" || element === null) return "";
const text = (element as { text?: unknown }).text;
return typeof text === "string" ? text : "";
}
function isPostContentFormatError(error: unknown): boolean {
return errorText(error).includes("content format of the post type is incorrect");
}
function errorText(error: unknown): string {
if (error instanceof Error) {
return `${error.message} ${errorText((error as { response?: unknown }).response)}`;
}
if (typeof error === "string") return error;
if (typeof error === "object" && error !== null) {
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
return String(error);
}
// --- Lark client + ws listener ---
export function createLarkClient(config: FeishuConfig): lark.Client {
+11 -3
View File
@@ -11,7 +11,7 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify";
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, addReaction, removeReaction, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
@@ -309,8 +309,11 @@ export function makeTriggerHandler(deps: TriggerDeps) {
workspaceDir: project.workspaceDir,
};
// Emoji reaction on the user's message (acknowledge receipt).
await reactToMessage(rt, msg.message_id, "THUMBSUP");
const processingReactionId = await addReaction(rt, msg.message_id, "Typing");
const removeProcessingReaction = async (): Promise<boolean> => {
if (processingReactionId === null) return false;
return removeReaction(rt, msg.message_id, processingReactionId);
};
// Stream agent response as text-as-card messages. Lazy-init: don't send
// anything until the first text-delta arrives (avoids empty placeholder).
@@ -381,8 +384,13 @@ export function makeTriggerHandler(deps: TriggerDeps) {
action: "run.finished",
metadata: { status: result.status, deliveredFiles },
});
await removeProcessingReaction();
})
.catch(async (e) => {
const removedProcessingReaction = await removeProcessingReaction();
if (removedProcessingReaction) {
await addReaction(rt, msg.message_id, "CrossMark");
}
try {
await deps.prisma.agentRun.update({
where: { id: run.id },