@rynx-ai/plugin-channel-lark 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli-mirror/mirror-index.d.ts +46 -0
  2. package/dist/cli-mirror/mirror-index.js +48 -0
  3. package/dist/cli-mirror/rollout-mirror.d.ts +85 -0
  4. package/dist/cli-mirror/rollout-mirror.js +333 -0
  5. package/dist/cli-mirror/rollout-watcher.d.ts +109 -0
  6. package/dist/cli-mirror/rollout-watcher.js +347 -0
  7. package/dist/host-adapters.d.ts +82 -0
  8. package/dist/host-adapters.js +404 -0
  9. package/dist/index.d.ts +7 -0
  10. package/dist/index.js +7 -0
  11. package/dist/lark-card-stream.d.ts +363 -0
  12. package/dist/lark-card-stream.js +1335 -0
  13. package/dist/lark-content.d.ts +30 -0
  14. package/dist/lark-content.js +138 -0
  15. package/dist/lark-conversation-directory.d.ts +65 -0
  16. package/dist/lark-conversation-directory.js +106 -0
  17. package/dist/lark-diff-card.d.ts +25 -0
  18. package/dist/lark-diff-card.js +58 -0
  19. package/dist/lark-fork-transcript.d.ts +16 -0
  20. package/dist/lark-fork-transcript.js +141 -0
  21. package/dist/lark-resume-card.d.ts +31 -0
  22. package/dist/lark-resume-card.js +105 -0
  23. package/dist/lark-sdk/client.d.ts +8 -0
  24. package/dist/lark-sdk/client.js +32 -0
  25. package/dist/lark-sdk/index.d.ts +1 -0
  26. package/dist/lark-sdk/index.js +1 -0
  27. package/dist/lark-sender.d.ts +114 -0
  28. package/dist/lark-sender.js +323 -0
  29. package/dist/lark-session-store.d.ts +13 -0
  30. package/dist/lark-session-store.js +14 -0
  31. package/dist/lark-settings-card.d.ts +55 -0
  32. package/dist/lark-settings-card.js +158 -0
  33. package/dist/lark-setup.d.ts +17 -0
  34. package/dist/lark-setup.js +86 -0
  35. package/dist/lark.d.ts +500 -0
  36. package/dist/lark.js +2010 -0
  37. package/dist/runtime.d.ts +16 -0
  38. package/dist/runtime.js +157 -0
  39. package/dist/runtime.mjs +130863 -0
  40. package/package.json +35 -0
  41. package/rynx-plugin.json +42 -0
