@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.
- package/.gitignore +35 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/config/access.example.json +88 -0
- package/config/config.example.json +72 -0
- package/config/import-instructions.HOWTO.md +58 -0
- package/config/import-instructions.md +67 -0
- package/config/memory-instructions.md +40 -0
- package/config/personalities/casual.md +24 -0
- package/config/personalities/professional.md +25 -0
- package/config/personalities/sharp.md +45 -0
- package/dist/ai/claude.js +153 -0
- package/dist/ai/sessions.js +63 -0
- package/dist/cli/import.js +17 -0
- package/dist/cli/index.js +70 -0
- package/dist/cli/service.js +105 -0
- package/dist/cli/setup.js +701 -0
- package/dist/cli/start.js +37 -0
- package/dist/cli/supervisor.js +37 -0
- package/dist/config.js +104 -0
- package/dist/gateway/bootstrap.js +56 -0
- package/dist/gateway/commands.js +58 -0
- package/dist/gateway/incoming.js +239 -0
- package/dist/gateway/outgoing.js +168 -0
- package/dist/gateway/triggers.js +75 -0
- package/dist/index.js +30 -0
- package/dist/logger.js +7 -0
- package/dist/memory/digest-flag.js +8 -0
- package/dist/memory/digest.js +211 -0
- package/dist/memory/frontmatter.js +100 -0
- package/dist/memory/importer.js +103 -0
- package/dist/memory/paths.js +26 -0
- package/dist/memory/preamble.js +98 -0
- package/dist/memory/router.js +90 -0
- package/dist/memory/scheduler.js +85 -0
- package/dist/memory/store.js +183 -0
- package/dist/promptlog.js +52 -0
- package/dist/queue/persistence.js +68 -0
- package/dist/queue/queue.js +49 -0
- package/dist/queue/types.js +1 -0
- package/dist/queue/worker.js +51 -0
- package/dist/store/media.js +108 -0
- package/dist/store/messages.js +33 -0
- package/dist/wa/auth.js +9 -0
- package/dist/wa/sender.js +79 -0
- package/dist/wa/socket.js +84 -0
- package/dist/wa/whitelist.js +213 -0
- package/package.json +63 -0
- 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
|
+
}
|