talon-agent 3.29.0 → 3.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.29.0",
3
+ "version": "3.30.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -41,14 +41,31 @@ export function trackDmUser(
41
41
 
42
42
  export function setAccessControl(cfg: {
43
43
  allowedUsers?: number[];
44
+ blockedUsers?: number[];
44
45
  adminUserId?: number;
45
46
  }): void {
46
47
  accessConfig.allowedUserIds = cfg.allowedUsers?.length
47
48
  ? new Set(cfg.allowedUsers)
48
49
  : null;
50
+ accessConfig.blockedUserIds = cfg.blockedUsers?.length
51
+ ? new Set(cfg.blockedUsers)
52
+ : null;
49
53
  accessConfig.adminId = cfg.adminUserId ?? 0;
50
54
  }
51
55
 
56
+ /**
57
+ * Blocked senders are dropped before any other check, in silence.
58
+ *
59
+ * The whitelist already stops an unknown sender being *acted on*, but it still
60
+ * answers them with a warning and pings the admin. For a spammer or a
61
+ * prompt-injection sender that reply is the payoff — it confirms a live bot is
62
+ * reading — and the admin ping is the cost. Blocking removes both.
63
+ */
64
+ function isBlocked(senderId: number | undefined): boolean {
65
+ if (!accessConfig.blockedUserIds || senderId === undefined) return false;
66
+ return accessConfig.blockedUserIds.has(senderId);
67
+ }
68
+
52
69
  /**
53
70
  * Check if a DM user is allowed. Returns true if no whitelist is set.
54
71
  */
@@ -114,15 +131,24 @@ export function shouldHandleInGroup(ctx: Context): boolean {
114
131
  }
115
132
 
116
133
  /**
117
- * Full access check: DM whitelist + group admin membership.
134
+ * Full access check: denylist, then DM whitelist + group admin membership.
118
135
  * Returns true if the message should be processed.
119
- * Warns unauthorized users and notifies the admin.
136
+ * Warns unauthorized users and notifies the admin — except blocked users,
137
+ * who are dropped silently.
120
138
  */
121
139
  export async function isAccessAllowed(
122
140
  ctx: Context,
123
141
  bot: Bot,
124
142
  ): Promise<boolean> {
125
143
  if (!ctx.chat) return false;
144
+
145
+ // Denylist wins over everything, including the group path: a blocked user
146
+ // gets no reply and generates no notification, anywhere.
147
+ if (isBlocked(ctx.from?.id)) {
148
+ log("users", `Dropped message from blocked user [id:${ctx.from?.id}]`);
149
+ return false;
150
+ }
151
+
126
152
  const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
127
153
 
128
154
  if (!isGroup) {
@@ -20,9 +20,11 @@ export const KNOWN_DM_USERS_CAP = 10_000;
20
20
  /** Reassignable access config — holder object so setAccessControl can mutate. */
21
21
  export const accessConfig: {
22
22
  allowedUserIds: Set<number> | null; // null = no whitelist (allow all)
23
+ blockedUserIds: Set<number> | null; // null = no denylist
23
24
  adminId: number;
24
25
  } = {
25
26
  allowedUserIds: null,
27
+ blockedUserIds: null,
26
28
  adminId: 0,
27
29
  };
28
30
 
@@ -87,6 +87,7 @@ export function createTelegramFrontend(
87
87
  setAdminUserId(config.adminUserId);
88
88
  setAccessControl({
89
89
  allowedUsers: config.allowedUsers,
90
+ blockedUsers: config.blockedUsers,
90
91
  adminUserId: config.adminUserId,
91
92
  });
92
93
 
@@ -53,7 +53,7 @@ import {
53
53
  seedMessageStore,
54
54
  } from "./message-store.js";
55
55
  import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
56
- import { classifyClose, REPLACED_BACKOFF_MS } from "./pairing.js";
56
+ import { classifyClose, isPaired, REPLACED_BACKOFF_MS } from "./pairing.js";
57
57
  import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
58
58
  import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
59
59
  import { makeWaLogger } from "./wa-logger.js";
@@ -527,11 +527,15 @@ export function createWhatsAppFrontend(
527
527
  case "logged-out":
528
528
  return resolve("logged-out");
529
529
  default:
530
- // A socket that died without ever registering is a pairing
531
- // window that expired — reconnecting would just open QR
530
+ // A socket that died without ever completing a login is a
531
+ // pairing window that expired — reconnecting would just open QR
532
532
  // session after QR session against WhatsApp's servers.
533
533
  // Park instead; /whatsapp pair opens the next one.
534
- if (!state.creds.registered) return resolve("unpaired");
534
+ //
535
+ // Test with isPaired, not creds.registered: a QR-linked session
536
+ // never sets that flag, so the flag alone parks a healthy
537
+ // session on its first ordinary disconnect.
538
+ if (!isPaired(state.creds)) return resolve("unpaired");
535
539
  log(
536
540
  "whatsapp",
537
541
  `Connection closed (code ${code ?? "?"}) — reconnecting`,
@@ -556,7 +560,17 @@ export function createWhatsAppFrontend(
556
560
  resolvePath(dirs.whatsappAuth, "creds.json"),
557
561
  "utf-8",
558
562
  );
559
- if ((JSON.parse(raw) as { registered?: boolean }).registered) return;
563
+ // Same trap as above: waiting on `registered` alone means a QR
564
+ // re-link can never end the park, because that flag stays false.
565
+ if (
566
+ isPaired(
567
+ JSON.parse(raw) as {
568
+ registered?: boolean;
569
+ me?: { id?: string } | null;
570
+ },
571
+ )
572
+ )
573
+ return;
560
574
  } catch {
561
575
  /* no creds yet — stay parked */
562
576
  }
@@ -41,5 +41,24 @@ export function classifyClose(
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * Has this auth state ever completed a login?
46
+ *
47
+ * `creds.registered` looks like the obvious test and is a trap: Baileys only
48
+ * sets it on the pairing-CODE path. A session linked by scanning the QR stays
49
+ * `registered: false` forever while being completely authenticated — it has
50
+ * `me`, `account`, app-state keys, and it sends and receives normally.
51
+ *
52
+ * Reading the flag alone therefore cannot tell a live QR session apart from a
53
+ * pairing window that expired without anyone scanning it, which is the
54
+ * distinction the reconnect loop actually needs.
55
+ */
56
+ export function isPaired(creds: {
57
+ registered?: boolean;
58
+ me?: { id?: string } | null;
59
+ }): boolean {
60
+ return Boolean(creds.registered || creds.me?.id);
61
+ }
62
+
44
63
  /** How long to sit out after a 440 — another client owns the session. */
45
64
  export const REPLACED_BACKOFF_MS = 60_000;
@@ -395,6 +395,10 @@ const configSchema = z.object({
395
395
  apiHash: z.string().optional(),
396
396
  adminUserId: z.number().int().optional(),
397
397
  allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
398
+ // Denylist of user IDs dropped in silence — no warning reply, no admin
399
+ // notification. For spam and prompt-injection senders, where the warning
400
+ // itself is the reward: it confirms a live bot is reading.
401
+ blockedUsers: z.array(z.number().int()).optional(),
398
402
  pulse: z.boolean().default(true),
399
403
  pulseIntervalMs: z.number().int().min(60000).default(300000),
400
404
  /**