moltbot-channel-feishu 0.0.8

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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Emoji reactions operations.
3
+ */
4
+
5
+ import type { Config } from "../config/schema.js";
6
+ import type { AddReactionParams, RemoveReactionParams, Reaction } from "../types/index.js";
7
+ import { getApiClient } from "./client.js";
8
+
9
+ // ============================================================================
10
+ // Constants
11
+ // ============================================================================
12
+
13
+ /**
14
+ * Common Feishu emoji types for convenience.
15
+ * @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
16
+ */
17
+ export const Emoji = {
18
+ // Common reactions
19
+ THUMBSUP: "THUMBSUP",
20
+ THUMBSDOWN: "THUMBSDOWN",
21
+ HEART: "HEART",
22
+ SMILE: "SMILE",
23
+ GRINNING: "GRINNING",
24
+ LAUGHING: "LAUGHING",
25
+ CRY: "CRY",
26
+ ANGRY: "ANGRY",
27
+ SURPRISED: "SURPRISED",
28
+ THINKING: "THINKING",
29
+ CLAP: "CLAP",
30
+ OK: "OK",
31
+ FIST: "FIST",
32
+ PRAY: "PRAY",
33
+ FIRE: "FIRE",
34
+ PARTY: "PARTY",
35
+ CHECK: "CHECK",
36
+ CROSS: "CROSS",
37
+ QUESTION: "QUESTION",
38
+ EXCLAMATION: "EXCLAMATION",
39
+ // Typing indicator (commonly used)
40
+ WRITING: "WRITING",
41
+ EYES: "EYES",
42
+ TYPING: "Typing",
43
+ } as const;
44
+
45
+ export type EmojiType = (typeof Emoji)[keyof typeof Emoji];
46
+
47
+ // ============================================================================
48
+ // API Operations
49
+ // ============================================================================
50
+
51
+ interface AddReactionResponse {
52
+ code?: number;
53
+ msg?: string;
54
+ data?: { reaction_id?: string };
55
+ }
56
+
57
+ /**
58
+ * Add a reaction (emoji) to a message.
59
+ *
60
+ * @throws Error if add fails
61
+ */
62
+ export async function addReaction(config: Config, params: AddReactionParams): Promise<string> {
63
+ const client = getApiClient(config);
64
+
65
+ const response = (await client.im.messageReaction.create({
66
+ path: { message_id: params.messageId },
67
+ data: {
68
+ reaction_type: { emoji_type: params.emojiType },
69
+ },
70
+ })) as AddReactionResponse;
71
+
72
+ if (response.code !== 0) {
73
+ throw new Error(`Add reaction failed: ${response.msg ?? `code ${response.code}`}`);
74
+ }
75
+
76
+ const reactionId = response.data?.reaction_id;
77
+ if (!reactionId) {
78
+ throw new Error("Add reaction failed: no reaction_id returned");
79
+ }
80
+
81
+ return reactionId;
82
+ }
83
+
84
+ interface RemoveReactionResponse {
85
+ code?: number;
86
+ msg?: string;
87
+ }
88
+
89
+ /**
90
+ * Remove a reaction from a message.
91
+ *
92
+ * @throws Error if remove fails
93
+ */
94
+ export async function removeReaction(config: Config, params: RemoveReactionParams): Promise<void> {
95
+ const client = getApiClient(config);
96
+
97
+ const response = (await client.im.messageReaction.delete({
98
+ path: {
99
+ message_id: params.messageId,
100
+ reaction_id: params.reactionId,
101
+ },
102
+ })) as RemoveReactionResponse;
103
+
104
+ if (response.code !== 0) {
105
+ throw new Error(`Remove reaction failed: ${response.msg ?? `code ${response.code}`}`);
106
+ }
107
+ }
108
+
109
+ interface ListReactionsResponse {
110
+ code?: number;
111
+ msg?: string;
112
+ data?: {
113
+ items?: {
114
+ reaction_id?: string;
115
+ reaction_type?: { emoji_type?: string };
116
+ operator_type?: string;
117
+ operator_id?: {
118
+ open_id?: string;
119
+ user_id?: string;
120
+ union_id?: string;
121
+ };
122
+ }[];
123
+ };
124
+ }
125
+
126
+ /**
127
+ * List all reactions for a message.
128
+ *
129
+ * @throws Error if list fails
130
+ */
131
+ export async function listReactions(
132
+ config: Config,
133
+ messageId: string,
134
+ emojiType?: string
135
+ ): Promise<Reaction[]> {
136
+ const client = getApiClient(config);
137
+
138
+ const response = (await client.im.messageReaction.list({
139
+ path: { message_id: messageId },
140
+ params: emojiType ? { reaction_type: emojiType } : undefined,
141
+ })) as ListReactionsResponse;
142
+
143
+ if (response.code !== 0) {
144
+ throw new Error(`List reactions failed: ${response.msg ?? `code ${response.code}`}`);
145
+ }
146
+
147
+ const items = response.data?.items ?? [];
148
+ return items.map((item) => ({
149
+ reactionId: item.reaction_id ?? "",
150
+ emojiType: item.reaction_type?.emoji_type ?? "",
151
+ operatorType: item.operator_type === "app" ? ("app" as const) : ("user" as const),
152
+ operatorId:
153
+ item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
154
+ }));
155
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Configuration schema definitions using Zod.
3
+ * All configuration types are derived from schemas via inference.
4
+ */
5
+
6
+ import { z } from "zod";
7
+
8
+ // ============================================================================
9
+ // Enums
10
+ // ============================================================================
11
+
12
+ export const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
13
+ export const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
14
+ export const DomainSchema = z.enum(["feishu", "lark"]);
15
+ export const ConnectionModeSchema = z.literal("websocket");
16
+ export const MarkdownModeSchema = z.enum(["native", "escape", "strip"]);
17
+ export const TableModeSchema = z.enum(["native", "ascii", "simple"]);
18
+ export const ChunkModeSchema = z.enum(["length", "newline"]);
19
+ export const HeartbeatVisibilitySchema = z.enum(["visible", "hidden"]);
20
+ export const ReplyToModeSchema = z.enum(["off", "first", "all"]);
21
+
22
+ // ============================================================================
23
+ // Sub-schemas
24
+ // ============================================================================
25
+
26
+ /** Tool policy for groups */
27
+ export const ToolPolicySchema = z
28
+ .object({
29
+ allow: z.array(z.string()).optional(),
30
+ deny: z.array(z.string()).optional(),
31
+ })
32
+ .strict()
33
+ .optional();
34
+
35
+ /** DM-specific configuration */
36
+ export const DmConfigSchema = z
37
+ .object({
38
+ enabled: z.boolean().optional(),
39
+ systemPrompt: z.string().optional(),
40
+ })
41
+ .strict()
42
+ .optional();
43
+
44
+ /** Markdown rendering options */
45
+ export const MarkdownConfigSchema = z
46
+ .object({
47
+ mode: MarkdownModeSchema.optional(),
48
+ tableMode: TableModeSchema.optional(),
49
+ })
50
+ .strict()
51
+ .optional();
52
+
53
+ /** Streaming coalesce settings */
54
+ export const StreamingCoalesceSchema = z
55
+ .object({
56
+ enabled: z.boolean().optional(),
57
+ minDelayMs: z.number().int().positive().optional(),
58
+ maxDelayMs: z.number().int().positive().optional(),
59
+ })
60
+ .strict()
61
+ .optional();
62
+
63
+ /** Heartbeat visibility settings */
64
+ export const HeartbeatConfigSchema = z
65
+ .object({
66
+ visibility: HeartbeatVisibilitySchema.optional(),
67
+ intervalMs: z.number().int().positive().optional(),
68
+ })
69
+ .strict()
70
+ .optional();
71
+
72
+ /** Group-specific configuration */
73
+ export const GroupConfigSchema = z
74
+ .object({
75
+ requireMention: z.boolean().optional(),
76
+ tools: ToolPolicySchema,
77
+ skills: z.array(z.string()).optional(),
78
+ enabled: z.boolean().optional(),
79
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
80
+ systemPrompt: z.string().optional(),
81
+ })
82
+ .strict();
83
+
84
+ // ============================================================================
85
+ // Main Configuration Schema
86
+ // ============================================================================
87
+
88
+ export const ConfigSchema = z
89
+ .object({
90
+ // Core settings
91
+ enabled: z.boolean().optional(),
92
+ appId: z.string().optional(),
93
+ appSecret: z.string().optional(),
94
+ domain: DomainSchema.optional().default("feishu"),
95
+ // Connection (websocket only, webhook removed)
96
+ connectionMode: ConnectionModeSchema.optional().default("websocket"),
97
+
98
+ // DM settings
99
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
100
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
101
+ dmHistoryLimit: z.number().int().min(0).optional(),
102
+ dms: z.record(z.string(), DmConfigSchema).optional(),
103
+
104
+ // Group settings
105
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
106
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
107
+ requireMention: z.boolean().optional().default(true),
108
+ groups: z.record(z.string(), GroupConfigSchema.optional()).optional(),
109
+ historyLimit: z.number().int().min(0).optional(),
110
+
111
+ // Message formatting
112
+ markdown: MarkdownConfigSchema,
113
+ textChunkLimit: z.number().int().positive().optional(),
114
+ chunkMode: ChunkModeSchema.optional(),
115
+ blockStreamingCoalesce: StreamingCoalesceSchema,
116
+
117
+ // Media
118
+ mediaMaxMb: z.number().positive().optional(),
119
+
120
+ // UI
121
+ heartbeat: HeartbeatConfigSchema,
122
+ capabilities: z.array(z.string()).optional(),
123
+
124
+ // Threading
125
+ replyToMode: ReplyToModeSchema.optional().default("first"),
126
+ configWrites: z.boolean().optional(),
127
+ })
128
+ .strict()
129
+ .superRefine((value, ctx) => {
130
+ // Validate that "open" DM policy requires wildcard in allowFrom
131
+ if (value.dmPolicy === "open") {
132
+ const allowFrom = value.allowFrom ?? [];
133
+ const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
134
+ if (!hasWildcard) {
135
+ ctx.addIssue({
136
+ code: z.ZodIssueCode.custom,
137
+ path: ["allowFrom"],
138
+ message: 'dmPolicy="open" requires allowFrom to include "*" wildcard',
139
+ });
140
+ }
141
+ }
142
+ });
143
+
144
+ // ============================================================================
145
+ // Type Exports (inferred from schemas)
146
+ // ============================================================================
147
+
148
+ export type Config = z.infer<typeof ConfigSchema>;
149
+ export type GroupConfig = z.infer<typeof GroupConfigSchema>;
150
+ export type ToolPolicy = z.infer<typeof ToolPolicySchema>;
151
+ export type DmConfig = z.infer<typeof DmConfigSchema>;
152
+ export type MarkdownConfig = z.infer<typeof MarkdownConfigSchema>;
153
+ export type StreamingCoalesce = z.infer<typeof StreamingCoalesceSchema>;
154
+ export type HeartbeatConfig = z.infer<typeof HeartbeatConfigSchema>;
155
+
156
+ // ============================================================================
157
+ // Credential Resolution
158
+ // ============================================================================
159
+
160
+ export interface Credentials {
161
+ appId: string;
162
+ appSecret: string;
163
+ domain: "feishu" | "lark";
164
+ }
165
+
166
+ /**
167
+ * Resolve credentials from config, with environment variable fallback.
168
+ * Returns null if required credentials are missing.
169
+ */
170
+ export function resolveCredentials(config: Config | undefined): Credentials | null {
171
+ const appId = config?.appId?.trim() || process.env["FEISHU_APP_ID"]?.trim();
172
+ const appSecret = config?.appSecret?.trim() || process.env["FEISHU_APP_SECRET"]?.trim();
173
+
174
+ if (!appId || !appSecret) {
175
+ return null;
176
+ }
177
+
178
+ return {
179
+ appId,
180
+ appSecret,
181
+ domain: config?.domain ?? "feishu",
182
+ };
183
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Message dispatch and reply handling.
3
+ */
4
+
5
+ import type { Config } from "../config/schema.js";
6
+ import type { ParsedMessage, SendResult } from "../types/index.js";
7
+ import { sendTextMessage, getMessage } from "../api/messages.js";
8
+ import { addReaction, removeReaction, Emoji } from "../api/reactions.js";
9
+ import { checkDmPolicy, checkGroupPolicy, shouldRequireMention } from "./policy.js";
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ export interface DispatchContext {
16
+ config: Config;
17
+ message: ParsedMessage;
18
+ botOpenId: string | undefined;
19
+ onLog?: (message: string) => void;
20
+ onError?: (message: string) => void;
21
+ }
22
+
23
+ export interface DispatchResult {
24
+ processed: boolean;
25
+ reason?: string;
26
+ }
27
+
28
+ export interface TypingIndicator {
29
+ messageId: string;
30
+ reactionId: string;
31
+ }
32
+
33
+ // ============================================================================
34
+ // Typing Indicator
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Add a typing indicator (reaction) to a message.
39
+ */
40
+ export async function addTypingIndicator(
41
+ config: Config,
42
+ messageId: string
43
+ ): Promise<TypingIndicator | null> {
44
+ try {
45
+ const reactionId = await addReaction(config, {
46
+ messageId,
47
+ emojiType: Emoji.TYPING,
48
+ });
49
+ return { messageId, reactionId };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Remove a typing indicator.
57
+ */
58
+ export async function removeTypingIndicator(
59
+ config: Config,
60
+ indicator: TypingIndicator
61
+ ): Promise<void> {
62
+ try {
63
+ await removeReaction(config, {
64
+ messageId: indicator.messageId,
65
+ reactionId: indicator.reactionId,
66
+ });
67
+ } catch {
68
+ // Ignore removal failures
69
+ }
70
+ }
71
+
72
+ // ============================================================================
73
+ // Message Validation
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Validate an incoming message against policies.
78
+ * Returns whether the message should be processed.
79
+ */
80
+ export function validateMessage(ctx: DispatchContext): DispatchResult {
81
+ const { config, message } = ctx;
82
+ const log = ctx.onLog ?? console.log;
83
+
84
+ const isGroup = message.chatType === "group";
85
+
86
+ if (isGroup) {
87
+ // Check group policy
88
+ const policyResult = checkGroupPolicy(
89
+ config,
90
+ message.chatId,
91
+ message.senderOpenId,
92
+ message.senderName
93
+ );
94
+
95
+ if (!policyResult.allowed) {
96
+ log(`Dispatch: group policy denied - ${policyResult.reason}`);
97
+ return { processed: false, reason: policyResult.reason };
98
+ }
99
+
100
+ // Check mention requirement
101
+ const requiresMention = shouldRequireMention(config, message.chatType, message.chatId);
102
+
103
+ if (requiresMention && !message.mentionedBot) {
104
+ log(`Dispatch: mention required but bot not mentioned`);
105
+ return { processed: false, reason: "Mention required" };
106
+ }
107
+ } else {
108
+ // Check DM policy
109
+ const policyResult = checkDmPolicy(config, message.senderOpenId, message.senderName);
110
+
111
+ if (!policyResult.allowed) {
112
+ log(`Dispatch: DM policy denied - ${policyResult.reason}`);
113
+ return { processed: false, reason: policyResult.reason };
114
+ }
115
+ }
116
+
117
+ return { processed: true };
118
+ }
119
+
120
+ // ============================================================================
121
+ // Quoted Message Context
122
+ // ============================================================================
123
+
124
+ /**
125
+ * Fetch quoted message content if replying to a message.
126
+ */
127
+ export async function fetchQuotedContent(
128
+ config: Config,
129
+ parentId: string | undefined,
130
+ onLog?: (message: string) => void
131
+ ): Promise<string | undefined> {
132
+ if (!parentId) {
133
+ return undefined;
134
+ }
135
+
136
+ try {
137
+ const quotedMsg = await getMessage(config, parentId);
138
+ if (quotedMsg) {
139
+ onLog?.(`Dispatch: fetched quoted message: ${quotedMsg.content.slice(0, 100)}`);
140
+ return quotedMsg.content;
141
+ }
142
+ } catch (err) {
143
+ onLog?.(`Dispatch: failed to fetch quoted: ${String(err)}`);
144
+ }
145
+
146
+ return undefined;
147
+ }
148
+
149
+ // ============================================================================
150
+ // Reply Sending
151
+ // ============================================================================
152
+
153
+ /**
154
+ * Send a reply to a message.
155
+ */
156
+ export async function sendReply(
157
+ config: Config,
158
+ chatId: string,
159
+ text: string,
160
+ replyToMessageId?: string
161
+ ): Promise<SendResult> {
162
+ return sendTextMessage(config, {
163
+ to: chatId,
164
+ text,
165
+ replyToMessageId,
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Send chunked replies for long messages.
171
+ */
172
+ export async function sendChunkedReply(
173
+ config: Config,
174
+ chatId: string,
175
+ text: string,
176
+ replyToMessageId?: string,
177
+ chunkLimit = 4000
178
+ ): Promise<SendResult[]> {
179
+ const chunks = chunkText(text, chunkLimit);
180
+ const results: SendResult[] = [];
181
+
182
+ for (const chunk of chunks) {
183
+ const result = await sendTextMessage(config, {
184
+ to: chatId,
185
+ text: chunk,
186
+ replyToMessageId,
187
+ });
188
+ results.push(result);
189
+ }
190
+
191
+ return results;
192
+ }
193
+
194
+ /**
195
+ * Split text into chunks at reasonable boundaries.
196
+ */
197
+ function chunkText(text: string, limit: number): string[] {
198
+ if (text.length <= limit) {
199
+ return [text];
200
+ }
201
+
202
+ const chunks: string[] = [];
203
+ let remaining = text;
204
+
205
+ while (remaining.length > limit) {
206
+ // Find a good break point
207
+ let breakPoint = remaining.lastIndexOf("\n\n", limit);
208
+ if (breakPoint === -1 || breakPoint < limit / 2) {
209
+ breakPoint = remaining.lastIndexOf("\n", limit);
210
+ }
211
+ if (breakPoint === -1 || breakPoint < limit / 2) {
212
+ breakPoint = remaining.lastIndexOf(" ", limit);
213
+ }
214
+ if (breakPoint === -1 || breakPoint < limit / 2) {
215
+ breakPoint = limit;
216
+ }
217
+
218
+ chunks.push(remaining.slice(0, breakPoint).trim());
219
+ remaining = remaining.slice(breakPoint).trim();
220
+ }
221
+
222
+ if (remaining) {
223
+ chunks.push(remaining);
224
+ }
225
+
226
+ return chunks;
227
+ }