@c4t4/heyamigo 0.1.0

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 (49) hide show
  1. package/.gitignore +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/config/access.example.json +88 -0
  5. package/config/config.example.json +72 -0
  6. package/config/import-instructions.HOWTO.md +58 -0
  7. package/config/import-instructions.md +67 -0
  8. package/config/memory-instructions.md +40 -0
  9. package/config/personalities/casual.md +24 -0
  10. package/config/personalities/professional.md +25 -0
  11. package/config/personalities/sharp.md +45 -0
  12. package/dist/ai/claude.js +153 -0
  13. package/dist/ai/sessions.js +63 -0
  14. package/dist/cli/import.js +17 -0
  15. package/dist/cli/index.js +70 -0
  16. package/dist/cli/service.js +105 -0
  17. package/dist/cli/setup.js +701 -0
  18. package/dist/cli/start.js +37 -0
  19. package/dist/cli/supervisor.js +37 -0
  20. package/dist/config.js +104 -0
  21. package/dist/gateway/bootstrap.js +56 -0
  22. package/dist/gateway/commands.js +58 -0
  23. package/dist/gateway/incoming.js +239 -0
  24. package/dist/gateway/outgoing.js +168 -0
  25. package/dist/gateway/triggers.js +75 -0
  26. package/dist/index.js +30 -0
  27. package/dist/logger.js +7 -0
  28. package/dist/memory/digest-flag.js +8 -0
  29. package/dist/memory/digest.js +211 -0
  30. package/dist/memory/frontmatter.js +100 -0
  31. package/dist/memory/importer.js +103 -0
  32. package/dist/memory/paths.js +26 -0
  33. package/dist/memory/preamble.js +98 -0
  34. package/dist/memory/router.js +90 -0
  35. package/dist/memory/scheduler.js +85 -0
  36. package/dist/memory/store.js +183 -0
  37. package/dist/promptlog.js +52 -0
  38. package/dist/queue/persistence.js +68 -0
  39. package/dist/queue/queue.js +49 -0
  40. package/dist/queue/types.js +1 -0
  41. package/dist/queue/worker.js +51 -0
  42. package/dist/store/media.js +108 -0
  43. package/dist/store/messages.js +33 -0
  44. package/dist/wa/auth.js +9 -0
  45. package/dist/wa/sender.js +79 -0
  46. package/dist/wa/socket.js +84 -0
  47. package/dist/wa/whitelist.js +213 -0
  48. package/package.json +63 -0
  49. package/scripts/start-browser.sh +158 -0
