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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/clawdbot.plugin.json +33 -0
- package/moltbot.plugin.json +33 -0
- package/package.json +86 -0
- package/src/api/client.ts +140 -0
- package/src/api/directory.ts +186 -0
- package/src/api/media.ts +335 -0
- package/src/api/messages.ts +290 -0
- package/src/api/reactions.ts +155 -0
- package/src/config/schema.ts +183 -0
- package/src/core/dispatcher.ts +227 -0
- package/src/core/gateway.ts +202 -0
- package/src/core/handler.ts +231 -0
- package/src/core/parser.ts +112 -0
- package/src/core/policy.ts +199 -0
- package/src/core/reply-dispatcher.ts +151 -0
- package/src/core/runtime.ts +27 -0
- package/src/index.ts +108 -0
- package/src/plugin/channel.ts +367 -0
- package/src/plugin/index.ts +28 -0
- package/src/plugin/onboarding.ts +378 -0
- package/src/types/clawdbot.d.ts +377 -0
- package/src/types/events.ts +72 -0
- package/src/types/index.ts +6 -0
- package/src/types/messages.ts +172 -0
|
@@ -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
|
+
}
|