forked from bai/curriculum-project-hub
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:
@@ -0,0 +1,20 @@
|
||||
# AGENTS.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
+223
-6
@@ -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,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 },
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_buildMarkdownPostRows,
|
||||
buildOutboundPayload,
|
||||
patchTextMessage,
|
||||
sendTextMessage,
|
||||
type FeishuRuntime,
|
||||
} from "../../src/feishu/client.js";
|
||||
|
||||
describe("Feishu markdown outbound payloads", () => {
|
||||
it("uses text messages for plain text", () => {
|
||||
const payload = buildOutboundPayload("hello from cph");
|
||||
|
||||
expect(payload.msgType).toBe("text");
|
||||
expect(JSON.parse(payload.content)).toEqual({ text: "hello from cph" });
|
||||
});
|
||||
|
||||
it("keeps fenced code blocks in separate post rows", () => {
|
||||
const content = "Before\n```ts\nconst x = 1;\n```\nAfter";
|
||||
|
||||
expect(_buildMarkdownPostRows(content)).toEqual([
|
||||
[{ tag: "md", text: "Before\n" }],
|
||||
[{ tag: "md", text: "```ts\nconst x = 1;\n```" }],
|
||||
[{ tag: "md", text: "\nAfter" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("forces markdown tables to plain text", () => {
|
||||
const content = "| A | B |\n|---|---|\n| 1 | 2 |";
|
||||
const payload = buildOutboundPayload(content);
|
||||
|
||||
expect(payload.msgType).toBe("text");
|
||||
expect(JSON.parse(payload.content)).toEqual({ text: content });
|
||||
});
|
||||
|
||||
it("uses post messages for markdown headers, bold text, and lists", () => {
|
||||
const content = "# Title\n\nThis is **important**.\n\n- first\n- second";
|
||||
const payload = buildOutboundPayload(content);
|
||||
|
||||
expect(payload.msgType).toBe("post");
|
||||
expect(JSON.parse(payload.content)).toEqual({
|
||||
zh_cn: {
|
||||
content: [[{ tag: "md", text: content }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["header", "# Heading"],
|
||||
["list", "- item"],
|
||||
["code block", "```ts\nconst x = 1;\n```"],
|
||||
["inline code", "Use `code` here"],
|
||||
["bold", "**bold**"],
|
||||
["italic", "_italic_"],
|
||||
["strikethrough", "~~removed~~"],
|
||||
["link", "[docs](https://example.com)"],
|
||||
["blockquote", "> quoted"],
|
||||
])("detects markdown %s syntax", (_name, content) => {
|
||||
expect(buildOutboundPayload(content).msgType).toBe("post");
|
||||
});
|
||||
|
||||
it("falls back from rejected post payloads to stripped plain text", async () => {
|
||||
const messageCreate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("content format of the post type is incorrect"))
|
||||
.mockResolvedValueOnce({ data: { message_id: "message-1" } });
|
||||
const rt = mockRuntime({ messageCreate });
|
||||
|
||||
await expect(sendTextMessage(rt, "chat-1", "# Title\n\n**bold** and `code`")).resolves.toBe("message-1");
|
||||
|
||||
expect(messageCreate).toHaveBeenCalledTimes(2);
|
||||
expect(messageCreate).toHaveBeenNthCalledWith(1, {
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: "chat-1",
|
||||
msg_type: "post",
|
||||
content: JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [[{ tag: "md", text: "# Title\n\n**bold** and `code`" }]],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(messageCreate).toHaveBeenNthCalledWith(2, {
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: "chat-1",
|
||||
msg_type: "text",
|
||||
content: JSON.stringify({ text: "Title\n\nbold and code" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("patches markdown post rows as separate interactive card elements", async () => {
|
||||
const patch = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ patch });
|
||||
|
||||
await patchTextMessage(rt, "message-1", "Before\n```ts\nconst x = 1;\n```\nAfter");
|
||||
|
||||
expect(patch).toHaveBeenCalledWith({
|
||||
path: { message_id: "message-1" },
|
||||
params: { msg_type: "interactive" },
|
||||
data: {
|
||||
content: JSON.stringify({
|
||||
elements: [
|
||||
{ tag: "div", text: { tag: "lark_md", content: "Before\n" } },
|
||||
{ tag: "div", text: { tag: "lark_md", content: "```ts\nconst x = 1;\n```" } },
|
||||
{ tag: "div", text: { tag: "lark_md", content: "\nAfter" } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
|
||||
readonly patch?: (payload: unknown) => Promise<unknown>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
|
||||
patch: options.patch ?? vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
||||
import type { FeishuRuntime, MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||
import { addReaction, removeReaction } from "../../src/feishu/client.js";
|
||||
import { makeTriggerHandler } from "../../src/feishu/trigger.js";
|
||||
import type { PermissionAuthorizer } from "../../src/permission.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
describe("Feishu reactions", () => {
|
||||
it("addReaction returns reaction_id on success", async () => {
|
||||
const request = vi.fn(async () => ({ data: { reaction_id: "reaction-1" } }));
|
||||
const rt = mockRuntime({ request });
|
||||
|
||||
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBe("reaction-1");
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
method: "POST",
|
||||
url: "/open-apis/im/v1/messages/message-1/reactions",
|
||||
data: { reaction_type: { emoji_type: "Typing" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("addReaction returns null on API failure", async () => {
|
||||
const rt = mockRuntime({
|
||||
request: vi.fn(async () => {
|
||||
throw new Error("api failed");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(addReaction(rt, "message-1", "Typing")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("removeReaction returns true on success", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ request });
|
||||
|
||||
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(true);
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
method: "DELETE",
|
||||
url: "/open-apis/im/v1/messages/message-1/reactions/reaction-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("removeReaction returns false on API failure", async () => {
|
||||
const rt = mockRuntime({
|
||||
request: vi.fn(async () => {
|
||||
throw new Error("api failed");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("adds Typing on start and removes it on success", async () => {
|
||||
const run = deferred<RunResult>();
|
||||
const rt = mockRuntime();
|
||||
const runAgent = vi.fn((req: RunRequest) => {
|
||||
req.onStream?.({ type: "text-delta", text: "done" });
|
||||
return run.promise;
|
||||
});
|
||||
|
||||
await triggerWithRunAgent(runAgent, rt);
|
||||
|
||||
expect(rt.reactionRequests).toEqual([
|
||||
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
|
||||
]);
|
||||
|
||||
run.resolve(completedRunResult("done"));
|
||||
await vi.waitFor(() => {
|
||||
expect(rt.reactionRequests).toEqual([
|
||||
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
|
||||
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces Typing with CrossMark on failure", async () => {
|
||||
const run = deferred<RunResult>();
|
||||
const rt = mockRuntime();
|
||||
const runAgent = vi.fn(() => run.promise);
|
||||
|
||||
await triggerWithRunAgent(runAgent, rt);
|
||||
|
||||
run.reject(new Error("agent failed"));
|
||||
await vi.waitFor(() => {
|
||||
expect(rt.reactionRequests).toEqual([
|
||||
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
|
||||
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
|
||||
{ kind: "add", messageId: "message-1", emoji: "CrossMark", reactionId: "reaction-2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function triggerWithRunAgent(
|
||||
runAgent: (req: RunRequest) => Promise<RunResult>,
|
||||
rt: MockRuntime,
|
||||
): Promise<void> {
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma: mockPrisma(),
|
||||
settings: mockSettings(),
|
||||
logger: rt.logger,
|
||||
authorizer: allowAllAuthorizer(),
|
||||
runAgent,
|
||||
});
|
||||
|
||||
await trigger(makeEvent(), rt);
|
||||
}
|
||||
|
||||
function completedRunResult(text: string): RunResult {
|
||||
return {
|
||||
status: "completed",
|
||||
text,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
numTurns: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
readonly reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
|
||||
function makeEvent(): MessageReceiveEvent {
|
||||
return {
|
||||
message: {
|
||||
message_id: "message-1",
|
||||
chat_id: "chat-1",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "@_user_1 do work" }),
|
||||
mentions: [bot],
|
||||
},
|
||||
sender: { sender_id: { open_id: "ou_user" }, sender_type: "user" },
|
||||
};
|
||||
}
|
||||
|
||||
type ReactionRequest =
|
||||
| { readonly kind: "add"; readonly messageId: string; readonly emoji: string; readonly reactionId: string }
|
||||
| { readonly kind: "remove"; readonly messageId: string; readonly reactionId: string };
|
||||
|
||||
interface MockRuntime extends FeishuRuntime {
|
||||
readonly reactionRequests: ReactionRequest[];
|
||||
}
|
||||
|
||||
function mockRuntime(options: { readonly request?: (payload: unknown) => Promise<unknown> } = {}): MockRuntime {
|
||||
const reactionRequests: ReactionRequest[] = [];
|
||||
let nextReactionId = 1;
|
||||
const request =
|
||||
options.request ??
|
||||
vi.fn(async (payload: unknown) => {
|
||||
const requestPayload = payload as { method?: string; url?: string; data?: { reaction_type?: { emoji_type?: string } } };
|
||||
const addMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions$/);
|
||||
if (requestPayload.method === "POST" && addMatch?.[1] !== undefined) {
|
||||
const reactionId = `reaction-${nextReactionId}`;
|
||||
nextReactionId += 1;
|
||||
reactionRequests.push({
|
||||
kind: "add",
|
||||
messageId: addMatch[1],
|
||||
emoji: requestPayload.data?.reaction_type?.emoji_type ?? "",
|
||||
reactionId,
|
||||
});
|
||||
return { data: { reaction_id: reactionId } };
|
||||
}
|
||||
|
||||
const removeMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions\/([^/]+)$/);
|
||||
if (requestPayload.method === "DELETE" && removeMatch?.[1] !== undefined && removeMatch[2] !== undefined) {
|
||||
reactionRequests.push({ kind: "remove", messageId: removeMatch[1], reactionId: removeMatch[2] });
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
return {
|
||||
client: {
|
||||
request,
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: vi.fn(async () => ({ data: { message_id: "reply-message-1" } })),
|
||||
patch: vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: silentLogger(),
|
||||
reactionRequests,
|
||||
};
|
||||
}
|
||||
|
||||
function mockSettings(): RuntimeSettings {
|
||||
const registry = new InMemoryModelRegistry(
|
||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
||||
[{ id: "draft", label: "Draft", defaultModel: "mock-model" }],
|
||||
);
|
||||
return {
|
||||
async provider(providerId) {
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: "https://example.invalid",
|
||||
authToken: "test-token",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {},
|
||||
};
|
||||
},
|
||||
async modelRegistry() {
|
||||
return registry;
|
||||
},
|
||||
async runPolicy() {
|
||||
return { maxTurns: 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function allowAllAuthorizer(): PermissionAuthorizer {
|
||||
return {
|
||||
async can(req) {
|
||||
return {
|
||||
allowed: true,
|
||||
reason: "test allow",
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
actorUserId: "user-1",
|
||||
principals: [{ type: "USER", id: "ou_user" }],
|
||||
requiredRole: "EDIT",
|
||||
effectiveRole: "EDIT",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockPrisma(): PrismaClient {
|
||||
let lock: { projectId: string; runId: string } | null = null;
|
||||
const session = { id: "session-1", metadata: {} };
|
||||
|
||||
return {
|
||||
feishuEventReceipt: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: "receipt-1" })),
|
||||
},
|
||||
projectGroupBinding: {
|
||||
findUnique: vi.fn(async () => ({ projectId: "project-1" })),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),
|
||||
},
|
||||
agentSession: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
create: vi.fn(async () => session),
|
||||
update: vi.fn(async () => session),
|
||||
},
|
||||
agentRun: {
|
||||
create: vi.fn(async () => ({ id: "run-1" })),
|
||||
findUnique: vi.fn(async () => null),
|
||||
update: vi.fn(async () => ({ id: "run-1" })),
|
||||
},
|
||||
projectAgentLock: {
|
||||
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
|
||||
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
|
||||
lock = { projectId: args.data.projectId, runId: args.data.runId };
|
||||
return lock;
|
||||
}),
|
||||
deleteMany: vi.fn(async () => {
|
||||
lock = null;
|
||||
return { count: 1 };
|
||||
}),
|
||||
},
|
||||
auditEntry: {
|
||||
create: vi.fn(async () => ({ id: "audit-1" })),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
function silentLogger(): FeishuRuntime["logger"] {
|
||||
return {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"];
|
||||
}
|
||||
Reference in New Issue
Block a user