@@ -0,0 +1,37 @@
1
+ import { execSync } from 'child_process';
2
+ import { attachIncoming } from '../gateway/incoming.js';
3
+ import { handleReply } from '../gateway/outgoing.js';
4
+ import { logger } from '../logger.js';
5
+ import { startScheduler } from '../memory/scheduler.js';
6
+ import { replayPending } from '../queue/queue.js';
7
+ import { startSocket } from '../wa/socket.js';
8
+ export async function main() {
9
+ try {
10
+ execSync('which claude', { stdio: 'pipe' });
11
+ }
12
+ catch {
13
+ console.error('Claude CLI not found. Install it first:\n\n' +
14
+ ' npm install -g @anthropic-ai/claude-code\n');
15
+ process.exit(1);
16
+ }
17
+ logger.info('heyamigo starting');
18
+ startScheduler();
19
+ await startSocket((sock) => {
20
+ attachIncoming(sock);
21
+ });
22
+ void replayPending(async (job, result) => {
23
+ await handleReply(job, result, {});
24
+ }).catch((err) => logger.error({ err }, 'replay failed'));
25
+ }
26
+ process.on('SIGINT', () => {
27
+ logger.info('SIGINT received, shutting down');
28
+ process.exit(0);
29
+ });
30
+ process.on('SIGTERM', () => {
31
+ logger.info('SIGTERM received, shutting down');
32
+ process.exit(0);
33
+ });
34
+ main().catch((err) => {
35
+ logger.error({ err }, 'fatal error during boot');
36
+ process.exit(1);
37
+ });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Supervisor: runs the bot, restarts on crash.
4
+ * Spawned by `heyamigo start` as a detached process.
5
+ */
6
+ import { spawn } from 'child_process';
7
+ import { resolve } from 'path';
8
+ const RESTART_DELAY_MS = 5000;
9
+ const cwd = process.cwd();
10
+ let child = null;
11
+ let shuttingDown = false;
12
+ function run() {
13
+ child = spawn(process.execPath, [
14
+ '--import',
15
+ 'file://' + resolve(cwd, 'node_modules/tsx/dist/loader.mjs'),
16
+ resolve(cwd, 'src/cli/start.ts'),
17
+ ], { stdio: 'inherit', cwd, env: { ...process.env, NODE_ENV: 'production' } });
18
+ child.on('exit', (code, signal) => {
19
+ if (shuttingDown) {
20
+ process.exit(0);
21
+ }
22
+ const ts = new Date().toISOString();
23
+ console.error(`[${ts}] Bot exited (code=${code}, signal=${signal}), restarting in ${RESTART_DELAY_MS / 1000}s...`);
24
+ setTimeout(run, RESTART_DELAY_MS);
25
+ });
26
+ }
27
+ process.on('SIGTERM', () => {
28
+ shuttingDown = true;
29
+ child?.kill('SIGTERM');
30
+ setTimeout(() => process.exit(0), 3000);
31
+ });
32
+ process.on('SIGINT', () => {
33
+ shuttingDown = true;
34
+ child?.kill('SIGINT');
35
+ setTimeout(() => process.exit(0), 3000);
36
+ });
37
+ run();
package/dist/config.js ADDED
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { z } from 'zod';
4
+ const TriggerModeSchema = z.enum(['all', 'mention', 'command', 'off']);
5
+ const ConfigSchema = z.object({
6
+ whatsapp: z.object({
7
+ authDir: z.string(),
8
+ browserName: z.string(),
9
+ }),
10
+ owner: z.object({
11
+ number: z.string(),
12
+ treatAsAllowedEverywhere: z.boolean(),
13
+ }),
14
+ triggers: z.object({
15
+ aliases: z.array(z.string()),
16
+ groupMode: TriggerModeSchema,
17
+ dmMode: TriggerModeSchema,
18
+ replyToBotCounts: z.boolean(),
19
+ }),
20
+ commands: z.object({
21
+ prefix: z.string(),
22
+ reset: z.array(z.string()),
23
+ status: z.array(z.string()),
24
+ reload: z.array(z.string()),
25
+ }),
26
+ claude: z.object({
27
+ model: z.string(),
28
+ timeoutMs: z.number(),
29
+ personalityFile: z.string(),
30
+ addDirs: z.array(z.string()),
31
+ outputFormat: z.enum(['json', 'text', 'stream-json']),
32
+ contextWindow: z.number(),
33
+ }),
34
+ bootstrap: z.object({
35
+ historyDepth: z.number(),
36
+ includeHistory: z.boolean(),
37
+ includeChatMetadata: z.boolean(),
38
+ }),
39
+ reply: z.object({
40
+ quoteInGroups: z.boolean(),
41
+ chunkChars: z.number(),
42
+ chunkDelayMs: z.number(),
43
+ typingIndicator: z.boolean(),
44
+ errorMessage: z.string(),
45
+ maxMessageAgeMs: z.number(),
46
+ }),
47
+ storage: z.object({
48
+ messagesDir: z.string(),
49
+ sessionsFile: z.string(),
50
+ mediaDir: z.string(),
51
+ mediaRetentionDays: z.number(),
52
+ }),
53
+ memory: z.object({
54
+ dir: z.string(),
55
+ instructionsFile: z.string(),
56
+ importInstructionsFile: z.string(),
57
+ importPermissionMode: z.enum(['acceptEdits', 'bypass']),
58
+ digestDebounceMs: z.number(),
59
+ sweepIntervalMs: z.number(),
60
+ sweepMinNewMessages: z.number(),
61
+ maxHistoryForDigest: z.number(),
62
+ }),
63
+ logging: z.object({
64
+ level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']),
65
+ promptRetentionDays: z.number(),
66
+ }),
67
+ });
68
+ function loadJsonIfExists(path) {
69
+ try {
70
+ return JSON.parse(readFileSync(path, 'utf-8'));
71
+ }
72
+ catch (err) {
73
+ if (err.code === 'ENOENT')
74
+ return undefined;
75
+ throw err;
76
+ }
77
+ }
78
+ function deepMerge(base, override) {
79
+ if (override === null || override === undefined)
80
+ return base;
81
+ if (typeof override !== 'object' || Array.isArray(override))
82
+ return override;
83
+ if (base === null || base === undefined)
84
+ return override;
85
+ if (typeof base !== 'object' || Array.isArray(base))
86
+ return override;
87
+ const out = { ...base };
88
+ for (const [k, v] of Object.entries(override)) {
89
+ out[k] = deepMerge(base[k], v);
90
+ }
91
+ return out;
92
+ }
93
+ function loadConfig() {
94
+ const cwd = process.cwd();
95
+ const baseFile = resolve(cwd, 'config/config.json');
96
+ const localFile = resolve(cwd, 'config/config.local.json');
97
+ const base = loadJsonIfExists(baseFile);
98
+ if (base === undefined)
99
+ throw new Error(`Missing config file: ${baseFile}`);
100
+ const local = loadJsonIfExists(localFile);
101
+ const merged = local ? deepMerge(base, local) : base;
102
+ return ConfigSchema.parse(merged);
103
+ }
104
+ export const config = loadConfig();
@@ -0,0 +1,56 @@
1
+ import { isJidGroup } from 'baileys';
2
+ import { config } from '../config.js';
3
+ import { logger } from '../logger.js';
4
+ import { readLast } from '../store/messages.js';
5
+ export async function buildInitPayload(params) {
6
+ const { jid, sock, userText, userNumber } = params;
7
+ const isGroup = isJidGroup(jid) === true;
8
+ const lines = [];
9
+ if (config.bootstrap.includeChatMetadata) {
10
+ lines.push('You are the assistant behind a WhatsApp chat.');
11
+ if (isGroup) {
12
+ let subject = 'unknown';
13
+ let participantSummary = '';
14
+ try {
15
+ const meta = await sock.groupMetadata(jid);
16
+ subject = meta.subject || subject;
17
+ if (meta.participants?.length) {
18
+ participantSummary = `${meta.participants.length} participants`;
19
+ }
20
+ }
21
+ catch (err) {
22
+ logger.warn({ err, jid }, 'group metadata fetch failed in bootstrap');
23
+ }
24
+ lines.push(`Chat type: group`);
25
+ lines.push(`Chat name: "${subject}"`);
26
+ if (participantSummary)
27
+ lines.push(`Members: ${participantSummary}`);
28
+ }
29
+ else {
30
+ lines.push(`Chat type: direct message`);
31
+ }
32
+ lines.push(`JID: ${jid}`);
33
+ lines.push('');
34
+ }
35
+ if (config.bootstrap.includeHistory) {
36
+ const history = await readLast(jid, config.bootstrap.historyDepth);
37
+ const prior = history.slice(0, -1); // exclude current message (appended already)
38
+ if (prior.length) {
39
+ lines.push('[Prior conversation history]');
40
+ for (const m of prior)
41
+ lines.push(formatLine(m));
42
+ lines.push('');
43
+ }
44
+ }
45
+ lines.push('[Current message]');
46
+ lines.push(`${userNumber}: ${userText}`);
47
+ return lines.join('\n');
48
+ }
49
+ function formatLine(m) {
50
+ const date = new Date(m.timestamp * 1000)
51
+ .toISOString()
52
+ .slice(0, 16)
53
+ .replace('T', ' ');
54
+ const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
55
+ return `${who} (${date}): ${m.text}`;
56
+ }
@@ -0,0 +1,58 @@
1
+ import { clearSession, getSessionInfo } from '../ai/sessions.js';
2
+ import { reloadSystemPrompt } from '../ai/claude.js';
3
+ import { config } from '../config.js';
4
+ import { runDigestNow } from '../memory/scheduler.js';
5
+ import { sendText } from '../wa/sender.js';
6
+ export async function tryCommand(ctx) {
7
+ const prefix = config.commands.prefix;
8
+ const trimmed = ctx.text.trim();
9
+ if (!trimmed.startsWith(prefix))
10
+ return false;
11
+ const cmd = trimmed.slice(prefix.length).split(/\s+/)[0]?.toLowerCase() ?? '';
12
+ if (!cmd)
13
+ return false;
14
+ if (config.commands.reset.includes(cmd)) {
15
+ const existed = clearSession(ctx.jid);
16
+ const reply = existed
17
+ ? 'Session reset. Next message will bootstrap a fresh Claude session.'
18
+ : 'No session to reset.';
19
+ await sendText(ctx.sock, ctx.jid, reply, ctx.quoted);
20
+ return true;
21
+ }
22
+ if (config.commands.status.includes(cmd)) {
23
+ const info = getSessionInfo(ctx.jid);
24
+ if (!info) {
25
+ await sendText(ctx.sock, ctx.jid, 'No session yet. Next message will bootstrap one.', ctx.quoted);
26
+ return true;
27
+ }
28
+ const lines = [`Session: ${info.sessionId.slice(0, 8)}…`];
29
+ if (info.usage) {
30
+ const max = config.claude.contextWindow;
31
+ const used = info.usage.totalContextTokens;
32
+ const leftPct = Math.max(0, 100 - (used / max) * 100).toFixed(1);
33
+ lines.push(`Context: ${used.toLocaleString()} / ${max.toLocaleString()} (${leftPct}% left)`);
34
+ lines.push(`Turns: ${info.usage.numTurns}`);
35
+ }
36
+ await sendText(ctx.sock, ctx.jid, lines.join('\n'), ctx.quoted);
37
+ return true;
38
+ }
39
+ if (config.commands.reload.includes(cmd)) {
40
+ reloadSystemPrompt();
41
+ const existed = clearSession(ctx.jid);
42
+ const reply = existed
43
+ ? 'Personality reloaded and session reset.'
44
+ : 'Personality reloaded.';
45
+ await sendText(ctx.sock, ctx.jid, reply, ctx.quoted);
46
+ return true;
47
+ }
48
+ if (cmd === 'digest') {
49
+ await sendText(ctx.sock, ctx.jid, 'Digesting memory now, this may take a moment.', ctx.quoted);
50
+ runDigestNow({
51
+ jid: ctx.jid,
52
+ number: ctx.senderNumber || undefined,
53
+ reason: 'manual /digest',
54
+ }).catch(() => undefined);
55
+ return true;
56
+ }
57
+ return false;
58
+ }
@@ -0,0 +1,239 @@
1
+ import { getContentType, isJidGroup, jidDecode, jidNormalizedUser, } from 'baileys';
2
+ import { getSession } from '../ai/sessions.js';
3
+ import { config } from '../config.js';
4
+ import { logger } from '../logger.js';
5
+ import { buildMemoryPreamble } from '../memory/preamble.js';
6
+ import { enqueue } from '../queue/queue.js';
7
+ import { downloadAndSave, mediaPromptTag } from '../store/media.js';
8
+ import { append } from '../store/messages.js';
9
+ import { checkAccess, discoverGroupIfNew, getRoleForContext, } from '../wa/whitelist.js';
10
+ import { buildInitPayload } from './bootstrap.js';
11
+ import { tryCommand } from './commands.js';
12
+ import { handleReply } from './outgoing.js';
13
+ import { checkTrigger } from './triggers.js';
14
+ export function attachIncoming(sock) {
15
+ const ownerJid = sock.user?.id
16
+ ? jidNormalizedUser(sock.user.id)
17
+ : '';
18
+ // History sync: WhatsApp delivers older messages via this event on connect.
19
+ // Process ones within the age window through the normal pipeline.
20
+ sock.ev.on('messaging-history.set', ({ messages: historyMsgs }) => {
21
+ logger.info({ count: historyMsgs.length }, 'history sync received');
22
+ void processMessages(historyMsgs, sock, ownerJid);
23
+ });
24
+ sock.ev.on('messages.upsert', async ({ messages, type }) => {
25
+ if (type !== 'notify' && type !== 'append')
26
+ return;
27
+ void processMessages(messages, sock, ownerJid, type === 'append');
28
+ });
29
+ }
30
+ async function processMessages(messages, sock, ownerJid, isHistorySync = false) {
31
+ for (const msg of messages) {
32
+ try {
33
+ const stored = await toStored(msg, ownerJid, sock);
34
+ if (!stored)
35
+ continue;
36
+ // Age gate: skip messages older than maxMessageAgeMs
37
+ const ageMs = Date.now() - stored.timestamp * 1000;
38
+ if (ageMs > config.reply.maxMessageAgeMs) {
39
+ if (isHistorySync)
40
+ continue; // don't store ancient history
41
+ await append(stored);
42
+ logger.debug({ jid: stored.jid, ageMs: Math.floor(ageMs) }, 'message too old, stored silently');
43
+ continue;
44
+ }
45
+ const isGroup = stored.jid.endsWith('@g.us');
46
+ if (isGroup)
47
+ await discoverGroupIfNew(sock, stored.jid);
48
+ const decision = checkAccess({
49
+ jid: stored.jid,
50
+ isGroup,
51
+ senderNumber: stored.senderNumber,
52
+ fromMe: stored.fromMe,
53
+ });
54
+ const logCtx = {
55
+ jid: stored.jid,
56
+ from: stored.senderNumber || '(owner)',
57
+ fromMe: stored.fromMe,
58
+ type: stored.messageType,
59
+ text: stored.text.slice(0, 80),
60
+ decision: decision.reason,
61
+ };
62
+ if (!decision.store) {
63
+ logger.debug(logCtx, 'message dropped');
64
+ continue;
65
+ }
66
+ // Download media if present (image, video, audio, document)
67
+ const media = await downloadAndSave(msg, stored.jid);
68
+ if (media) {
69
+ stored.mediaType = media.mediaType;
70
+ stored.mediaPath = media.mediaPath;
71
+ stored.mediaMime = media.mediaMime;
72
+ }
73
+ await append(stored);
74
+ if (!decision.respond) {
75
+ logger.info(logCtx, 'message captured, silent');
76
+ continue;
77
+ }
78
+ // Need either text or media to respond
79
+ if (!stored.text.trim() && !media) {
80
+ logger.debug(logCtx, 'message captured, respond skipped (empty)');
81
+ continue;
82
+ }
83
+ // Commands short-circuit the AI pipeline (always, regardless of trigger mode)
84
+ const isCommand = await tryCommand({
85
+ sock,
86
+ jid: stored.jid,
87
+ text: stored.text,
88
+ senderNumber: stored.senderNumber,
89
+ quoted: isGroup && config.reply.quoteInGroups ? msg : undefined,
90
+ });
91
+ if (isCommand) {
92
+ logger.info(logCtx, 'command handled');
93
+ continue;
94
+ }
95
+ // Trigger gate: alias / @mention / reply-to-bot depending on mode
96
+ const trigger = checkTrigger({
97
+ isGroup,
98
+ text: stored.text,
99
+ msg,
100
+ sock,
101
+ });
102
+ if (!trigger.triggered) {
103
+ logger.info({ ...logCtx, trigger: trigger.reason }, 'message captured, no trigger');
104
+ continue;
105
+ }
106
+ const { role } = getRoleForContext(stored.senderNumber, isGroup);
107
+ const existingSession = getSession(stored.jid);
108
+ let userContent = stored.text;
109
+ if (media) {
110
+ const tag = mediaPromptTag(media, stored.text);
111
+ userContent = tag;
112
+ }
113
+ const recentText = stored.text;
114
+ const memoryPreamble = buildMemoryPreamble({
115
+ jid: stored.jid,
116
+ senderNumber: stored.senderNumber,
117
+ isGroup,
118
+ recentText,
119
+ });
120
+ const core = existingSession
121
+ ? userContent
122
+ : await buildInitPayload({
123
+ jid: stored.jid,
124
+ sock,
125
+ userText: userContent,
126
+ userNumber: stored.senderNumber,
127
+ });
128
+ const input = `${memoryPreamble}\n\n---\n\n${core}`;
129
+ logger.info({ ...logCtx, resume: !!existingSession, trigger: trigger.reason }, 'message captured, enqueuing');
130
+ const job = {
131
+ jid: stored.jid,
132
+ text: stored.text,
133
+ input,
134
+ sessionId: existingSession,
135
+ senderNumber: stored.senderNumber,
136
+ fromMe: stored.fromMe,
137
+ allowedTools: role.tools,
138
+ };
139
+ // Start typing indicator immediately; refresh every 10s (WA expires ~15s)
140
+ let typingHeartbeat = null;
141
+ if (config.reply.typingIndicator) {
142
+ void sock
143
+ .sendPresenceUpdate('composing', stored.jid)
144
+ .catch(() => undefined);
145
+ typingHeartbeat = setInterval(() => {
146
+ void sock
147
+ .sendPresenceUpdate('composing', stored.jid)
148
+ .catch(() => undefined);
149
+ }, 10000);
150
+ }
151
+ const stopTyping = () => {
152
+ if (typingHeartbeat)
153
+ clearInterval(typingHeartbeat);
154
+ typingHeartbeat = null;
155
+ };
156
+ enqueue(job)
157
+ .then((result) => {
158
+ stopTyping();
159
+ return handleReply(job, result, msg);
160
+ })
161
+ .catch((err) => {
162
+ stopTyping();
163
+ logger.error({ err, jid: job.jid }, 'pipeline failed');
164
+ void handleReply(job, { reply: config.reply.errorMessage }, msg).catch((e) => logger.error({ err: e, jid: job.jid }, 'failed to send error reply'));
165
+ });
166
+ }
167
+ catch (err) {
168
+ logger.error({ err, msgId: msg.key.id }, 'failed to process incoming message');
169
+ }
170
+ }
171
+ }
172
+ async function resolveToPn(sock, jid) {
173
+ if (!jid || !jid.endsWith('@lid'))
174
+ return jid;
175
+ try {
176
+ const pn = await sock.signalRepository.lidMapping.getPNForLID(jid);
177
+ return pn ?? jid;
178
+ }
179
+ catch {
180
+ return jid;
181
+ }
182
+ }
183
+ async function toStored(msg, ownerJid, sock) {
184
+ const rawJid = msg.key.remoteJid;
185
+ if (!rawJid)
186
+ return null;
187
+ if (!msg.message)
188
+ return null;
189
+ if (rawJid === 'status@broadcast')
190
+ return null;
191
+ const fromMe = !!msg.key.fromMe;
192
+ const isGroup = isJidGroup(rawJid) === true;
193
+ // canonicalize chat jid: groups stay as @g.us, DMs preferred as @s.whatsapp.net,
194
+ // drop device suffix (e.g. ":19") so chats from different devices merge
195
+ const jid = isGroup
196
+ ? jidNormalizedUser(rawJid)
197
+ : jidNormalizedUser(await resolveToPn(sock, rawJid));
198
+ let senderRaw;
199
+ if (fromMe) {
200
+ senderRaw = ownerJid;
201
+ }
202
+ else if (isGroup) {
203
+ senderRaw = msg.key.participant ?? '';
204
+ }
205
+ else {
206
+ senderRaw = rawJid;
207
+ }
208
+ const sender = jidNormalizedUser(await resolveToPn(sock, senderRaw));
209
+ const senderNumber = jidDecode(sender)?.user ?? '';
210
+ const messageType = getContentType(msg.message) ?? 'unknown';
211
+ const text = extractText(msg.message);
212
+ return {
213
+ id: msg.key.id ?? '',
214
+ jid,
215
+ direction: fromMe ? 'out' : 'in',
216
+ fromMe,
217
+ sender,
218
+ senderNumber,
219
+ pushName: msg.pushName ?? undefined,
220
+ timestamp: typeof msg.messageTimestamp === 'number'
221
+ ? msg.messageTimestamp
222
+ : Number(msg.messageTimestamp ?? 0),
223
+ text,
224
+ messageType,
225
+ };
226
+ }
227
+ function extractText(message) {
228
+ if (message.conversation)
229
+ return message.conversation;
230
+ if (message.extendedTextMessage?.text)
231
+ return message.extendedTextMessage.text;
232
+ if (message.imageMessage?.caption)
233
+ return message.imageMessage.caption;
234
+ if (message.videoMessage?.caption)
235
+ return message.videoMessage.caption;
236
+ if (message.documentMessage?.caption)
237
+ return message.documentMessage.caption;
238
+ return '';
239
+ }