@@ -0,0 +1,30 @@
1
+ import type { LarkMessageReceiveEvent } from "./lark.js";
2
+ export declare function extractLarkTextPrompt(event: LarkMessageReceiveEvent): string;
3
+ export declare function isLarkNewSessionCommand(prompt: string): boolean;
4
+ export declare function isLarkStopCommand(prompt: string): boolean;
5
+ export interface LarkCommand {
6
+ /** Lower-cased command name without the leading slash, e.g. `search`. */
7
+ name: string;
8
+ /** Trimmed argument string after the command name (may be empty). */
9
+ args: string;
10
+ }
11
+ /**
12
+ * Parse a `/command [args...]` message into `{ name, args }`. Returns null for
13
+ * non-command text. The command name must start with a letter so bare `/` or
14
+ * `/123` is not treated as a command. Used for the informational slash-command
15
+ * registry (`/help`, `/models`, `/search`); `/new` and `/stop` keep their
16
+ * exact-match predicates above for backward compatibility.
17
+ */
18
+ export declare function parseLarkCommand(prompt: string): LarkCommand | null;
19
+ export declare function chunkLarkReplyText(text: string, maxBytes?: number): string[];
20
+ /**
21
+ * Wrap a turn that replies to a Lark message card so the agent receives the
22
+ * full card content as context. Intentionally generic: it makes no assumption
23
+ * about the card's purpose and names no specific skill. The agent inspects the
24
+ * card content and decides how to handle the request (and which skill, if any,
25
+ * matches).
26
+ */
27
+ export declare function buildLarkCardReplyPrompt({ userPrompt, cardContent, }: {
28
+ userPrompt: string;
29
+ cardContent: string;
30
+ }): string;
@@ -0,0 +1,138 @@
1
+ import { Buffer } from "node:buffer";
2
+ const LARK_TEXT_MAX_BYTES = 120 * 1024;
3
+ export function extractLarkTextPrompt(event) {
4
+ try {
5
+ const parsed = JSON.parse(event.message.content);
6
+ let text = "";
7
+ if (event.message.message_type === "text") {
8
+ text = typeof parsed.text === "string" ? parsed.text : "";
9
+ }
10
+ if (event.message.message_type === "post") {
11
+ text = extractLarkPostPrompt(parsed, event.message.mentions ?? []);
12
+ }
13
+ for (const mention of event.message.mentions ?? []) {
14
+ text = text.replaceAll(mention.key, `@${mention.name}`);
15
+ }
16
+ return text.replace(/^(\s*@\S+\s*)+/, "").trim();
17
+ }
18
+ catch {
19
+ return "";
20
+ }
21
+ }
22
+ export function isLarkNewSessionCommand(prompt) {
23
+ return prompt === "/new";
24
+ }
25
+ export function isLarkStopCommand(prompt) {
26
+ const normalized = prompt.trim();
27
+ return normalized === "/stop" || normalized === "/cancel";
28
+ }
29
+ /**
30
+ * Parse a `/command [args...]` message into `{ name, args }`. Returns null for
31
+ * non-command text. The command name must start with a letter so bare `/` or
32
+ * `/123` is not treated as a command. Used for the informational slash-command
33
+ * registry (`/help`, `/models`, `/search`); `/new` and `/stop` keep their
34
+ * exact-match predicates above for backward compatibility.
35
+ */
36
+ export function parseLarkCommand(prompt) {
37
+ const trimmed = prompt.trim();
38
+ if (!trimmed.startsWith("/")) {
39
+ return null;
40
+ }
41
+ const match = /^\/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$/.exec(trimmed);
42
+ if (!match) {
43
+ return null;
44
+ }
45
+ return { name: match[1].toLowerCase(), args: (match[2] ?? "").trim() };
46
+ }
47
+ export function chunkLarkReplyText(text, maxBytes = LARK_TEXT_MAX_BYTES) {
48
+ if (!text) {
49
+ return [];
50
+ }
51
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) {
52
+ return [text];
53
+ }
54
+ const chunks = [];
55
+ let current = "";
56
+ for (const character of text) {
57
+ const next = `${current}${character}`;
58
+ if (current && Buffer.byteLength(next, "utf8") > maxBytes) {
59
+ chunks.push(current);
60
+ current = character;
61
+ continue;
62
+ }
63
+ current = next;
64
+ }
65
+ if (current) {
66
+ chunks.push(current);
67
+ }
68
+ return chunks;
69
+ }
70
+ /**
71
+ * Wrap a turn that replies to a Lark message card so the agent receives the
72
+ * full card content as context. Intentionally generic: it makes no assumption
73
+ * about the card's purpose and names no specific skill. The agent inspects the
74
+ * card content and decides how to handle the request (and which skill, if any,
75
+ * matches).
76
+ */
77
+ export function buildLarkCardReplyPrompt({ userPrompt, cardContent, }) {
78
+ const trimmedPrompt = userPrompt.trim();
79
+ return [
80
+ "The user replied to a Lark message card. The full card content is included below — use it as context to handle the user's request, and if one of your skills matches this kind of card, use that skill.",
81
+ trimmedPrompt ? `User reply: ${trimmedPrompt}` : "User reply: (empty)",
82
+ "Lark card content:",
83
+ "```json",
84
+ cardContent,
85
+ "```",
86
+ ].join("\n");
87
+ }
88
+ function extractLarkPostPrompt(parsed, mentions) {
89
+ const locale = resolveLarkPostLocale(parsed);
90
+ if (!locale) {
91
+ return "";
92
+ }
93
+ const lines = [];
94
+ if (typeof locale.title === "string" && locale.title.trim()) {
95
+ lines.push(locale.title.trim());
96
+ }
97
+ for (const block of locale.content ?? []) {
98
+ const line = block
99
+ .map((item) => renderLarkPostItem(item, mentions))
100
+ .join("")
101
+ .trim();
102
+ if (line) {
103
+ lines.push(line);
104
+ }
105
+ }
106
+ return lines.join("\n");
107
+ }
108
+ function resolveLarkPostLocale(parsed) {
109
+ if (Array.isArray(parsed.content)) {
110
+ return parsed;
111
+ }
112
+ for (const value of Object.values(parsed)) {
113
+ if (value &&
114
+ typeof value === "object" &&
115
+ Array.isArray(value.content)) {
116
+ return value;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+ function renderLarkPostItem(item, mentions) {
122
+ if (item.tag === "at") {
123
+ return `@${item.user_name ?? resolveMentionName(item, mentions)}`;
124
+ }
125
+ if (typeof item.text === "string") {
126
+ return item.text;
127
+ }
128
+ if (item.tag === "a" && typeof item.href === "string") {
129
+ return item.href;
130
+ }
131
+ return "";
132
+ }
133
+ function resolveMentionName(item, mentions) {
134
+ if (item.user_name) {
135
+ return item.user_name;
136
+ }
137
+ return mentions.at(0)?.name ?? "用户";
138
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Lark adapter over the channel-agnostic @rynx-ai/core conversation directory.
3
+ * Keeps the Lark-facing record shape + API (so the resume card, mirror index,
4
+ * and ingestor are unchanged) while delegating storage + indexing to core's
5
+ * generic FileConversationDirectory, parameterized over Lark's routing payload
6
+ * (chat/thread ids). The plugin runtime supplies one explicit file per instance.
7
+ */
8
+ import { deriveConversationShortId as coreDeriveConversationShortId } from "@rynx-ai/core";
9
+ /** Conversation kind, derived from the inbound message. */
10
+ export type LarkConversationKind = "p2p" | "group" | "thread";
11
+ export interface LarkConversationRecord {
12
+ /** Stable conversation key (= the bot's sessionKey), e.g. `lark:t:chat:oc_x`. */
13
+ conversationKey: string;
14
+ /** Stable short id (6 hex chars derived from the key) for `/resume <id>`. */
15
+ shortId: string;
16
+ chatId: string;
17
+ /** Present only for topic-thread conversations. */
18
+ threadId?: string;
19
+ kind: LarkConversationKind;
20
+ /** Last user message text (truncated), used as the display title. */
21
+ title: string;
22
+ /** open_ids that have sent a message in this conversation. */
23
+ participants: string[];
24
+ updatedAt: string;
25
+ }
26
+ export interface LarkRecordConversationInput {
27
+ conversationKey: string;
28
+ chatId: string;
29
+ threadId?: string;
30
+ kind: LarkConversationKind;
31
+ title: string;
32
+ participantOpenId?: string;
33
+ }
34
+ export interface LarkConversationDirectory {
35
+ readonly filePath: string;
36
+ record(input: LarkRecordConversationInput): Promise<LarkConversationRecord>;
37
+ /** Conversations the given user has participated in, newest first. */
38
+ listForUser(openId: string, limit?: number): Promise<LarkConversationRecord[]>;
39
+ getByShortId(shortId: string): Promise<LarkConversationRecord | null>;
40
+ get(conversationKey: string): Promise<LarkConversationRecord | null>;
41
+ }
42
+ /** Re-exported from @rynx-ai/core (kept here for existing importers). */
43
+ export declare const deriveConversationShortId: typeof coreDeriveConversationShortId;
44
+ export declare class FileLarkConversationDirectory implements LarkConversationDirectory {
45
+ private readonly core;
46
+ constructor({ filePath, now, }: {
47
+ filePath: string;
48
+ now?: () => string;
49
+ });
50
+ get filePath(): string;
51
+ record(input: LarkRecordConversationInput): Promise<LarkConversationRecord>;
52
+ listForUser(openId: string, limit?: number): Promise<LarkConversationRecord[]>;
53
+ getByShortId(shortId: string): Promise<LarkConversationRecord | null>;
54
+ get(conversationKey: string): Promise<LarkConversationRecord | null>;
55
+ }
56
+ /** Non-persistent test/direct-construction helper. Production uses the file
57
+ * implementation with an explicit context.dataDir-scoped path. */
58
+ export declare class MemoryLarkConversationDirectory implements LarkConversationDirectory {
59
+ readonly filePath = "memory://lark-conversations";
60
+ private readonly records;
61
+ record(input: LarkRecordConversationInput): Promise<LarkConversationRecord>;
62
+ listForUser(openId: string, limit?: number): Promise<LarkConversationRecord[]>;
63
+ getByShortId(shortId: string): Promise<LarkConversationRecord | null>;
64
+ get(conversationKey: string): Promise<LarkConversationRecord | null>;
65
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Lark adapter over the channel-agnostic @rynx-ai/core conversation directory.
3
+ * Keeps the Lark-facing record shape + API (so the resume card, mirror index,
4
+ * and ingestor are unchanged) while delegating storage + indexing to core's
5
+ * generic FileConversationDirectory, parameterized over Lark's routing payload
6
+ * (chat/thread ids). The plugin runtime supplies one explicit file per instance.
7
+ */
8
+ import { deriveConversationShortId as coreDeriveConversationShortId, FileConversationDirectory, } from "@rynx-ai/core";
9
+ const TITLE_MAX_CHARS = 40;
10
+ /** Re-exported from @rynx-ai/core (kept here for existing importers). */
11
+ export const deriveConversationShortId = coreDeriveConversationShortId;
12
+ function truncateTitle(title) {
13
+ const normalized = title.replace(/\s+/g, " ").trim();
14
+ if (normalized.length <= TITLE_MAX_CHARS) {
15
+ return normalized;
16
+ }
17
+ return `${normalized.slice(0, TITLE_MAX_CHARS - 1)}…`;
18
+ }
19
+ function toLarkRecord(entry) {
20
+ return {
21
+ conversationKey: entry.key,
22
+ shortId: entry.shortId,
23
+ chatId: entry.meta.chatId,
24
+ threadId: entry.meta.threadId,
25
+ kind: entry.meta.kind,
26
+ title: entry.title || "(无标题)",
27
+ participants: entry.participants,
28
+ updatedAt: entry.updatedAt,
29
+ };
30
+ }
31
+ export class FileLarkConversationDirectory {
32
+ core;
33
+ constructor({ filePath, now, }) {
34
+ this.core = new FileConversationDirectory({ filePath, now });
35
+ }
36
+ get filePath() {
37
+ return this.core.filePath;
38
+ }
39
+ async record(input) {
40
+ const entry = await this.core.record({
41
+ key: input.conversationKey,
42
+ title: truncateTitle(input.title),
43
+ participant: input.participantOpenId,
44
+ // Keep a previously-recorded threadId when this message didn't carry one.
45
+ meta: (previous) => ({
46
+ chatId: input.chatId,
47
+ threadId: input.threadId ?? previous?.threadId,
48
+ kind: input.kind,
49
+ }),
50
+ });
51
+ return toLarkRecord(entry);
52
+ }
53
+ async listForUser(openId, limit = 5) {
54
+ const entries = await this.core.listRecent({
55
+ limit,
56
+ where: (entry) => entry.participants.includes(openId),
57
+ });
58
+ return entries.map(toLarkRecord);
59
+ }
60
+ async getByShortId(shortId) {
61
+ const entry = await this.core.getByShortId(shortId);
62
+ return entry ? toLarkRecord(entry) : null;
63
+ }
64
+ async get(conversationKey) {
65
+ const entry = await this.core.get(conversationKey);
66
+ return entry ? toLarkRecord(entry) : null;
67
+ }
68
+ }
69
+ /** Non-persistent test/direct-construction helper. Production uses the file
70
+ * implementation with an explicit context.dataDir-scoped path. */
71
+ export class MemoryLarkConversationDirectory {
72
+ filePath = "memory://lark-conversations";
73
+ records = new Map();
74
+ async record(input) {
75
+ const previous = this.records.get(input.conversationKey);
76
+ const participants = new Set(previous?.participants ?? []);
77
+ if (input.participantOpenId)
78
+ participants.add(input.participantOpenId);
79
+ const record = {
80
+ conversationKey: input.conversationKey,
81
+ shortId: deriveConversationShortId(input.conversationKey),
82
+ chatId: input.chatId,
83
+ ...(input.threadId ?? previous?.threadId
84
+ ? { threadId: input.threadId ?? previous?.threadId }
85
+ : {}),
86
+ kind: input.kind,
87
+ title: truncateTitle(input.title) || "(无标题)",
88
+ participants: [...participants],
89
+ updatedAt: new Date().toISOString(),
90
+ };
91
+ this.records.set(input.conversationKey, record);
92
+ return record;
93
+ }
94
+ async listForUser(openId, limit = 5) {
95
+ return [...this.records.values()]
96
+ .filter((record) => record.participants.includes(openId))
97
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
98
+ .slice(0, limit);
99
+ }
100
+ async getByShortId(shortId) {
101
+ return [...this.records.values()].find((record) => record.shortId === shortId) ?? null;
102
+ }
103
+ async get(conversationKey) {
104
+ return this.records.get(conversationKey) ?? null;
105
+ }
106
+ }
@@ -0,0 +1,25 @@
1
+ /** Per-file change stats from `git diff --numstat` (plus untracked files). */
2
+ export interface DiffFileStat {
3
+ path: string;
4
+ /** Added lines, or `null` for binary files (numstat reports `-`). */
5
+ additions: number | null;
6
+ /** Removed lines, or `null` for binary files. */
7
+ deletions: number | null;
8
+ /** True for files surfaced by `git ls-files --others` (not yet staged). */
9
+ untracked?: boolean;
10
+ }
11
+ export interface DiffCardInput {
12
+ files: DiffFileStat[];
13
+ totalAdditions: number;
14
+ totalDeletions: number;
15
+ /** Unified diff body to embed (already capped by the caller). */
16
+ diffBody: string;
17
+ /** Whether `diffBody` was truncated. */
18
+ truncated: boolean;
19
+ }
20
+ /**
21
+ * Render `/diff` as a CardKit card: a summary line, a per-file change list, and
22
+ * the full unified diff inside a collapsed panel (a ```diff fenced block so
23
+ * Feishu colors the +/- lines). Far more readable than a raw text dump.
24
+ */
25
+ export declare function buildDiffCardJson(input: DiffCardInput): string;
@@ -0,0 +1,58 @@
1
+ import { renderDiffMarkdown } from "./lark-card-stream.js";
2
+ /** Cap the per-file list so a huge change set doesn't blow up the card. */
3
+ const FILE_LIST_LIMIT = 40;
4
+ function renderFileLine(file) {
5
+ if (file.untracked) {
6
+ return `\`${file.path}\` <font color='grey'>未跟踪</font>`;
7
+ }
8
+ if (file.additions === null || file.deletions === null) {
9
+ return `\`${file.path}\` <font color='grey'>二进制</font>`;
10
+ }
11
+ return `\`${file.path}\` <font color='green'>+${file.additions}</font> <font color='red'>-${file.deletions}</font>`;
12
+ }
13
+ /**
14
+ * Render `/diff` as a CardKit card: a summary line, a per-file change list, and
15
+ * the full unified diff inside a collapsed panel (a ```diff fenced block so
16
+ * Feishu colors the +/- lines). Far more readable than a raw text dump.
17
+ */
18
+ export function buildDiffCardJson(input) {
19
+ const { files, totalAdditions, totalDeletions, diffBody, truncated } = input;
20
+ const summary = `共 **${files.length}** 个文件 ` +
21
+ `<font color='green'>+${totalAdditions}</font> ` +
22
+ `<font color='red'>-${totalDeletions}</font>`;
23
+ const shown = files.slice(0, FILE_LIST_LIMIT);
24
+ const fileLines = shown.map(renderFileLine);
25
+ const hidden = files.length - shown.length;
26
+ if (hidden > 0) {
27
+ fileLines.push(`<font color='grey'>… 还有 ${hidden} 个文件</font>`);
28
+ }
29
+ const elements = [
30
+ { tag: "markdown", content: summary },
31
+ { tag: "hr" },
32
+ { tag: "markdown", content: fileLines.join("\n") },
33
+ ];
34
+ if (diffBody.trim()) {
35
+ elements.push({
36
+ tag: "collapsible_panel",
37
+ expanded: false,
38
+ header: {
39
+ title: {
40
+ tag: "plain_text",
41
+ content: truncated ? "完整 diff(已截断)" : "完整 diff",
42
+ },
43
+ vertical_align: "center",
44
+ icon: { tag: "standard_icon", token: "down-small-ccm_outlined" },
45
+ },
46
+ elements: [{ tag: "markdown", content: renderDiffMarkdown(diffBody) }],
47
+ });
48
+ }
49
+ const card = {
50
+ schema: "2.0",
51
+ header: {
52
+ title: { tag: "plain_text", content: "📝 工作区改动" },
53
+ template: "blue",
54
+ },
55
+ body: { elements },
56
+ };
57
+ return JSON.stringify(card);
58
+ }
@@ -0,0 +1,16 @@
1
+ export interface ForkTranscriptMessage {
2
+ msgType: string;
3
+ senderId?: string;
4
+ senderType?: string;
5
+ content: string;
6
+ }
7
+ /** Extract a human-readable line from a Feishu message body `content` (a JSON
8
+ * string whose shape depends on `msgType`). Non-text types become a short
9
+ * placeholder so the transcript stays readable. */
10
+ export declare function extractMessageText(msgType: string, content: string): string;
11
+ /**
12
+ * Render a conversation history as a transcript: one `名字:内容` line per
13
+ * message. `nameById` maps a user open_id to a display name; messages from the
14
+ * bot (sender_type !== "user") are labelled 机器人. Bounded to a sane size.
15
+ */
16
+ export declare function buildForkTranscript(messages: ForkTranscriptMessage[], nameById: Map<string, string>): string;
@@ -0,0 +1,141 @@
1
+ const TRANSCRIPT_MAX_BYTES = 80 * 1024;
2
+ /** Extract a human-readable line from a Feishu message body `content` (a JSON
3
+ * string whose shape depends on `msgType`). Non-text types become a short
4
+ * placeholder so the transcript stays readable. */
5
+ export function extractMessageText(msgType, content) {
6
+ try {
7
+ const parsed = JSON.parse(content);
8
+ if (msgType === "text") {
9
+ return typeof parsed.text === "string" ? parsed.text.trim() : "";
10
+ }
11
+ if (msgType === "post") {
12
+ return extractPostText(parsed);
13
+ }
14
+ if (msgType === "interactive") {
15
+ return extractCardText(parsed) || "[卡片]";
16
+ }
17
+ }
18
+ catch {
19
+ // fall through to placeholder
20
+ }
21
+ switch (msgType) {
22
+ case "image":
23
+ return "[图片]";
24
+ case "file":
25
+ return "[文件]";
26
+ case "audio":
27
+ return "[语音]";
28
+ case "media":
29
+ return "[视频]";
30
+ case "sticker":
31
+ return "[表情]";
32
+ case "interactive":
33
+ return "[卡片]";
34
+ default:
35
+ return msgType ? `[${msgType}]` : "";
36
+ }
37
+ }
38
+ const CARD_TEXT_KEYS = new Set([
39
+ "text",
40
+ "content",
41
+ "title",
42
+ "lines",
43
+ "elements",
44
+ "columns",
45
+ "plain_text",
46
+ "i18n",
47
+ "header",
48
+ "body",
49
+ ]);
50
+ const CARD_TEXT_MAX_CHARS = 400;
51
+ /** Best-effort visible-text extraction from an interactive card body. Returns
52
+ * "" when the card carries no inline text (e.g. a bare card_id reference), so
53
+ * the caller can fall back to a "[卡片]" placeholder. */
54
+ function extractCardText(parsed) {
55
+ const fragments = [];
56
+ collectCardText(parsed, fragments);
57
+ const seen = new Set();
58
+ const deduped = [];
59
+ for (const fragment of fragments) {
60
+ const normalized = fragment.replace(/\s+/g, " ").trim();
61
+ if (!normalized || seen.has(normalized)) {
62
+ continue;
63
+ }
64
+ seen.add(normalized);
65
+ deduped.push(normalized);
66
+ }
67
+ const joined = deduped.join(" ");
68
+ if (!joined) {
69
+ return "";
70
+ }
71
+ return joined.length > CARD_TEXT_MAX_CHARS
72
+ ? `${joined.slice(0, CARD_TEXT_MAX_CHARS)}…`
73
+ : joined;
74
+ }
75
+ function collectCardText(value, out) {
76
+ if (typeof value === "string") {
77
+ out.push(value);
78
+ return;
79
+ }
80
+ if (Array.isArray(value)) {
81
+ for (const item of value) {
82
+ collectCardText(item, out);
83
+ }
84
+ return;
85
+ }
86
+ if (!value || typeof value !== "object") {
87
+ return;
88
+ }
89
+ for (const [key, nested] of Object.entries(value)) {
90
+ if (CARD_TEXT_KEYS.has(key)) {
91
+ collectCardText(nested, out);
92
+ }
93
+ }
94
+ }
95
+ function extractPostText(parsed) {
96
+ // post content is { <locale>: { title, content: item[][] } } or already the
97
+ // locale object. Collect title + any text/link fragments.
98
+ const locale = Array.isArray(parsed.content)
99
+ ? parsed
100
+ : Object.values(parsed).find((value) => value && typeof value === "object" && Array.isArray(value.content));
101
+ if (!locale) {
102
+ return "[富文本]";
103
+ }
104
+ const parts = [];
105
+ if (typeof locale.title === "string" && locale.title.trim()) {
106
+ parts.push(locale.title.trim());
107
+ }
108
+ for (const block of locale.content ?? []) {
109
+ for (const item of block) {
110
+ if (typeof item.text === "string") {
111
+ parts.push(item.text);
112
+ }
113
+ else if (item.tag === "a" && typeof item.href === "string") {
114
+ parts.push(item.href);
115
+ }
116
+ }
117
+ }
118
+ return parts.join(" ").trim() || "[富文本]";
119
+ }
120
+ /**
121
+ * Render a conversation history as a transcript: one `名字:内容` line per
122
+ * message. `nameById` maps a user open_id to a display name; messages from the
123
+ * bot (sender_type !== "user") are labelled 机器人. Bounded to a sane size.
124
+ */
125
+ export function buildForkTranscript(messages, nameById) {
126
+ const lines = [];
127
+ for (const message of messages) {
128
+ const text = extractMessageText(message.msgType, message.content);
129
+ if (!text) {
130
+ continue;
131
+ }
132
+ const isBot = message.senderType !== "user" || !message.senderId;
133
+ const name = isBot ? "机器人" : nameById.get(message.senderId) || "用户";
134
+ lines.push(`${name}:${text}`);
135
+ }
136
+ let transcript = lines.join("\n");
137
+ if (Buffer.byteLength(transcript, "utf8") > TRANSCRIPT_MAX_BYTES) {
138
+ transcript = `${transcript.slice(0, TRANSCRIPT_MAX_BYTES)}\n…(记录过长已截断)`;
139
+ }
140
+ return transcript;
141
+ }
@@ -0,0 +1,31 @@
1
+ import type { LarkConversationRecord } from "./lark-conversation-directory.js";
2
+ /** applink that opens a chat (p2p or group) in the Feishu client. Topic
3
+ * threads have no deep-link, so those are forwarded instead (see the
4
+ * `resume_forward` card action). */
5
+ export declare function buildChatApplink(chatId: string): string;
6
+ /** A single-button card whose button opens `chatId` (used by /fork to give the
7
+ * user a one-tap entry into the new group from the current conversation). */
8
+ export declare function buildOpenChatCardJson(chatId: string, chatName: string): string;
9
+ /** Where /resume was invoked — the forward lands here. When `threadId` is
10
+ * set (invoked inside a topic thread) the forward targets that thread so it
11
+ * appears in-place; otherwise it targets the chat. */
12
+ export interface ResumeTarget {
13
+ chatId: string;
14
+ threadId?: string;
15
+ }
16
+ export interface ResumeForwardValue {
17
+ kind: "resume_forward";
18
+ shortId: string;
19
+ /** The topic thread being resumed (forwarded). */
20
+ sourceThreadId: string;
21
+ /** Current chat to forward into. */
22
+ targetChatId: string;
23
+ /** Current thread to forward into, when /resume was invoked inside one. */
24
+ targetThreadId?: string;
25
+ }
26
+ /**
27
+ * Build a CardKit 2.0 card listing recent conversations. Each row shows the
28
+ * title + short id, with a button that either opens the chat (applink) or
29
+ * forwards the topic thread into `targetChatId`.
30
+ */
31
+ export declare function buildResumeCardJson(records: LarkConversationRecord[], target: ResumeTarget): string;