@c4t4/heyamigo 0.8.15 → 0.9.1
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/dist/channels/adapter.js +24 -0
- package/dist/channels/baileys.js +158 -0
- package/dist/channels/index.js +16 -0
- package/dist/config.js +4 -0
- package/dist/db/address.js +72 -0
- package/dist/db/check.js +74 -0
- package/dist/db/identity-sync.js +147 -0
- package/dist/db/index.js +44 -0
- package/dist/db/migrate.js +113 -0
- package/dist/db/schema.js +97 -0
- package/dist/gateway/outgoing.js +127 -177
- package/dist/index.js +18 -0
- package/dist/queue/outbound-postsend.js +77 -0
- package/dist/queue/outbound.js +185 -0
- package/dist/queue/sender-worker.js +199 -0
- package/migrations/0000_phase0_identity_control.sql +34 -0
- package/migrations/0001_phase1_outbound.sql +23 -0
- package/migrations/meta/0000_snapshot.json +223 -0
- package/migrations/meta/0001_snapshot.json +377 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +6 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Schema source of truth. Every DDL change goes here first, then
|
|
2
|
+
// `npx drizzle-kit generate` produces a SQL migration in migrations/.
|
|
3
|
+
// Direct ALTER/CREATE/DROP outside this flow is forbidden — see the
|
|
4
|
+
// "Cardinal rule" section in refactor.md.
|
|
5
|
+
import { sql } from 'drizzle-orm';
|
|
6
|
+
import { sqliteTable, text, integer, primaryKey, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
7
|
+
// ──────────────────────────────────────────────────────────────────
|
|
8
|
+
// Identity
|
|
9
|
+
// ──────────────────────────────────────────────────────────────────
|
|
10
|
+
// Canonical humans the bot knows about. One row per real person; same
|
|
11
|
+
// person on multiple channels = multiple rows in `identities`, one row
|
|
12
|
+
// here.
|
|
13
|
+
export const persons = sqliteTable('persons', {
|
|
14
|
+
id: text('id').primaryKey(),
|
|
15
|
+
displayName: text('display_name'),
|
|
16
|
+
timezone: text('timezone'),
|
|
17
|
+
createdAt: integer('created_at').notNull(),
|
|
18
|
+
});
|
|
19
|
+
// Channel-addressable identities resolving to a person. Address is
|
|
20
|
+
// channel-prefixed: wa:dm:..., wa:group:..., tg:dm:..., etc.
|
|
21
|
+
export const identities = sqliteTable('identities', {
|
|
22
|
+
personId: text('person_id').notNull().references(() => persons.id),
|
|
23
|
+
address: text('address').notNull().unique(),
|
|
24
|
+
addedAt: integer('added_at').notNull(),
|
|
25
|
+
}, t => ({
|
|
26
|
+
pk: primaryKey({ columns: [t.personId, t.address] }),
|
|
27
|
+
}));
|
|
28
|
+
// ──────────────────────────────────────────────────────────────────
|
|
29
|
+
// Runtime control
|
|
30
|
+
// ──────────────────────────────────────────────────────────────────
|
|
31
|
+
// Worker registry. Every worker (chat, async, browser, sender, memory,
|
|
32
|
+
// orchestrator) inserts a row at startup and updates last_seen as a
|
|
33
|
+
// heartbeat. Orchestrator uses this for liveness detection and
|
|
34
|
+
// graceful shutdown drain (see refactor.md §Workers).
|
|
35
|
+
export const workers = sqliteTable('workers', {
|
|
36
|
+
id: text('id').primaryKey(), // `${hostname}-${pid}-${slot}`
|
|
37
|
+
kind: text('kind').notNull(), // 'chat'|'async'|'browser'|'sender'|'memory'|'orchestrator'
|
|
38
|
+
status: text('status').notNull(), // 'idle'|'busy'|'draining'|'dead'
|
|
39
|
+
currentJob: text('current_job'), // 'queue:id' if busy
|
|
40
|
+
lastSeen: integer('last_seen').notNull(),
|
|
41
|
+
startedAt: integer('started_at').notNull(),
|
|
42
|
+
}, t => ({
|
|
43
|
+
byKindStatus: index('workers_by_kind_status').on(t.kind, t.status),
|
|
44
|
+
byLastSeen: index('workers_by_last_seen').on(t.lastSeen),
|
|
45
|
+
}));
|
|
46
|
+
// Bot-wide control signals. Insert a row to request shutdown/pause/
|
|
47
|
+
// reload; orchestrator picks it up on its next tick. Single-key, so
|
|
48
|
+
// using PK on key gives us upsert semantics.
|
|
49
|
+
export const control = sqliteTable('control', {
|
|
50
|
+
key: text('key').primaryKey(), // 'shutdown'|'pause'|'reload_config'
|
|
51
|
+
value: text('value'),
|
|
52
|
+
requestedBy: text('requested_by'),
|
|
53
|
+
requestedAt: integer('requested_at').notNull(),
|
|
54
|
+
});
|
|
55
|
+
// ──────────────────────────────────────────────────────────────────
|
|
56
|
+
// Outbound queue (Phase 1)
|
|
57
|
+
// ──────────────────────────────────────────────────────────────────
|
|
58
|
+
// Replies waiting to be sent on any channel. Workers (chat / async /
|
|
59
|
+
// browser) insert rows here; the sender worker drains by channel
|
|
60
|
+
// adapter. AI code never touches the WA socket directly.
|
|
61
|
+
//
|
|
62
|
+
// Status lifecycle:
|
|
63
|
+
// pending → sending → done (happy path)
|
|
64
|
+
// sending → pending (transient fail, attempts++, backoff)
|
|
65
|
+
// sending → failed (permanent fail, kept for inspection)
|
|
66
|
+
// sending → dlq (attempts > N; dead-letter)
|
|
67
|
+
//
|
|
68
|
+
// idempotency_key prevents double-sends when a worker is reclaimed
|
|
69
|
+
// after TTL and its delayed insert collides with the replacement
|
|
70
|
+
// worker's insert. Format suggested: `from-inbound-<id>` or
|
|
71
|
+
// `from-async-<task-id>`.
|
|
72
|
+
export const outbound = sqliteTable('outbound', {
|
|
73
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
74
|
+
address: text('address').notNull(), // 'wa:dm:...' | 'wa:group:...' | etc.
|
|
75
|
+
kind: text('kind').notNull(), // 'text'|'image'|'video'|'audio'|'document'
|
|
76
|
+
text: text('text'), // body or caption
|
|
77
|
+
mediaPath: text('media_path'), // relative to storage/ when set
|
|
78
|
+
mediaMime: text('media_mime'),
|
|
79
|
+
mediaBytes: integer('media_bytes'), // enforced cap
|
|
80
|
+
quoteMsgId: text('quote_msg_id'),
|
|
81
|
+
idempotencyKey: text('idempotency_key'),
|
|
82
|
+
status: text('status').notNull(), // 'pending'|'sending'|'done'|'failed'|'dlq'
|
|
83
|
+
attempts: integer('attempts').notNull().default(0),
|
|
84
|
+
nextAttemptAt: integer('next_attempt_at'), // unix seconds; null = ready immediately
|
|
85
|
+
lastError: text('last_error'),
|
|
86
|
+
claimedBy: text('claimed_by'),
|
|
87
|
+
claimedAt: integer('claimed_at'),
|
|
88
|
+
createdAt: integer('created_at').notNull(),
|
|
89
|
+
updatedAt: integer('updated_at').notNull(),
|
|
90
|
+
}, t => ({
|
|
91
|
+
byStatusNext: index('outbound_by_status_next').on(t.status, t.nextAttemptAt),
|
|
92
|
+
byAddress: index('outbound_by_address').on(t.address),
|
|
93
|
+
// Sparse unique: enforced only when idempotencyKey is non-null.
|
|
94
|
+
uniqIdemp: uniqueIndex('outbound_idempotency_key_uq')
|
|
95
|
+
.on(t.idempotencyKey)
|
|
96
|
+
.where(sql `${t.idempotencyKey} IS NOT NULL`),
|
|
97
|
+
}));
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from 'fs';
|
|
2
|
+
import { extname } from 'path';
|
|
2
3
|
import { isJidGroup } from 'baileys';
|
|
3
4
|
import { config } from '../config.js';
|
|
5
|
+
import { formatAddress, jidToAddress } from '../db/address.js';
|
|
4
6
|
import { logger } from '../logger.js';
|
|
5
|
-
import {
|
|
6
|
-
import { detectMediaType
|
|
7
|
-
import { getSocket } from '../wa/socket.js';
|
|
7
|
+
import { enqueueOutbound } from '../queue/outbound.js';
|
|
8
|
+
import { detectMediaType } from '../wa/sender.js';
|
|
8
9
|
// Matches [FILE: path], [IMAGE: path], [VIDEO: path], [AUDIO: path], [DOCUMENT: path]
|
|
9
10
|
const FILE_TAG_RE = /\[(?:FILE|IMAGE|VIDEO|AUDIO|DOCUMENT):\s*([^\]]+)\]/gi;
|
|
10
11
|
function extractFiles(reply) {
|
|
@@ -21,104 +22,140 @@ function extractFiles(reply) {
|
|
|
21
22
|
}).trim();
|
|
22
23
|
return { text, files };
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
function kindForFile(filePath) {
|
|
26
|
+
return detectMediaType(filePath);
|
|
27
|
+
}
|
|
28
|
+
const MIME_MAP = {
|
|
29
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
31
|
+
'.mp4': 'video/mp4', '.avi': 'video/avi', '.mov': 'video/quicktime',
|
|
32
|
+
'.mkv': 'video/x-matroska',
|
|
33
|
+
'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.opus': 'audio/opus',
|
|
34
|
+
'.m4a': 'audio/mp4', '.wav': 'audio/wav',
|
|
35
|
+
'.pdf': 'application/pdf',
|
|
36
|
+
'.doc': 'application/msword',
|
|
37
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
38
|
+
'.xls': 'application/vnd.ms-excel',
|
|
39
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
40
|
+
'.csv': 'text/csv', '.txt': 'text/plain', '.zip': 'application/zip',
|
|
41
|
+
};
|
|
42
|
+
function mimeFor(filePath) {
|
|
43
|
+
return MIME_MAP[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
44
|
+
}
|
|
45
|
+
function fileSize(filePath) {
|
|
46
|
+
try {
|
|
47
|
+
return statSync(filePath).size;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
29
51
|
}
|
|
52
|
+
}
|
|
53
|
+
// `originalMsg` is currently ignored when routing through the outbound
|
|
54
|
+
// queue — Baileys quoting needs the full WAMessage embedded in
|
|
55
|
+
// contextInfo, which we'd have to serialize through the DB row and
|
|
56
|
+
// reconstruct. Known regression for Phase 1; see refactor-scrap.md.
|
|
57
|
+
// Kept in the signature so existing callers don't change.
|
|
58
|
+
export async function handleReply(job, result, _originalMsg) {
|
|
30
59
|
const raw = result.reply?.replaceAll('—', ', ').replaceAll('–', '-');
|
|
31
60
|
if (!raw)
|
|
32
61
|
return;
|
|
33
62
|
const { text, files } = extractFiles(raw);
|
|
34
63
|
const isGroup = isJidGroup(job.jid) === true;
|
|
35
|
-
|
|
64
|
+
void isGroup; // quoting deferred; see comment above
|
|
65
|
+
const address = formatAddress(jidToAddress(job.jid));
|
|
36
66
|
const footer = result.stats && config.reply.showStats
|
|
37
67
|
? formatStatsFooter(result.stats)
|
|
38
68
|
: '';
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
text: caption || `[${mediaType}: ${filePath}]`,
|
|
67
|
-
messageType: `${mediaType}Message`,
|
|
68
|
-
mediaPath: filePath,
|
|
69
|
-
mediaType,
|
|
70
|
-
});
|
|
71
|
-
logger.info({ jid: job.jid, path: filePath, mediaType }, 'file sent');
|
|
72
|
-
// Clean up temp file after sending
|
|
73
|
-
try {
|
|
74
|
-
unlinkSync(filePath);
|
|
75
|
-
}
|
|
76
|
-
catch { }
|
|
77
|
-
if (files.length > 1)
|
|
78
|
-
await sleep(config.reply.chunkDelayMs);
|
|
79
|
-
}
|
|
80
|
-
// Send text (skip if already used as caption on single file)
|
|
81
|
-
const textAlreadySent = files.length === 1 && text && text.length <= 1000 && detectMediaType(files[0]) !== 'audio';
|
|
82
|
-
if (text && !textAlreadySent) {
|
|
83
|
-
const chunks = chunkText(text, config.reply.chunkChars);
|
|
84
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
85
|
-
const chunk = chunks[i];
|
|
86
|
-
const q = i === 0 && files.length === 0 ? quoted : undefined;
|
|
87
|
-
const isLast = i === chunks.length - 1;
|
|
88
|
-
const chunkForSend = isLast && footer ? `${chunk}\n\n${footer}` : chunk;
|
|
89
|
-
await sendText(sock, job.jid, chunkForSend, q);
|
|
90
|
-
await append({
|
|
91
|
-
id: `reply-${Date.now()}-${i}`,
|
|
92
|
-
jid: job.jid,
|
|
93
|
-
direction: 'out',
|
|
94
|
-
fromMe: true,
|
|
95
|
-
sender: sock.user?.id ?? '',
|
|
96
|
-
senderNumber: config.owner.number,
|
|
97
|
-
timestamp: Math.floor(Date.now() / 1000),
|
|
98
|
-
text: chunk,
|
|
99
|
-
messageType: 'conversation',
|
|
100
|
-
});
|
|
101
|
-
if (i < chunks.length - 1)
|
|
102
|
-
await sleep(config.reply.chunkDelayMs);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (config.reply.typingIndicator) {
|
|
106
|
-
await sock
|
|
107
|
-
.sendPresenceUpdate('paused', job.jid)
|
|
108
|
-
.catch(() => undefined);
|
|
109
|
-
}
|
|
110
|
-
logger.info({
|
|
111
|
-
jid: job.jid,
|
|
112
|
-
files: files.length,
|
|
113
|
-
chars: text.length,
|
|
114
|
-
}, 'reply sent');
|
|
69
|
+
let pieceIdx = 0;
|
|
70
|
+
const baseKey = `reply-${job.jid}-${Date.now()}`;
|
|
71
|
+
const enqueuePiece = (input) => {
|
|
72
|
+
enqueueOutbound({ ...input, idempotencyKey: `${baseKey}-${pieceIdx++}` });
|
|
73
|
+
};
|
|
74
|
+
// Files first. Caption goes on the single-file-with-short-text case,
|
|
75
|
+
// matching pre-refactor behavior.
|
|
76
|
+
for (let i = 0; i < files.length; i++) {
|
|
77
|
+
const filePath = files[i];
|
|
78
|
+
const isFirst = i === 0;
|
|
79
|
+
const kind = kindForFile(filePath);
|
|
80
|
+
const supportsCaption = kind !== 'audio';
|
|
81
|
+
const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
|
|
82
|
+
? text
|
|
83
|
+
: undefined;
|
|
84
|
+
const willHaveTextAfter = !!text && !(files.length === 1 && text.length <= 1000 && supportsCaption);
|
|
85
|
+
const captionForSend = caption && footer && !willHaveTextAfter
|
|
86
|
+
? `${caption}\n\n${footer}`
|
|
87
|
+
: caption;
|
|
88
|
+
enqueuePiece({
|
|
89
|
+
address,
|
|
90
|
+
kind,
|
|
91
|
+
text: captionForSend,
|
|
92
|
+
mediaPath: filePath,
|
|
93
|
+
mediaMime: mimeFor(filePath),
|
|
94
|
+
mediaBytes: fileSize(filePath),
|
|
95
|
+
});
|
|
115
96
|
}
|
|
116
|
-
|
|
117
|
-
|
|
97
|
+
// Text — skip if already used as a caption on a single file.
|
|
98
|
+
const textAlreadySent = files.length === 1 &&
|
|
99
|
+
text &&
|
|
100
|
+
text.length <= 1000 &&
|
|
101
|
+
kindForFile(files[0]) !== 'audio';
|
|
102
|
+
if (text && !textAlreadySent) {
|
|
103
|
+
const chunks = chunkText(text, config.reply.chunkChars);
|
|
104
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
105
|
+
const chunk = chunks[i];
|
|
106
|
+
const isLast = i === chunks.length - 1;
|
|
107
|
+
const chunkForSend = isLast && footer ? `${chunk}\n\n${footer}` : chunk;
|
|
108
|
+
enqueuePiece({ address, kind: 'text', text: chunkForSend });
|
|
109
|
+
}
|
|
118
110
|
}
|
|
111
|
+
logger.info({ jid: job.jid, files: files.length, chars: text.length, pieces: pieceIdx }, 'reply enqueued for outbound');
|
|
119
112
|
}
|
|
120
|
-
|
|
121
|
-
|
|
113
|
+
// Proactive outbound: send a message to a chat without an incoming
|
|
114
|
+
// trigger. Same parsing as handleReply; enqueues outbound rows.
|
|
115
|
+
// `text` may contain [FILE:...] tags; they're extracted and enqueued
|
|
116
|
+
// as media. Returns true if anything was enqueued.
|
|
117
|
+
export async function initiate(params) {
|
|
118
|
+
const raw = params.text.replaceAll('—', ', ').replaceAll('–', '-');
|
|
119
|
+
if (!raw.trim())
|
|
120
|
+
return false;
|
|
121
|
+
const { text, files } = extractFiles(raw);
|
|
122
|
+
if (!text && files.length === 0)
|
|
123
|
+
return false;
|
|
124
|
+
const address = formatAddress(jidToAddress(params.jid));
|
|
125
|
+
let pieceIdx = 0;
|
|
126
|
+
const baseKey = `initiate-${params.jid}-${Date.now()}`;
|
|
127
|
+
const enqueuePiece = (input) => {
|
|
128
|
+
enqueueOutbound({ ...input, idempotencyKey: `${baseKey}-${pieceIdx++}` });
|
|
129
|
+
};
|
|
130
|
+
for (let i = 0; i < files.length; i++) {
|
|
131
|
+
const filePath = files[i];
|
|
132
|
+
const isFirst = i === 0;
|
|
133
|
+
const kind = kindForFile(filePath);
|
|
134
|
+
const supportsCaption = kind !== 'audio';
|
|
135
|
+
const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
|
|
136
|
+
? text
|
|
137
|
+
: undefined;
|
|
138
|
+
enqueuePiece({
|
|
139
|
+
address,
|
|
140
|
+
kind,
|
|
141
|
+
text: caption,
|
|
142
|
+
mediaPath: filePath,
|
|
143
|
+
mediaMime: mimeFor(filePath),
|
|
144
|
+
mediaBytes: fileSize(filePath),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const textAlreadySent = files.length === 1 &&
|
|
148
|
+
text &&
|
|
149
|
+
text.length <= 1000 &&
|
|
150
|
+
kindForFile(files[0]) !== 'audio';
|
|
151
|
+
if (text && !textAlreadySent) {
|
|
152
|
+
const chunks = chunkText(text, config.reply.chunkChars);
|
|
153
|
+
for (const chunk of chunks) {
|
|
154
|
+
enqueuePiece({ address, kind: 'text', text: chunk });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
logger.info({ jid: params.jid, files: files.length, chars: text.length, pieces: pieceIdx }, 'proactive message enqueued for outbound');
|
|
158
|
+
return pieceIdx > 0;
|
|
122
159
|
}
|
|
123
160
|
// Append-only-at-send footer. Never stored, never in Claude's recent-context
|
|
124
161
|
// feedback loop. Adaptive: shows only what's interesting for this reply.
|
|
@@ -142,16 +179,12 @@ export function formatStatsFooter(stats) {
|
|
|
142
179
|
else if (pct >= 70)
|
|
143
180
|
parts.push(`${pct}% ctx`);
|
|
144
181
|
}
|
|
145
|
-
// Fresh session — resume is default, says nothing
|
|
146
182
|
if (stats.fresh)
|
|
147
183
|
parts.push('fresh');
|
|
148
|
-
// Journal flagged — show each slug (usually 0 or 1)
|
|
149
184
|
for (const slug of stats.journalSlugs)
|
|
150
185
|
parts.push(`+journal:${slug}`);
|
|
151
|
-
// Digest fired
|
|
152
186
|
if (stats.hasDigest)
|
|
153
187
|
parts.push('+digest');
|
|
154
|
-
// Async spawned
|
|
155
188
|
if (stats.asyncCount > 0) {
|
|
156
189
|
parts.push(stats.asyncCount === 1 ? '+async' : `+${stats.asyncCount} async`);
|
|
157
190
|
}
|
|
@@ -164,89 +197,6 @@ function compactTokens(n) {
|
|
|
164
197
|
return `${(n / 1000).toFixed(1)}k`;
|
|
165
198
|
return `${Math.round(n / 1000)}k`;
|
|
166
199
|
}
|
|
167
|
-
// Proactive outbound: send a message to a chat without an incoming trigger.
|
|
168
|
-
// Extracts [FILE:]/[IMAGE:]/etc tags the same way handleReply does — files
|
|
169
|
-
// get sent as WhatsApp media, remaining text sent normally. Chunks, persists
|
|
170
|
-
// to the message log, never throws. Callers are responsible for the
|
|
171
|
-
// canSendProactive() gate — this function does not re-check it.
|
|
172
|
-
export async function initiate(params) {
|
|
173
|
-
const sock = getSocket();
|
|
174
|
-
if (!sock) {
|
|
175
|
-
logger.warn({ jid: params.jid }, 'initiate: no socket available');
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
const raw = params.text.replaceAll('—', ', ').replaceAll('–', '-');
|
|
179
|
-
if (!raw.trim())
|
|
180
|
-
return false;
|
|
181
|
-
const { text, files } = extractFiles(raw);
|
|
182
|
-
try {
|
|
183
|
-
// Send any files first — images, video, PDFs, audio, etc.
|
|
184
|
-
for (const filePath of files) {
|
|
185
|
-
const isFirst = filePath === files[0];
|
|
186
|
-
const mediaType = detectMediaType(filePath);
|
|
187
|
-
const supportsCaption = mediaType !== 'audio';
|
|
188
|
-
const caption = isFirst &&
|
|
189
|
-
text &&
|
|
190
|
-
text.length <= 1000 &&
|
|
191
|
-
files.length === 1 &&
|
|
192
|
-
supportsCaption
|
|
193
|
-
? text
|
|
194
|
-
: undefined;
|
|
195
|
-
await sendFile(sock, params.jid, filePath, caption);
|
|
196
|
-
await append({
|
|
197
|
-
id: `initiate-file-${Date.now()}`,
|
|
198
|
-
jid: params.jid,
|
|
199
|
-
direction: 'out',
|
|
200
|
-
fromMe: true,
|
|
201
|
-
sender: sock.user?.id ?? '',
|
|
202
|
-
senderNumber: config.owner.number,
|
|
203
|
-
timestamp: Math.floor(Date.now() / 1000),
|
|
204
|
-
text: caption || `[${mediaType}: ${filePath}]`,
|
|
205
|
-
messageType: `${mediaType}Message`,
|
|
206
|
-
mediaPath: filePath,
|
|
207
|
-
mediaType,
|
|
208
|
-
});
|
|
209
|
-
logger.info({ jid: params.jid, path: filePath, mediaType }, 'proactive file sent');
|
|
210
|
-
try {
|
|
211
|
-
unlinkSync(filePath);
|
|
212
|
-
}
|
|
213
|
-
catch { }
|
|
214
|
-
if (files.length > 1)
|
|
215
|
-
await sleep(config.reply.chunkDelayMs);
|
|
216
|
-
}
|
|
217
|
-
// Send text — skip only when it was used as the caption on a single file
|
|
218
|
-
const textAlreadySent = files.length === 1 &&
|
|
219
|
-
text &&
|
|
220
|
-
text.length <= 1000 &&
|
|
221
|
-
detectMediaType(files[0]) !== 'audio';
|
|
222
|
-
if (text && !textAlreadySent) {
|
|
223
|
-
const chunks = chunkText(text, config.reply.chunkChars);
|
|
224
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
225
|
-
const chunk = chunks[i];
|
|
226
|
-
await sendText(sock, params.jid, chunk);
|
|
227
|
-
await append({
|
|
228
|
-
id: `initiate-${Date.now()}-${i}`,
|
|
229
|
-
jid: params.jid,
|
|
230
|
-
direction: 'out',
|
|
231
|
-
fromMe: true,
|
|
232
|
-
sender: sock.user?.id ?? '',
|
|
233
|
-
senderNumber: config.owner.number,
|
|
234
|
-
timestamp: Math.floor(Date.now() / 1000),
|
|
235
|
-
text: chunk,
|
|
236
|
-
messageType: 'conversation',
|
|
237
|
-
});
|
|
238
|
-
if (i < chunks.length - 1)
|
|
239
|
-
await sleep(config.reply.chunkDelayMs);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
logger.info({ jid: params.jid, files: files.length, chars: text.length }, 'proactive message sent');
|
|
243
|
-
return true;
|
|
244
|
-
}
|
|
245
|
-
catch (err) {
|
|
246
|
-
logger.error({ err, jid: params.jid }, 'initiate failed');
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
200
|
export function chunkText(text, maxChars) {
|
|
251
201
|
if (text.length <= maxChars)
|
|
252
202
|
return [text];
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
|
+
import { setBaileysSocket } from './channels/index.js';
|
|
2
|
+
import { closeDb, initDb } from './db/index.js';
|
|
3
|
+
import { syncIdentitiesFromAccess } from './db/identity-sync.js';
|
|
1
4
|
import { attachIncoming } from './gateway/incoming.js';
|
|
2
5
|
import { handleReply } from './gateway/outgoing.js';
|
|
3
6
|
import { logger } from './logger.js';
|
|
4
7
|
import { startScheduler } from './memory/scheduler.js';
|
|
5
8
|
import { replayPending } from './queue/queue.js';
|
|
9
|
+
import { startSenderWorker, stopSenderWorker } from './queue/sender-worker.js';
|
|
6
10
|
import { startSocket } from './wa/socket.js';
|
|
7
11
|
async function main() {
|
|
8
12
|
logger.info('heyamigo starting');
|
|
13
|
+
// Migrations + drift check first; refuses to start on schema mismatch.
|
|
14
|
+
initDb();
|
|
15
|
+
// Derived view: populate persons + identities from access.json.
|
|
16
|
+
syncIdentitiesFromAccess();
|
|
17
|
+
// Sender worker drains outbound queue → channel adapters. Started
|
|
18
|
+
// before the socket so it's ready when handleReply enqueues rows.
|
|
19
|
+
startSenderWorker();
|
|
9
20
|
startScheduler();
|
|
10
21
|
await startSocket((sock) => {
|
|
11
22
|
attachIncoming(sock);
|
|
23
|
+
// Point the Baileys adapter at the live socket. Called on each
|
|
24
|
+
// reconnect with a fresh sock; the adapter just keeps the latest.
|
|
25
|
+
setBaileysSocket(sock);
|
|
12
26
|
});
|
|
13
27
|
// Replay any jobs left from a previous crash (no original WAMessage
|
|
14
28
|
// available, so replies are sent as plain messages, not quoted).
|
|
@@ -18,10 +32,14 @@ async function main() {
|
|
|
18
32
|
}
|
|
19
33
|
process.on('SIGINT', () => {
|
|
20
34
|
logger.info('SIGINT received, shutting down');
|
|
35
|
+
stopSenderWorker();
|
|
36
|
+
closeDb();
|
|
21
37
|
process.exit(0);
|
|
22
38
|
});
|
|
23
39
|
process.on('SIGTERM', () => {
|
|
24
40
|
logger.info('SIGTERM received, shutting down');
|
|
41
|
+
stopSenderWorker();
|
|
42
|
+
closeDb();
|
|
25
43
|
process.exit(0);
|
|
26
44
|
});
|
|
27
45
|
main().catch((err) => {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Post-send bookkeeping run by the sender worker after a successful
|
|
2
|
+
// channel send. Kept separate from the worker loop so it's easy to
|
|
3
|
+
// extend (e.g. emit metrics, fire webhooks) without bloating the loop.
|
|
4
|
+
//
|
|
5
|
+
// Today's job:
|
|
6
|
+
// - Append a row to the message log so Claude sees what the bot
|
|
7
|
+
// said in future bootstrap context.
|
|
8
|
+
// - Delete the local file if the outbound row pointed at one and the
|
|
9
|
+
// path looks like ephemeral outbox content. We trust producers to
|
|
10
|
+
// only point at files they want unlinked after send.
|
|
11
|
+
import { existsSync, unlinkSync } from 'fs';
|
|
12
|
+
import { isAbsolute, resolve } from 'path';
|
|
13
|
+
import { config } from '../config.js';
|
|
14
|
+
import { addressToExternalId, parseAddress } from '../db/address.js';
|
|
15
|
+
import { logger } from '../logger.js';
|
|
16
|
+
import { append } from '../store/messages.js';
|
|
17
|
+
export async function afterSend(row, sentMsgId) {
|
|
18
|
+
await persistToMessageLog(row, sentMsgId);
|
|
19
|
+
maybeUnlinkMedia(row);
|
|
20
|
+
}
|
|
21
|
+
async function persistToMessageLog(row, msgId) {
|
|
22
|
+
let address;
|
|
23
|
+
try {
|
|
24
|
+
address = parseAddress(row.address);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Message log is currently WA-specific (keyed by JID). Only persist
|
|
30
|
+
// WA-bound replies for now; multi-channel log unification comes in a
|
|
31
|
+
// later phase.
|
|
32
|
+
if (address.channel !== 'wa')
|
|
33
|
+
return;
|
|
34
|
+
const jid = addressToExternalId(address);
|
|
35
|
+
const messageType = row.kind === 'text' ? 'conversation' : `${row.kind}Message`;
|
|
36
|
+
try {
|
|
37
|
+
await append({
|
|
38
|
+
id: `outbound-${row.id}-${msgId}`,
|
|
39
|
+
jid,
|
|
40
|
+
direction: 'out',
|
|
41
|
+
fromMe: true,
|
|
42
|
+
sender: '',
|
|
43
|
+
senderNumber: config.owner.number,
|
|
44
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
45
|
+
text: row.text ?? (row.mediaPath ? `[${row.kind}: ${row.mediaPath}]` : ''),
|
|
46
|
+
messageType,
|
|
47
|
+
mediaPath: row.mediaPath ?? undefined,
|
|
48
|
+
mediaType: row.kind === 'text' ? undefined : row.kind,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
logger.warn({ err, outboundId: row.id }, 'failed to append to message log (send already succeeded)');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function maybeUnlinkMedia(row) {
|
|
56
|
+
if (!row.mediaPath)
|
|
57
|
+
return;
|
|
58
|
+
// Resolve relative paths the same way the sender worker did when
|
|
59
|
+
// calling the adapter, so we delete the actual file.
|
|
60
|
+
const path = isAbsolute(row.mediaPath)
|
|
61
|
+
? row.mediaPath
|
|
62
|
+
: resolve(process.cwd(), row.mediaPath);
|
|
63
|
+
// Only auto-delete files inside known-ephemeral directories. Inbound
|
|
64
|
+
// media in storage/media/ has its own retention cron and should not
|
|
65
|
+
// be touched here. config.storage.mediaDir = inbound media.
|
|
66
|
+
const inboundMediaDir = resolve(process.cwd(), config.storage.mediaDir);
|
|
67
|
+
if (path.startsWith(inboundMediaDir)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(path))
|
|
72
|
+
unlinkSync(path);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.warn({ err, path }, 'failed to unlink outbound media file');
|
|
76
|
+
}
|
|
77
|
+
}
|