@c4t4/heyamigo 0.10.1 → 0.10.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.
@@ -1,4 +1,3 @@
1
- import { jidDecode } from 'baileys';
2
1
  import { config } from '../config.js';
3
2
  function escapeRegex(s) {
4
3
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -11,20 +10,8 @@ function aliasMatches(text, aliases) {
11
10
  }
12
11
  return null;
13
12
  }
14
- function ownerNumbers(sock) {
15
- const out = new Set();
16
- if (config.owner.number)
17
- out.add(config.owner.number);
18
- const pn = sock.user?.id ? jidDecode(sock.user.id)?.user : undefined;
19
- if (pn)
20
- out.add(pn);
21
- const lid = sock.user?.lid ? jidDecode(sock.user.lid)?.user : undefined;
22
- if (lid)
23
- out.add(lid);
24
- return out;
25
- }
26
13
  export function checkTrigger(params) {
27
- const { isGroup, text, msg, sock } = params;
14
+ const { isGroup, text } = params;
28
15
  const mode = isGroup
29
16
  ? config.triggers.groupMode
30
17
  : config.triggers.dmMode;
@@ -43,33 +30,13 @@ export function checkTrigger(params) {
43
30
  const alias = aliasMatches(text, config.triggers.aliases);
44
31
  if (alias)
45
32
  return { triggered: true, reason: `alias:${alias}` };
46
- // Extract contextInfo from any message type (text, image, video, etc.)
47
- const content = msg.message ?? {};
48
- const contextInfo = (content.extendedTextMessage?.contextInfo) ??
49
- (content.imageMessage?.contextInfo) ??
50
- (content.videoMessage?.contextInfo) ??
51
- (content.audioMessage?.contextInfo) ??
52
- (content.documentMessage?.contextInfo) ??
53
- (content.documentWithCaptionMessage?.message?.documentMessage?.contextInfo) ??
54
- (content.stickerMessage?.contextInfo);
55
- // 2. WA @mention pointing at owner
56
- const owners = ownerNumbers(sock);
57
- const mentioned = contextInfo?.mentionedJid ?? [];
58
- for (const m of mentioned) {
59
- const user = jidDecode(m)?.user;
60
- if (user && owners.has(user)) {
61
- return { triggered: true, reason: 'wa mention' };
62
- }
63
- }
33
+ // 2. Channel-provided mention signal, e.g. WhatsApp @mention or
34
+ // Telegram bot username mention.
35
+ if (params.mentionedBot)
36
+ return { triggered: true, reason: 'mention' };
64
37
  // 3. Reply to a bot/owner message
65
- if (config.triggers.replyToBotCounts) {
66
- const quotedParticipant = contextInfo?.participant;
67
- if (quotedParticipant) {
68
- const user = jidDecode(quotedParticipant)?.user;
69
- if (user && owners.has(user)) {
70
- return { triggered: true, reason: 'reply to bot' };
71
- }
72
- }
38
+ if (config.triggers.replyToBotCounts && params.replyToBot) {
39
+ return { triggered: true, reason: 'reply to bot' };
73
40
  }
74
41
  return { triggered: false, reason: 'no trigger match' };
75
42
  }
@@ -32,8 +32,8 @@ function profilePrompt(params) {
32
32
  const personMessages = messages.filter((m) => m.senderNumber === number && m.direction === 'in');
33
33
  const replyMessages = messages.filter((m) => m.direction === 'out');
34
34
  const lines = [
35
- `You are consolidating the long-term profile for a WhatsApp contact.`,
36
- `Contact number: ${number}`,
35
+ `You are consolidating the long-term profile for a chat contact.`,
36
+ `Contact key: ${number}`,
37
37
  ``,
38
38
  `Current profile (may be empty):`,
39
39
  current || '(empty)',
@@ -51,7 +51,7 @@ function profilePrompt(params) {
51
51
  : '(none)',
52
52
  ``,
53
53
  `Rewrite the profile in markdown. Structure:`,
54
- `# <Name if known, else number>`,
54
+ `# <Name if known, else contact key>`,
55
55
  `## Facts`,
56
56
  `## Preferences`,
57
57
  `## Patterns`,
@@ -69,8 +69,8 @@ function profilePrompt(params) {
69
69
  function briefPrompt(params) {
70
70
  const { jid, current, messages, reason } = params;
71
71
  const lines = [
72
- `You are consolidating the long-term brief for a WhatsApp chat.`,
73
- `Chat JID: ${jid}`,
72
+ `You are consolidating the long-term brief for a chat.`,
73
+ `Chat key: ${jid}`,
74
74
  ``,
75
75
  `Current brief (may be empty):`,
76
76
  current || '(empty)',
@@ -115,6 +115,7 @@ async function executeAsyncTask(task) {
115
115
  logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'async task claude call failed');
116
116
  await initiate({
117
117
  jid: task.jid,
118
+ address: task.address,
118
119
  text: `Heads up: the background task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
119
120
  });
120
121
  return;
@@ -188,7 +189,7 @@ async function executeAsyncTask(task) {
188
189
  const chatText = clean.trim();
189
190
  const anyMarkerFired = appendedCount > 0 || journalCreates.length > 0 || digest !== null;
190
191
  if (chatText.length > 0) {
191
- await initiate({ jid: task.jid, text: chatText });
192
+ await initiate({ jid: task.jid, address: task.address, text: chatText });
192
193
  }
193
194
  else if (anyMarkerFired) {
194
195
  // Worker emitted only markers, no chat text. That's contract-breaking
@@ -205,6 +206,7 @@ async function executeAsyncTask(task) {
205
206
  bits.push('digest scheduled');
206
207
  await initiate({
207
208
  jid: task.jid,
209
+ address: task.address,
208
210
  text: `Done. ${bits.join(', ')}.`,
209
211
  });
210
212
  }
@@ -257,7 +259,7 @@ export function enqueueBrowserTask(input) {
257
259
  startedAt: Math.floor(Date.now() / 1000),
258
260
  };
259
261
  enqueueBrowserJob({
260
- address: formatAddress(jidToAddress(task.jid)),
262
+ address: task.address ?? formatAddress(jidToAddress(task.jid)),
261
263
  description: task.description,
262
264
  originatingMessage: task.originatingMessage,
263
265
  senderNumber: task.senderNumber,
@@ -350,6 +352,7 @@ export async function runBrowserTask(task) {
350
352
  logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'browser task provider call failed');
351
353
  await initiate({
352
354
  jid: task.jid,
355
+ address: task.address,
353
356
  text: `Heads up: the browser task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
354
357
  });
355
358
  return;
@@ -409,7 +412,7 @@ export async function runBrowserTask(task) {
409
412
  }
410
413
  const chatText = clean.trim();
411
414
  if (chatText.length > 0) {
412
- await initiate({ jid: task.jid, text: chatText });
415
+ await initiate({ jid: task.jid, address: task.address, text: chatText });
413
416
  }
414
417
  else if (appendedCount > 0 ||
415
418
  journalCreates.length > 0 ||
@@ -423,7 +426,11 @@ export async function runBrowserTask(task) {
423
426
  }
424
427
  if (digest)
425
428
  bits.push('digest scheduled');
426
- await initiate({ jid: task.jid, text: `Done. ${bits.join(', ')}.` });
429
+ await initiate({
430
+ jid: task.jid,
431
+ address: task.address,
432
+ text: `Done. ${bits.join(', ')}.`,
433
+ });
427
434
  }
428
435
  logger.info({
429
436
  id: task.id,
@@ -11,7 +11,7 @@ import { hostname } from 'os';
11
11
  import { eq } from 'drizzle-orm';
12
12
  import { config } from '../config.js';
13
13
  import { getDb } from '../db/index.js';
14
- import { parseAddress } from '../db/address.js';
14
+ import { addressToChatKey } from '../db/address.js';
15
15
  import { workers } from '../db/schema.js';
16
16
  import { logger } from '../logger.js';
17
17
  import { claimNextBrowserTask, markBrowserTaskDone, markBrowserTaskRetryOrDlq, } from './browser-queue.js';
@@ -82,7 +82,8 @@ function rowToAsyncTask(row) {
82
82
  }
83
83
  return {
84
84
  id: `browser-${row.id}`,
85
- jid: parseAddress(row.address).externalId,
85
+ jid: addressToChatKey(row.address),
86
+ address: row.address,
86
87
  senderNumber: row.senderNumber,
87
88
  senderName: row.senderName ?? undefined,
88
89
  description: row.description,
@@ -109,7 +110,8 @@ async function processOne(workerId, row) {
109
110
  // User-facing failure ack so the chat isn't left hanging.
110
111
  try {
111
112
  await initiate({
112
- jid: parseAddress(row.address).externalId,
113
+ jid: addressToChatKey(row.address),
114
+ address: row.address,
113
115
  text: `Heads up: the browser task "${row.description.slice(0, 80)}" failed. Ask me again and I'll retry.`,
114
116
  });
115
117
  }
@@ -6,6 +6,7 @@
6
6
  // worker can attribute token usage back via addCronUsage(). Lets
7
7
  // /crons show running totals per recurring schedule.
8
8
  import { logger } from '../logger.js';
9
+ import { addressToChatKey } from '../db/address.js';
9
10
  import { enqueueBrowserJob } from './browser-queue.js';
10
11
  import { getInternalCronHandler } from './cron-handlers.js';
11
12
  import { enqueueInbound } from './inbound.js';
@@ -43,7 +44,8 @@ export function dispatchCron(row) {
43
44
  return;
44
45
  }
45
46
  enqueueAsyncTask({
46
- jid: payload.address.replace(/^wa:(dm|group):/, ''), // strip prefix back to raw jid
47
+ jid: addressToChatKey(payload.address),
48
+ address: payload.address,
47
49
  senderNumber: payload.senderNumber,
48
50
  description: payload.description,
49
51
  originatingMessage: `[cron:${row.name}]`,
@@ -85,18 +87,15 @@ function dispatchInboundPrompt(row, payload) {
85
87
  logger.error({ id: row.id, payload }, 'cron prompt payload malformed');
86
88
  return;
87
89
  }
88
- // Address parsing: payload.address is the chat address (formatted),
89
- // so we extract the jid form for the synthesized Job.
90
- // For wa:dm:1234@s.whatsapp.net → jid is the part after the second :
91
- const jidMatch = /^wa:(?:dm|group):(.+)$/.exec(payload.address);
92
- const rawJid = jidMatch ? jidMatch[1] : payload.address;
90
+ const chatKey = addressToChatKey(payload.address);
93
91
  const now = Math.floor(Date.now() / 1000);
94
92
  // Minimal Job — the chat worker calling processJob will recompute
95
93
  // memoryPreamble / recentContext via the existing buildMemoryPreamble
96
94
  // path. We just provide the user-facing text + the cronId for
97
95
  // cost attribution.
98
96
  const job = {
99
- jid: rawJid,
97
+ jid: chatKey,
98
+ address: payload.address,
100
99
  text: payload.prompt,
101
100
  input: payload.prompt, // chat worker will wrap with memory preamble
102
101
  senderNumber: payload.senderNumber ?? 'system',
@@ -0,0 +1,4 @@
1
+ import { formatAddress, jidToAddress } from '../db/address.js';
2
+ export function addressForJob(job) {
3
+ return job.address ?? formatAddress(jidToAddress(job.jid));
4
+ }
@@ -11,7 +11,7 @@
11
11
  import { existsSync, unlinkSync } from 'fs';
12
12
  import { isAbsolute, resolve } from 'path';
13
13
  import { config } from '../config.js';
14
- import { addressToExternalId, parseAddress } from '../db/address.js';
14
+ import { addressToChatKey, addressToExternalId, parseAddress } from '../db/address.js';
15
15
  import { logger } from '../logger.js';
16
16
  import { append } from '../store/messages.js';
17
17
  export async function afterSend(row, sentMsgId) {
@@ -26,12 +26,9 @@ async function persistToMessageLog(row, msgId) {
26
26
  catch {
27
27
  return;
28
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);
29
+ const jid = address.channel === 'wa'
30
+ ? addressToExternalId(address)
31
+ : addressToChatKey(address);
35
32
  const messageType = row.kind === 'text' ? 'conversation' : `${row.kind}Message`;
36
33
  try {
37
34
  await append({
@@ -1,7 +1,6 @@
1
1
  import { getProvider } from '../ai/providers.js';
2
2
  import { clearSession, getSessionInfo, setSession, setUsage, } from '../ai/sessions.js';
3
3
  import { config } from '../config.js';
4
- import { formatAddress, jidToAddress } from '../db/address.js';
5
4
  import { logger } from '../logger.js';
6
5
  import { addDailyTokens } from '../store/usage.js';
7
6
  import { getTimezoneForSenderNumber } from '../db/identity-sync.js';
@@ -13,11 +12,16 @@ import { addCronUsage, enqueueCron } from './crons.js';
13
12
  import { compressThread, coolThread, createThread, dropThread, resolveThread, touchThread, updateThread, } from './threads.js';
14
13
  import { setCategoryWeight } from './thread-weights.js';
15
14
  import { enqueueMemoryWrite } from './memory-writes.js';
15
+ import { addressForJob } from './job-address.js';
16
16
  import { enqueueOutbound } from './outbound.js';
17
17
  import { formatLocalTime, resolveTimeExpression } from './time-expr.js';
18
18
  function isStaleSessionError(err) {
19
- return (err instanceof Error &&
20
- err.message.includes('No conversation found'));
19
+ if (!(err instanceof Error))
20
+ return false;
21
+ const msg = err.message.toLowerCase();
22
+ return (msg.includes('no conversation found') ||
23
+ msg.includes('session not found') ||
24
+ msg.includes('no session found'));
21
25
  }
22
26
  async function callClaude(job) {
23
27
  const startedAt = Date.now();
@@ -205,6 +209,7 @@ async function callClaude(job) {
205
209
  const t = asyncTasks[i];
206
210
  enqueueAsyncTask({
207
211
  jid: job.jid,
212
+ address: job.address,
208
213
  senderNumber: job.senderNumber,
209
214
  description: t.description,
210
215
  originatingMessage: job.text,
@@ -225,6 +230,7 @@ async function callClaude(job) {
225
230
  const t = asyncBrowserTasks[i];
226
231
  enqueueBrowserTask({
227
232
  jid: job.jid,
233
+ address: job.address,
228
234
  senderNumber: job.senderNumber,
229
235
  description: t.description,
230
236
  originatingMessage: job.text,
@@ -259,7 +265,7 @@ async function callClaude(job) {
259
265
  // originating chat (job.jid) is the destination for both. Sender's
260
266
  // timezone drives "9am" / "today at..." resolution so the schedule
261
267
  // lands in their wall-clock time, not the server's.
262
- const chatAddress = formatAddress(jidToAddress(job.jid));
268
+ const chatAddress = addressForJob(job);
263
269
  const senderTz = getTimezoneForSenderNumber(job.senderNumber);
264
270
  const nowSec = Math.floor(Date.now() / 1000);
265
271
  const cronBase = `chat-cron-${job.jid}-${Date.now()}`;
@@ -450,7 +456,7 @@ async function callClaude(job) {
450
456
  outputTokens: turnOutput,
451
457
  cacheReadTokens: turnCacheRead,
452
458
  totalContextTokens,
453
- contextWindow: config.claude.contextWindow,
459
+ contextWindow: provider.contextWindow,
454
460
  fresh: wasFresh,
455
461
  hasDigest: digest !== null,
456
462
  journalSlugs: journals.map((j) => j.slug),
@@ -3,6 +3,7 @@ import { resolve } from 'path';
3
3
  import { jidDecode } from 'baileys';
4
4
  import { z } from 'zod';
5
5
  import { config } from '../config.js';
6
+ import { actorKeyFromAddress, parseAddress } from '../db/address.js';
6
7
  import { logger } from '../logger.js';
7
8
  const AccessModeSchema = z.enum(['off', 'silent', 'active']);
8
9
  const RoleNameSchema = z.enum(['admin', 'user', 'guest']);
@@ -163,12 +164,21 @@ export function getAccess() {
163
164
  // consents to the bot nudging them in their own DM. Other DMs and groups
164
165
  // require an explicit `proactive: true` entry in access.json.
165
166
  export function canSendProactive(jid) {
166
- const isGroup = jid.endsWith('@g.us');
167
+ let parsedAddress = null;
168
+ try {
169
+ parsedAddress = parseAddress(jid);
170
+ }
171
+ catch {
172
+ parsedAddress = null;
173
+ }
174
+ const isGroup = parsedAddress ? parsedAddress.scope === 'group' : jid.endsWith('@g.us');
167
175
  if (isGroup) {
168
176
  const entry = current.groups.find((g) => g.jid === jid);
169
177
  return entry?.proactive === true;
170
178
  }
171
- const number = jidDecode(jid)?.user;
179
+ const number = parsedAddress
180
+ ? actorKeyFromAddress(parsedAddress)
181
+ : jidDecode(jid)?.user;
172
182
  if (!number)
173
183
  return false;
174
184
  // Owner's self-DM is always allowed.
@@ -254,10 +264,10 @@ const storeAndRespond = (reason) => ({
254
264
  reason,
255
265
  });
256
266
  export function checkAccess(params) {
257
- const { jid, isGroup, senderNumber, fromMe } = params;
267
+ const { jid, address, isGroup, senderNumber, fromMe } = params;
258
268
  const ownerAllowed = fromMe && config.owner.treatAsAllowedEverywhere;
259
269
  if (isGroup) {
260
- const group = current.groups.find((g) => g.jid === jid);
270
+ const group = current.groups.find((g) => g.jid === jid || (address && g.jid === address));
261
271
  if (!group)
262
272
  return DROP;
263
273
  if (group.mode === 'off')
@@ -273,7 +283,15 @@ export function checkAccess(params) {
273
283
  }
274
284
  return storeOnly('group sender not in allowedSenders');
275
285
  }
276
- const partnerNumber = jidDecode(jid)?.user ?? '';
286
+ let partnerNumber = jidDecode(jid)?.user ?? '';
287
+ if (!partnerNumber && address) {
288
+ try {
289
+ partnerNumber = actorKeyFromAddress(address);
290
+ }
291
+ catch {
292
+ partnerNumber = '';
293
+ }
294
+ }
277
295
  // Self-chat: owner messaging themselves — respond like a direct conversation with the bot
278
296
  const isSelfChat = fromMe && partnerNumber === config.owner.number;
279
297
  if (fromMe && !isSelfChat)
@@ -286,6 +304,23 @@ export function checkAccess(params) {
286
304
  return storeOnly('dm silent');
287
305
  return storeAndRespond('dm active');
288
306
  }
307
+ export async function discoverAddressGroupIfNew(params) {
308
+ const parsed = parseAddress(params.address);
309
+ if (parsed.scope !== 'group')
310
+ return false;
311
+ if (current.groups.some((g) => g.jid === params.address))
312
+ return false;
313
+ const entry = {
314
+ jid: params.address,
315
+ name: params.name || 'Unknown group',
316
+ mode: 'off',
317
+ allowedSenders: params.ownerSender ? [params.ownerSender] : [],
318
+ proactive: false,
319
+ };
320
+ save({ ...current, groups: [...current.groups, entry] });
321
+ logger.info({ address: params.address, name: entry.name }, 'discovered new channel group — added to access.json with mode=off');
322
+ return true;
323
+ }
289
324
  export async function discoverGroupIfNew(sock, jid) {
290
325
  if (!jid.endsWith('@g.us'))
291
326
  return false;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.10.1",
4
- "description": "WhatsApp AI bot powered by Claude with long-term memory, browser control, and role-based access",
3
+ "version": "0.10.2",
4
+ "description": "WhatsApp and Telegram AI bot powered by Claude, Codex, or Grok with long-term memory, browser control, and role-based access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "keywords": [
32
32
  "whatsapp",
33
+ "telegram",
33
34
  "chatbot",
34
35
  "claude",
35
36
  "ai",