@c4t4/heyamigo 0.9.0 → 0.9.2
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/identity-sync.js +147 -0
- package/dist/db/schema.js +45 -1
- package/dist/gateway/outgoing.js +127 -177
- package/dist/index.js +13 -2
- package/dist/memory/digest-flag.js +28 -0
- package/dist/queue/async-tasks.js +27 -2
- package/dist/queue/outbound-postsend.js +77 -0
- package/dist/queue/outbound.js +185 -0
- package/dist/queue/sender-worker.js +199 -0
- package/dist/queue/worker.js +15 -1
- package/migrations/0001_phase1_outbound.sql +23 -0
- package/migrations/meta/0001_snapshot.json +377 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Outbound queue helpers. Producers (chat workers, async workers,
|
|
2
|
+
// crons, external triggers) call enqueueOutbound. The sender worker
|
|
3
|
+
// drains via claimNextOutbound + markOutbound{Done,Failed,Retry}.
|
|
4
|
+
//
|
|
5
|
+
// All mutations preserve the claimed_by safety check on completion:
|
|
6
|
+
// only the holder of a claim can mark it done/failed. A slow worker
|
|
7
|
+
// that comes back after TTL-reclaim will harmlessly no-op.
|
|
8
|
+
import { and, asc, eq, isNull, lte, or, sql } from 'drizzle-orm';
|
|
9
|
+
import { getDb } from '../db/index.js';
|
|
10
|
+
import { outbound } from '../db/schema.js';
|
|
11
|
+
// Insert a row, or no-op when the same idempotency_key already exists.
|
|
12
|
+
// Returns the row either way so callers can log/observe.
|
|
13
|
+
export function enqueueOutbound(input) {
|
|
14
|
+
const db = getDb();
|
|
15
|
+
const now = Math.floor(Date.now() / 1000);
|
|
16
|
+
// Idempotency: look up first. SQLite has no INSERT ... ON CONFLICT
|
|
17
|
+
// returning the previous row, so we serve it in two queries inside
|
|
18
|
+
// a transaction.
|
|
19
|
+
if (input.idempotencyKey) {
|
|
20
|
+
const found = db
|
|
21
|
+
.select()
|
|
22
|
+
.from(outbound)
|
|
23
|
+
.where(eq(outbound.idempotencyKey, input.idempotencyKey))
|
|
24
|
+
.get();
|
|
25
|
+
if (found)
|
|
26
|
+
return { inserted: false, row: found };
|
|
27
|
+
}
|
|
28
|
+
const inserted = db
|
|
29
|
+
.insert(outbound)
|
|
30
|
+
.values({
|
|
31
|
+
address: input.address,
|
|
32
|
+
kind: input.kind,
|
|
33
|
+
text: input.text ?? null,
|
|
34
|
+
mediaPath: input.mediaPath ?? null,
|
|
35
|
+
mediaMime: input.mediaMime ?? null,
|
|
36
|
+
mediaBytes: input.mediaBytes ?? null,
|
|
37
|
+
quoteMsgId: input.quoteMsgId ?? null,
|
|
38
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
39
|
+
status: 'pending',
|
|
40
|
+
attempts: 0,
|
|
41
|
+
nextAttemptAt: null,
|
|
42
|
+
lastError: null,
|
|
43
|
+
claimedBy: null,
|
|
44
|
+
claimedAt: null,
|
|
45
|
+
createdAt: now,
|
|
46
|
+
updatedAt: now,
|
|
47
|
+
})
|
|
48
|
+
.returning()
|
|
49
|
+
.get();
|
|
50
|
+
return { inserted: true, row: inserted };
|
|
51
|
+
}
|
|
52
|
+
// Atomic claim. Returns the row or null if nothing's ready.
|
|
53
|
+
// Reserves rows whose nextAttemptAt is null (ready immediately) OR in
|
|
54
|
+
// the past (backoff elapsed). Single-statement so two workers can't
|
|
55
|
+
// claim the same row.
|
|
56
|
+
export function claimNextOutbound(workerId) {
|
|
57
|
+
const db = getDb();
|
|
58
|
+
const now = Math.floor(Date.now() / 1000);
|
|
59
|
+
// SQLite supports UPDATE ... RETURNING since 3.35.
|
|
60
|
+
return db.transaction((tx) => {
|
|
61
|
+
const target = tx
|
|
62
|
+
.select({ id: outbound.id })
|
|
63
|
+
.from(outbound)
|
|
64
|
+
.where(and(eq(outbound.status, 'pending'), or(isNull(outbound.nextAttemptAt), lte(outbound.nextAttemptAt, now))))
|
|
65
|
+
.orderBy(asc(outbound.id))
|
|
66
|
+
.limit(1)
|
|
67
|
+
.get();
|
|
68
|
+
if (!target)
|
|
69
|
+
return null;
|
|
70
|
+
const claimed = tx
|
|
71
|
+
.update(outbound)
|
|
72
|
+
.set({
|
|
73
|
+
status: 'sending',
|
|
74
|
+
claimedBy: workerId,
|
|
75
|
+
claimedAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
})
|
|
78
|
+
.where(and(eq(outbound.id, target.id), eq(outbound.status, 'pending')))
|
|
79
|
+
.returning()
|
|
80
|
+
.get();
|
|
81
|
+
return claimed ?? null;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Mark done — succeeds only when the row is still owned by the caller.
|
|
85
|
+
// Returns whether the update actually applied.
|
|
86
|
+
export function markOutboundDone(id, workerId) {
|
|
87
|
+
const db = getDb();
|
|
88
|
+
const now = Math.floor(Date.now() / 1000);
|
|
89
|
+
const result = db
|
|
90
|
+
.update(outbound)
|
|
91
|
+
.set({ status: 'done', updatedAt: now })
|
|
92
|
+
.where(and(eq(outbound.id, id), eq(outbound.status, 'sending'), eq(outbound.claimedBy, workerId)))
|
|
93
|
+
.returning({ id: outbound.id })
|
|
94
|
+
.all();
|
|
95
|
+
return result.length > 0;
|
|
96
|
+
}
|
|
97
|
+
// Backoff schedule: 1s, 5s, 30s, 2min, give up.
|
|
98
|
+
const BACKOFF_SECONDS = [1, 5, 30, 120];
|
|
99
|
+
const MAX_ATTEMPTS = BACKOFF_SECONDS.length;
|
|
100
|
+
// Transient failure: return to pending with next_attempt_at set, or
|
|
101
|
+
// move to DLQ if attempts exceeded. Caller-owned check applies.
|
|
102
|
+
export function markOutboundRetryOrDlq(id, workerId, errorMessage) {
|
|
103
|
+
const db = getDb();
|
|
104
|
+
return db.transaction((tx) => {
|
|
105
|
+
const row = tx
|
|
106
|
+
.select()
|
|
107
|
+
.from(outbound)
|
|
108
|
+
.where(eq(outbound.id, id))
|
|
109
|
+
.get();
|
|
110
|
+
if (!row || row.status !== 'sending' || row.claimedBy !== workerId) {
|
|
111
|
+
return { retried: false, deadLettered: false };
|
|
112
|
+
}
|
|
113
|
+
const now = Math.floor(Date.now() / 1000);
|
|
114
|
+
const nextAttempts = row.attempts + 1;
|
|
115
|
+
if (nextAttempts > MAX_ATTEMPTS) {
|
|
116
|
+
tx.update(outbound)
|
|
117
|
+
.set({
|
|
118
|
+
status: 'dlq',
|
|
119
|
+
attempts: nextAttempts,
|
|
120
|
+
lastError: errorMessage,
|
|
121
|
+
claimedBy: null,
|
|
122
|
+
claimedAt: null,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
})
|
|
125
|
+
.where(eq(outbound.id, id))
|
|
126
|
+
.run();
|
|
127
|
+
return { retried: false, deadLettered: true };
|
|
128
|
+
}
|
|
129
|
+
const backoff = BACKOFF_SECONDS[Math.min(row.attempts, BACKOFF_SECONDS.length - 1)];
|
|
130
|
+
tx.update(outbound)
|
|
131
|
+
.set({
|
|
132
|
+
status: 'pending',
|
|
133
|
+
attempts: nextAttempts,
|
|
134
|
+
nextAttemptAt: now + backoff,
|
|
135
|
+
lastError: errorMessage,
|
|
136
|
+
claimedBy: null,
|
|
137
|
+
claimedAt: null,
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
})
|
|
140
|
+
.where(eq(outbound.id, id))
|
|
141
|
+
.run();
|
|
142
|
+
return { retried: true, deadLettered: false };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Permanent failure (no retry). Used when the error is unrecoverable
|
|
146
|
+
// — e.g. media file missing, malformed address.
|
|
147
|
+
export function markOutboundFailed(id, workerId, errorMessage) {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
const now = Math.floor(Date.now() / 1000);
|
|
150
|
+
const result = db
|
|
151
|
+
.update(outbound)
|
|
152
|
+
.set({
|
|
153
|
+
status: 'failed',
|
|
154
|
+
lastError: errorMessage,
|
|
155
|
+
claimedBy: null,
|
|
156
|
+
claimedAt: null,
|
|
157
|
+
updatedAt: now,
|
|
158
|
+
})
|
|
159
|
+
.where(and(eq(outbound.id, id), eq(outbound.status, 'sending'), eq(outbound.claimedBy, workerId)))
|
|
160
|
+
.returning({ id: outbound.id })
|
|
161
|
+
.all();
|
|
162
|
+
return result.length > 0;
|
|
163
|
+
}
|
|
164
|
+
// Orchestrator helper: reclaim rows whose worker died mid-send.
|
|
165
|
+
// "Stuck in sending past TTL" → return to pending so another worker
|
|
166
|
+
// can pick them up. attempts NOT incremented (the worker may have
|
|
167
|
+
// died before even talking to the channel).
|
|
168
|
+
const CLAIM_TTL_SECONDS = 60;
|
|
169
|
+
export function reclaimStuckOutbound() {
|
|
170
|
+
const db = getDb();
|
|
171
|
+
const cutoff = Math.floor(Date.now() / 1000) - CLAIM_TTL_SECONDS;
|
|
172
|
+
const result = db
|
|
173
|
+
.update(outbound)
|
|
174
|
+
.set({
|
|
175
|
+
status: 'pending',
|
|
176
|
+
claimedBy: null,
|
|
177
|
+
claimedAt: null,
|
|
178
|
+
// intentionally leaving updatedAt as-is so observability can spot reclaims
|
|
179
|
+
updatedAt: sql `${outbound.updatedAt}`,
|
|
180
|
+
})
|
|
181
|
+
.where(and(eq(outbound.status, 'sending'), lte(outbound.claimedAt, cutoff)))
|
|
182
|
+
.returning({ id: outbound.id })
|
|
183
|
+
.all();
|
|
184
|
+
return result.length;
|
|
185
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Sender worker. Drains the outbound queue and pushes each row to the
|
|
2
|
+
// matching channel adapter. One per process (no concurrency) so
|
|
3
|
+
// per-address ordering is preserved naturally and rate-limiting lives
|
|
4
|
+
// in one place.
|
|
5
|
+
import { hostname } from 'os';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { eq } from 'drizzle-orm';
|
|
8
|
+
import { config } from '../config.js';
|
|
9
|
+
import { getDb } from '../db/index.js';
|
|
10
|
+
import { parseAddress } from '../db/address.js';
|
|
11
|
+
import { workers } from '../db/schema.js';
|
|
12
|
+
import { getChannelAdapter, PermanentChannelError, TransientChannelError, } from '../channels/index.js';
|
|
13
|
+
import { logger } from '../logger.js';
|
|
14
|
+
import { claimNextOutbound, markOutboundDone, markOutboundFailed, markOutboundRetryOrDlq, } from './outbound.js';
|
|
15
|
+
import { afterSend } from './outbound-postsend.js';
|
|
16
|
+
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
17
|
+
const IDLE_POLL_INTERVAL_MS = 500; // when queue empty
|
|
18
|
+
const BUSY_POLL_INTERVAL_MS = 50; // immediately fetch next after a successful send
|
|
19
|
+
let workerId = null;
|
|
20
|
+
let stopping = false;
|
|
21
|
+
let heartbeatTimer = null;
|
|
22
|
+
function newWorkerId() {
|
|
23
|
+
return `${hostname()}-${process.pid}-sender-0`;
|
|
24
|
+
}
|
|
25
|
+
function registerWorker(id) {
|
|
26
|
+
const db = getDb();
|
|
27
|
+
const now = Math.floor(Date.now() / 1000);
|
|
28
|
+
db.insert(workers)
|
|
29
|
+
.values({
|
|
30
|
+
id,
|
|
31
|
+
kind: 'sender',
|
|
32
|
+
status: 'idle',
|
|
33
|
+
currentJob: null,
|
|
34
|
+
lastSeen: now,
|
|
35
|
+
startedAt: now,
|
|
36
|
+
})
|
|
37
|
+
.onConflictDoUpdate({
|
|
38
|
+
target: workers.id,
|
|
39
|
+
set: { status: 'idle', currentJob: null, lastSeen: now, startedAt: now },
|
|
40
|
+
})
|
|
41
|
+
.run();
|
|
42
|
+
}
|
|
43
|
+
function setWorkerStatus(id, status, currentJob = null) {
|
|
44
|
+
const db = getDb();
|
|
45
|
+
db.update(workers)
|
|
46
|
+
.set({
|
|
47
|
+
status,
|
|
48
|
+
currentJob,
|
|
49
|
+
lastSeen: Math.floor(Date.now() / 1000),
|
|
50
|
+
})
|
|
51
|
+
.where(eq(workers.id, id))
|
|
52
|
+
.run();
|
|
53
|
+
}
|
|
54
|
+
function heartbeat(id) {
|
|
55
|
+
const db = getDb();
|
|
56
|
+
db.update(workers)
|
|
57
|
+
.set({ lastSeen: Math.floor(Date.now() / 1000) })
|
|
58
|
+
.where(eq(workers.id, id))
|
|
59
|
+
.run();
|
|
60
|
+
}
|
|
61
|
+
// Translate an outbound row into the channel-agnostic message shape.
|
|
62
|
+
// Media paths are stored relative to the project root in the row;
|
|
63
|
+
// resolved to absolute here so the adapter can readFileSync directly.
|
|
64
|
+
function rowToMessage(row) {
|
|
65
|
+
return {
|
|
66
|
+
kind: row.kind,
|
|
67
|
+
text: row.text ?? undefined,
|
|
68
|
+
mediaPath: row.mediaPath ? resolve(process.cwd(), row.mediaPath) : undefined,
|
|
69
|
+
mediaMime: row.mediaMime ?? undefined,
|
|
70
|
+
quoteMsgId: row.quoteMsgId ?? undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Enforce media-size cap. mediaBytes is stored on the row by the
|
|
74
|
+
// producer; if missing, we trust the channel to enforce its own limit.
|
|
75
|
+
function tooLarge(row) {
|
|
76
|
+
const cap = config.reply.maxOutboundMediaBytes ?? null;
|
|
77
|
+
if (cap === null)
|
|
78
|
+
return null;
|
|
79
|
+
if (row.mediaBytes !== null && row.mediaBytes > cap) {
|
|
80
|
+
return `media too large: ${row.mediaBytes} > ${cap} bytes`;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
async function processOne(id, row) {
|
|
85
|
+
setWorkerStatus(id, 'busy', `outbound:${row.id}`);
|
|
86
|
+
// Size cap → permanent fail; no point retrying same payload.
|
|
87
|
+
const sizeError = tooLarge(row);
|
|
88
|
+
if (sizeError) {
|
|
89
|
+
markOutboundFailed(row.id, id, sizeError);
|
|
90
|
+
logger.warn({ id: row.id, address: row.address }, sizeError);
|
|
91
|
+
setWorkerStatus(id, 'idle');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let address;
|
|
95
|
+
try {
|
|
96
|
+
address = parseAddress(row.address);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
markOutboundFailed(row.id, id, `bad address: ${row.address}`);
|
|
100
|
+
logger.warn({ id: row.id, err }, 'outbound row has unparseable address');
|
|
101
|
+
setWorkerStatus(id, 'idle');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// system:* addresses are bot-internal; not real channels. Drop them
|
|
105
|
+
// with a friendly log so future cron-emitted system messages don't
|
|
106
|
+
// accidentally try to "send" anywhere.
|
|
107
|
+
if (address.channel === 'system') {
|
|
108
|
+
markOutboundFailed(row.id, id, 'system addresses are not sendable');
|
|
109
|
+
logger.warn({ id: row.id, address: row.address }, 'system address routed to sender');
|
|
110
|
+
setWorkerStatus(id, 'idle');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
let adapter;
|
|
114
|
+
try {
|
|
115
|
+
adapter = getChannelAdapter(address.channel);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
markOutboundFailed(row.id, id, err.message);
|
|
119
|
+
logger.error({ id: row.id, channel: address.channel }, 'no adapter for channel');
|
|
120
|
+
setWorkerStatus(id, 'idle');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const result = await adapter.send(address.externalId, rowToMessage(row));
|
|
125
|
+
const ok = markOutboundDone(row.id, id);
|
|
126
|
+
if (!ok) {
|
|
127
|
+
// Lost the claim — orchestrator reclaimed it as stuck, or
|
|
128
|
+
// status changed under us. The send already happened though
|
|
129
|
+
// (channel returned a msg_id), so we just log and move on. The
|
|
130
|
+
// reclaimed copy may re-send → that's why idempotency_key
|
|
131
|
+
// matters at the producer side.
|
|
132
|
+
logger.warn({ id: row.id, msgId: result.msgId }, 'outbound sent but markDone failed (claim lost?)');
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
await afterSend(row, result.msgId).catch((err) => {
|
|
136
|
+
logger.error({ err, id: row.id }, 'afterSend hook failed');
|
|
137
|
+
});
|
|
138
|
+
logger.info({ id: row.id, address: row.address, kind: row.kind, msgId: result.msgId }, 'outbound sent');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err instanceof PermanentChannelError) {
|
|
143
|
+
markOutboundFailed(row.id, id, err.message);
|
|
144
|
+
logger.error({ id: row.id, err: err.message }, 'outbound permanent failure');
|
|
145
|
+
}
|
|
146
|
+
else if (err instanceof TransientChannelError) {
|
|
147
|
+
const result = markOutboundRetryOrDlq(row.id, id, err.message);
|
|
148
|
+
if (result.deadLettered) {
|
|
149
|
+
logger.error({ id: row.id }, 'outbound dead-lettered after max attempts');
|
|
150
|
+
}
|
|
151
|
+
else if (result.retried) {
|
|
152
|
+
logger.warn({ id: row.id, err: err.message }, 'outbound transient fail, will retry');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// Unexpected throw — treat as transient (safer to retry than to
|
|
157
|
+
// give up on something we don't understand).
|
|
158
|
+
const result = markOutboundRetryOrDlq(row.id, id, `unexpected error: ${err.message}`);
|
|
159
|
+
logger.error({ id: row.id, err, retried: result.retried, dlq: result.deadLettered }, 'outbound unexpected error');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
setWorkerStatus(id, 'idle');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function loop(id) {
|
|
167
|
+
while (!stopping) {
|
|
168
|
+
let processed = false;
|
|
169
|
+
try {
|
|
170
|
+
const row = claimNextOutbound(id);
|
|
171
|
+
if (row) {
|
|
172
|
+
await processOne(id, row);
|
|
173
|
+
processed = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
logger.error({ err }, 'sender worker loop error');
|
|
178
|
+
}
|
|
179
|
+
const delay = processed ? BUSY_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
|
|
180
|
+
await new Promise((res) => setTimeout(res, delay));
|
|
181
|
+
}
|
|
182
|
+
setWorkerStatus(id, 'dead');
|
|
183
|
+
}
|
|
184
|
+
export function startSenderWorker() {
|
|
185
|
+
if (workerId) {
|
|
186
|
+
logger.warn('sender worker already started; ignoring');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
workerId = newWorkerId();
|
|
190
|
+
registerWorker(workerId);
|
|
191
|
+
heartbeatTimer = setInterval(() => workerId && heartbeat(workerId), HEARTBEAT_INTERVAL_MS);
|
|
192
|
+
void loop(workerId).catch((err) => logger.fatal({ err }, 'sender worker loop crashed'));
|
|
193
|
+
logger.info({ workerId }, 'sender worker started');
|
|
194
|
+
}
|
|
195
|
+
export function stopSenderWorker() {
|
|
196
|
+
stopping = true;
|
|
197
|
+
if (heartbeatTimer)
|
|
198
|
+
clearInterval(heartbeatTimer);
|
|
199
|
+
}
|
package/dist/queue/worker.js
CHANGED
|
@@ -7,6 +7,7 @@ import { extractFlags } from '../memory/digest-flag.js';
|
|
|
7
7
|
import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
|
|
8
8
|
import { scheduleDigest } from '../memory/scheduler.js';
|
|
9
9
|
import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
|
|
10
|
+
import { enqueueOutbound } from './outbound.js';
|
|
10
11
|
function isStaleSessionError(err) {
|
|
11
12
|
return (err instanceof Error &&
|
|
12
13
|
err.message.includes('No conversation found'));
|
|
@@ -39,7 +40,7 @@ async function callClaude(job) {
|
|
|
39
40
|
if (job.senderNumber) {
|
|
40
41
|
addDailyTokens(job.senderNumber, usage.inputTokens + usage.outputTokens);
|
|
41
42
|
}
|
|
42
|
-
const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, } = extractFlags(reply);
|
|
43
|
+
const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, sendTexts, } = extractFlags(reply);
|
|
43
44
|
if (digest) {
|
|
44
45
|
logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
|
|
45
46
|
scheduleDigest({
|
|
@@ -105,6 +106,19 @@ async function callClaude(job) {
|
|
|
105
106
|
allowedTools: job.allowedTools ?? 'all',
|
|
106
107
|
});
|
|
107
108
|
}
|
|
109
|
+
// SEND-TEXT: cross-chat text send. Agent specified the destination
|
|
110
|
+
// address explicitly. Just drops a row in outbound; sender worker
|
|
111
|
+
// dispatches by channel.
|
|
112
|
+
for (let i = 0; i < sendTexts.length; i++) {
|
|
113
|
+
const t = sendTexts[i];
|
|
114
|
+
enqueueOutbound({
|
|
115
|
+
address: t.address,
|
|
116
|
+
kind: 'text',
|
|
117
|
+
text: t.body,
|
|
118
|
+
idempotencyKey: `sendtext-${job.jid}-${Date.now()}-${i}`,
|
|
119
|
+
});
|
|
120
|
+
logger.info({ from: job.jid, to: t.address, chars: t.body.length }, 'SEND-TEXT enqueued');
|
|
121
|
+
}
|
|
108
122
|
return {
|
|
109
123
|
reply: clean,
|
|
110
124
|
stats: {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
CREATE TABLE `outbound` (
|
|
2
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
3
|
+
`address` text NOT NULL,
|
|
4
|
+
`kind` text NOT NULL,
|
|
5
|
+
`text` text,
|
|
6
|
+
`media_path` text,
|
|
7
|
+
`media_mime` text,
|
|
8
|
+
`media_bytes` integer,
|
|
9
|
+
`quote_msg_id` text,
|
|
10
|
+
`idempotency_key` text,
|
|
11
|
+
`status` text NOT NULL,
|
|
12
|
+
`attempts` integer DEFAULT 0 NOT NULL,
|
|
13
|
+
`next_attempt_at` integer,
|
|
14
|
+
`last_error` text,
|
|
15
|
+
`claimed_by` text,
|
|
16
|
+
`claimed_at` integer,
|
|
17
|
+
`created_at` integer NOT NULL,
|
|
18
|
+
`updated_at` integer NOT NULL
|
|
19
|
+
);
|
|
20
|
+
--> statement-breakpoint
|
|
21
|
+
CREATE INDEX `outbound_by_status_next` ON `outbound` (`status`,`next_attempt_at`);--> statement-breakpoint
|
|
22
|
+
CREATE INDEX `outbound_by_address` ON `outbound` (`address`);--> statement-breakpoint
|
|
23
|
+
CREATE UNIQUE INDEX `outbound_idempotency_key_uq` ON `outbound` (`idempotency_key`) WHERE "outbound"."idempotency_key" IS NOT NULL;
|