linkgravity 1.5.3 → 1.5.5

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/bin/cli.js CHANGED
@@ -50,6 +50,7 @@ function runPm2(args, silent = true) {
50
50
  }
51
51
 
52
52
  let hasSudoInstructions = false;
53
+ let sudoCommand = null;
53
54
  if (silent && result.stdout && (args[0] === 'startup' || args[0] === 'unstartup')) {
54
55
  const out = result.stdout.toString();
55
56
  const lines = out.split('\n');
@@ -59,11 +60,8 @@ function runPm2(args, silent = true) {
59
60
  line.trim().startsWith('sudo su -c') ||
60
61
  line.includes('sudo ')
61
62
  ) {
62
- console.log(
63
- `\n\n${color.yellow}⚠ Action Required:${color.reset} To complete setup, copy and paste this command into your terminal:\n`,
64
- );
65
- console.log(` ${color.cyan}${line.trim()}${color.reset}\n`);
66
63
  hasSudoInstructions = true;
64
+ sudoCommand = line.trim();
67
65
  }
68
66
  }
69
67
  }
@@ -74,6 +72,27 @@ function runPm2(args, silent = true) {
74
72
  }
75
73
  process.exit(result.status);
76
74
  }
75
+
76
+ return { hasSudoInstructions, sudoCommand };
77
+ }
78
+
79
+ function runSudoStepThen(sudoCommand, successMessage) {
80
+ // stdin stays inherited so sudo can still prompt for a password on the real terminal;
81
+ // stdout/stderr are captured so pm2's own noise only surfaces if this actually fails.
82
+ const result = spawnSync('sh', ['-c', sudoCommand], { stdio: ['inherit', 'pipe', 'pipe'] });
83
+ if (result.status === 0) {
84
+ success(`${successMessage}\n`);
85
+ } else {
86
+ console.log(`${color.yellow}⚠${color.reset} That didn't complete:\n`);
87
+ const output = [
88
+ (result.stdout || '').toString().trim(),
89
+ (result.stderr || '').toString().trim(),
90
+ ]
91
+ .filter(Boolean)
92
+ .join('\n');
93
+ if (output) console.log(output);
94
+ console.log(`\nRun \`lgy enable\` again to retry.\n`);
95
+ }
77
96
  }
78
97
 
79
98
  // Matches a leading timestamp from either loguru or aiohttp's access-log format; only strips the first bracket group so aiohttp's second "[INFO ]" bracket is left alone.
@@ -223,11 +242,18 @@ function formatUptime(pmUptimeMs) {
223
242
  return `${days}d ${hours % 24}h`;
224
243
  }
225
244
 
245
+ function visibleLength(s) {
246
+ return String(s).replace(ANSI_ESCAPE, '').length;
247
+ }
248
+
226
249
  function renderTable(headers, rows) {
227
250
  const widths = headers.map((h, i) =>
228
- Math.max(h.length, ...rows.map((r) => String(r[i]).length)),
251
+ Math.max(h.length, ...rows.map((r) => visibleLength(r[i]))),
229
252
  );
230
- const pad = (s, w) => ` ${String(s).padEnd(w)} `;
253
+ const pad = (s, w) => {
254
+ const str = String(s);
255
+ return ` ${str}${' '.repeat(Math.max(0, w - visibleLength(str)))} `;
256
+ };
231
257
  const sepLine = (l, m, r) => l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r;
232
258
  const rowLine = (cells) => '│' + cells.map((c, i) => pad(c, widths[i])).join('│') + '│';
233
259
 
@@ -237,7 +263,7 @@ function renderTable(headers, rows) {
237
263
  return lines.join('\n');
238
264
  }
239
265
 
240
- // Mirrors src/config.py's AGY_BIN resolution (AGY_BIN_PATH env var, else ~/.local/bin/agy), plus a PATH fallback for installs that don't use the default location.
266
+ // Must stay in sync with config.py's AGY_BIN resolution.
241
267
  function findAgyBin() {
242
268
  const envPath = process.env.AGY_BIN_PATH;
243
269
  if (envPath && fs.existsSync(envPath)) return envPath;
@@ -256,12 +282,61 @@ function findAgyBin() {
256
282
  function getPm2Proc() {
257
283
  const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
258
284
  if (jlist.status !== 0) return null;
285
+
286
+ // pm2 can print noise before the real JSON (version banners, ANSI escapes, daemon-spawn logs) that
287
+ // can itself contain '[' - try every '[' left-to-right and keep the first one that parses as JSON.
288
+ const out = jlist.stdout.toString().replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
289
+ for (let i = 0; i < out.length; i++) {
290
+ if (out[i] !== '[') continue;
291
+ try {
292
+ const procs = JSON.parse(out.slice(i));
293
+ if (Array.isArray(procs)) return procs.find((p) => p.name === LGY_PM2_NAME) || null;
294
+ } catch (e) {}
295
+ }
296
+
297
+ console.error(
298
+ `${color.yellow}⚠${color.reset} Couldn't read pm2 status (no valid JSON found in its output). Raw output:\n${out.trim()}`,
299
+ );
300
+ return null;
301
+ }
302
+
303
+ // Best-effort: pm2 has no API for "is this registered to start on boot", so this checks the OS directly and returns null (unknown) if that check itself isn't available.
304
+ function isAutostartEnabled() {
305
+ if (isWin) return null;
259
306
  try {
260
- const procs = JSON.parse(jlist.stdout.toString());
261
- return procs.find((p) => p.name === LGY_PM2_NAME) || null;
307
+ const user = os.userInfo().username;
308
+ if (process.platform === 'linux') {
309
+ const check = spawnSync('systemctl', ['is-enabled', `pm2-${user}`], { stdio: 'pipe' });
310
+ if (check.error) return null;
311
+ const out = check.stdout.toString().trim();
312
+ if (out === 'enabled') return true;
313
+ if (out === 'disabled' || check.status !== 0) return false;
314
+ return null;
315
+ }
316
+ if (process.platform === 'darwin') {
317
+ const plistPath = path.join(
318
+ os.homedir(),
319
+ 'Library',
320
+ 'LaunchAgents',
321
+ `pm2.${user}.plist`,
322
+ );
323
+ return fs.existsSync(plistPath);
324
+ }
262
325
  } catch (e) {
263
326
  return null;
264
327
  }
328
+ return null;
329
+ }
330
+
331
+ function checkLatestVersionFast(currentVersion) {
332
+ const view = spawnSync('npm', ['view', 'linkgravity', 'version'], {
333
+ stdio: 'pipe',
334
+ timeout: 3000,
335
+ });
336
+ if (view.error || view.status !== 0) return null;
337
+ const latest = view.stdout.toString().trim();
338
+ if (!latest) return null;
339
+ return { latest, upToDate: latest === currentVersion };
265
340
  }
266
341
 
267
342
  if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
@@ -390,6 +465,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
390
465
  }
391
466
  }
392
467
 
468
+ const daemonAlive = !!proc && proc.pm2_env.status === 'online';
469
+
393
470
  const rows = Object.entries(PLATFORMS).map(([key, def]) => {
394
471
  const { enabled } = platformState(key, settings);
395
472
  const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
@@ -397,17 +474,54 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
397
474
  let connection = '-';
398
475
  let since = '-';
399
476
  if (enabled) {
400
- if (!h) connection = 'unknown';
401
- else if (h.status === 'running') connection = 'connected';
402
- else if (h.status === 'connecting') connection = 'connecting...';
403
- else if (h.status === 'error') connection = `error: ${h.detail || '?'}`;
404
- else if (h.status === 'stopped') connection = 'stopped';
405
- if (h && h.at) since = formatUptime(new Date(h.at).getTime());
477
+ if (!daemonAlive) {
478
+ // health.json freezes at its last value if the process was killed outright (kill -9, OOM, reboot) instead of exiting cleanly, so don't trust it once pm2 confirms the daemon isn't actually running.
479
+ connection = `${color.red}down${color.reset}`;
480
+ since = h && h.at ? `last seen ${formatUptime(new Date(h.at).getTime())} ago` : '-';
481
+ } else if (!h) {
482
+ connection = 'unknown';
483
+ } else {
484
+ if (h.status === 'running') connection = `${color.green}connected${color.reset}`;
485
+ else if (h.status === 'connecting') connection = 'connecting...';
486
+ else if (h.status === 'error')
487
+ connection = `${color.red}error: ${h.detail || '?'}${color.reset}`;
488
+ else if (h.status === 'stopped') connection = 'stopped';
489
+ if (h.at) since = formatUptime(new Date(h.at).getTime());
490
+ }
406
491
  }
407
492
  return [def.label, enabled ? 'yes' : 'no', connection, since, String(sessionCount)];
408
493
  });
409
494
  console.log(renderTable(['platform', 'enabled', 'connection', 'since', 'sessions'], rows));
410
495
  console.log();
496
+
497
+ const pkg = require('../package.json');
498
+ const versionCheck = checkLatestVersionFast(pkg.version);
499
+ const versionLine =
500
+ versionCheck === null
501
+ ? pkg.version
502
+ : versionCheck.upToDate
503
+ ? `${pkg.version} (up to date)`
504
+ : `${pkg.version} ${color.yellow}(v${versionCheck.latest} available - run \`lgy update\`)${color.reset}`;
505
+
506
+ const agyPath = findAgyBin();
507
+ const agyLine = agyPath ? `found (${agyPath})` : `${color.yellow}not found${color.reset}`;
508
+
509
+ const autostart = isAutostartEnabled();
510
+ const autostartLine =
511
+ autostart === null
512
+ ? 'unknown'
513
+ : autostart
514
+ ? `${color.green}enabled${color.reset}`
515
+ : 'disabled';
516
+
517
+ for (const [label, value] of [
518
+ ['version', versionLine],
519
+ ['agy', agyLine],
520
+ ['autostart', autostartLine],
521
+ ]) {
522
+ console.log(`${label.padEnd(10)} ${value}`);
523
+ }
524
+ console.log();
411
525
  } else if (cmd === 'enable') {
412
526
  if (isWin) {
413
527
  console.log(
@@ -417,9 +531,13 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
417
531
  process.exit(1);
418
532
  }
419
533
  info('Registering LinkGravity to start on system boot...');
420
- runPm2(['startup']);
534
+ const { hasSudoInstructions, sudoCommand } = runPm2(['startup']);
421
535
  runPm2(['save']);
422
- success('Auto-start configuration saved.\n');
536
+ if (hasSudoInstructions && sudoCommand) {
537
+ runSudoStepThen(sudoCommand, 'Auto-start configuration saved.');
538
+ } else {
539
+ success('Auto-start configuration saved.\n');
540
+ }
423
541
  } else if (cmd === 'disable') {
424
542
  if (isWin) {
425
543
  console.log(
@@ -429,9 +547,13 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
429
547
  process.exit(1);
430
548
  }
431
549
  info('Removing LinkGravity from system boot...');
432
- runPm2(['unstartup']);
550
+ const { hasSudoInstructions, sudoCommand } = runPm2(['unstartup']);
433
551
  runPm2(['save']);
434
- success('Auto-start configuration removed.\n');
552
+ if (hasSudoInstructions && sudoCommand) {
553
+ runSudoStepThen(sudoCommand, 'Auto-start configuration removed.');
554
+ } else {
555
+ success('Auto-start configuration removed.\n');
556
+ }
435
557
  } else if (cmd === 'setup' || cmd === 'init') {
436
558
  const runSetup = require('./setup');
437
559
  runSetup().catch((err) => {
package/bin/setup.js CHANGED
@@ -33,25 +33,39 @@ async function collectSessionScopes(existingScopes) {
33
33
  'Server / Channel Access',
34
34
  );
35
35
 
36
+ if (hasExisting) {
37
+ const summary = existingScopes
38
+ .map(
39
+ (s) =>
40
+ `${s.guild_id}${s.channel_ids.length ? ` (channels: ${s.channel_ids.join(', ')})` : ' (whole server)'}`,
41
+ )
42
+ .join('; ');
43
+ p.note(`Current server/channel settings: ${summary}`, 'Current Setting');
44
+
45
+ const change = await p.confirm({
46
+ message: 'Change the server/channel access list?',
47
+ initialValue: false,
48
+ });
49
+ if (p.isCancel(change)) {
50
+ p.cancel('Setup cancelled.');
51
+ process.exit(0);
52
+ }
53
+ if (!change) return null; // keep existing config untouched
54
+ }
55
+
36
56
  let isFirst = true;
37
57
  while (true) {
38
- const promptSuffix =
39
- isFirst && hasExisting
40
- ? ' (leave empty to keep your current server/channel settings entirely unchanged)'
41
- : ' (leave empty if you have no more servers to add)';
42
-
43
58
  const guildId = await p.text({
44
- message: `Server (Guild) ID to allow${promptSuffix}. Right-click the SERVER NAME (not a channel) → Copy Server ID:`,
59
+ message: isFirst
60
+ ? 'Server (Guild) ID to allow. Right-click the SERVER NAME (not a channel) → Copy Server ID ' +
61
+ '(leave empty if done - an empty list means /new works nowhere):'
62
+ : 'Another server ID to add (leave empty if done):',
45
63
  });
46
64
  if (p.isCancel(guildId)) {
47
65
  p.cancel('Setup cancelled.');
48
66
  process.exit(0);
49
67
  }
50
-
51
- if (!guildId) {
52
- if (isFirst) return null; // signal: user wants to keep existing config untouched
53
- break;
54
- }
68
+ if (!guildId) break;
55
69
  isFirst = false;
56
70
 
57
71
  const channelIds = [];
@@ -103,30 +117,41 @@ async function collectUserIds(existingIds, platformLabel) {
103
117
  const hasExisting = existingIds && existingIds.length > 0;
104
118
 
105
119
  p.note(
106
- 'ONLY these users can use the bot (leave completely empty on first setup to allow EVERYONE). ' +
120
+ 'ONLY these users can use the bot (leave completely empty to allow EVERYONE). ' +
107
121
  'Not related to DMs - this only gates the channel/threads configured above.',
108
122
  `Allowed ${platformLabel} Users`,
109
123
  );
110
124
 
125
+ if (hasExisting) {
126
+ p.note(
127
+ `Current allowed ${platformLabel} users: ${existingIds.join(', ')}`,
128
+ 'Current Setting',
129
+ );
130
+
131
+ const change = await p.confirm({
132
+ message: `Change the allowed-user list for ${platformLabel}?`,
133
+ initialValue: false,
134
+ });
135
+ if (p.isCancel(change)) {
136
+ p.cancel('Setup cancelled.');
137
+ process.exit(0);
138
+ }
139
+ if (!change) return null; // keep existing config untouched
140
+ }
141
+
111
142
  let isFirst = true;
112
143
  while (true) {
113
- const promptSuffix =
114
- isFirst && hasExisting
115
- ? ' (leave empty to keep your current allowed-user settings entirely unchanged)'
116
- : ' (leave empty if you have no more users to add)';
117
-
118
144
  const userId = await p.text({
119
- message: `${platformLabel} User ID to allow${promptSuffix}:`,
145
+ message: isFirst
146
+ ? `${platformLabel} User ID to allow (leave empty to allow EVERYONE):`
147
+ : `Another ${platformLabel} user ID to allow (leave empty if done):`,
120
148
  });
121
149
  if (p.isCancel(userId)) {
122
150
  p.cancel('Setup cancelled.');
123
151
  process.exit(0);
124
152
  }
125
153
 
126
- if (!userId) {
127
- if (isFirst) return null; // signal: keep existing config untouched
128
- break;
129
- }
154
+ if (!userId) break;
130
155
  isFirst = false;
131
156
 
132
157
  ids.push(userId.trim());
@@ -390,13 +415,12 @@ async function platformMenu(key) {
390
415
  );
391
416
  if (configured)
392
417
  options.push({ value: 'edit', label: 'Edit settings (token, access, etc.)' });
393
- options.push({ value: 'back', label: '← Back' });
394
418
 
395
419
  const action = await p.select({
396
- message: `${def.label} — currently ${enabled ? 'ON' : 'OFF'}${configured ? '' : ' (not configured)'}`,
420
+ message: `${def.label} — currently ${enabled ? 'ON' : 'OFF'}${configured ? '' : ' (not configured)'} (Esc to go back)`,
397
421
  options,
398
422
  });
399
- if (p.isCancel(action) || action === 'back') return;
423
+ if (p.isCancel(action)) return;
400
424
 
401
425
  if (action === 'off') {
402
426
  updateSettings({ [`${key}_enabled`]: false });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
@@ -92,7 +92,7 @@ class VoiceCog(commands.Cog):
92
92
  opts.append(app_commands.Choice(name=str(v), value=v))
93
93
  return opts
94
94
 
95
- async def threshold_autocomplete(
95
+ async def interrupt_threshold_autocomplete(
96
96
  self, interaction: discord.Interaction, current: str
97
97
  ) -> list[app_commands.Choice[int]]:
98
98
  current_val = int(self.bot_settings.get("voice_threshold", 3000))
@@ -105,6 +105,19 @@ class VoiceCog(commands.Cog):
105
105
  opts.append(app_commands.Choice(name=str(v), value=v))
106
106
  return opts
107
107
 
108
+ async def wake_sensitivity_autocomplete(
109
+ self, interaction: discord.Interaction, current: str
110
+ ) -> list[app_commands.Choice[float]]:
111
+ current_val = float(self.bot_settings.get("wake_threshold", 0.4))
112
+ opts = []
113
+ if str(current_val) in current or not current:
114
+ opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
115
+
116
+ for v in [0.2, 0.3, 0.4, 0.5, 0.6]:
117
+ if v != current_val and len(opts) < 25:
118
+ opts.append(app_commands.Choice(name=str(v), value=v))
119
+ return opts
120
+
108
121
  async def tts_voice_autocomplete(
109
122
  self, interaction: discord.Interaction, current: str
110
123
  ) -> list[app_commands.Choice[str]]:
@@ -282,12 +295,13 @@ class VoiceCog(commands.Cog):
282
295
 
283
296
  @app_commands.command(
284
297
  name="sound",
285
- description="Configure voice settings (Wake word, active time, threshold, TTS voice/speed, TTS on/off)",
298
+ description="Configure voice settings (Wake word, active time, thresholds, TTS voice/speed, TTS on/off)",
286
299
  )
287
300
  @app_commands.describe(
288
301
  wake_word="The single word/phrase that wakes the bot (recorded in your voice)",
289
302
  active_times="Duration in seconds the bot stays awake",
290
- threshold="Voice volume sensitivity (1000~10000)",
303
+ interrupt_threshold="Mic volume that interrupts (barges into) TTS playback (1000~10000)",
304
+ wake_sensitivity="Wake word match sensitivity (0.1~0.9, lower = easier to trigger but more false wakes)",
291
305
  tts_voice="Select the AI TTS voice",
292
306
  tts_enabled="Turn Text-to-Speech ON or OFF",
293
307
  tts_speed="TTS playback speed multiplier, e.g. 1.3 for 1.3x (0.5~2.0)",
@@ -295,7 +309,8 @@ class VoiceCog(commands.Cog):
295
309
  )
296
310
  @app_commands.autocomplete(
297
311
  active_times=active_times_autocomplete,
298
- threshold=threshold_autocomplete,
312
+ interrupt_threshold=interrupt_threshold_autocomplete,
313
+ wake_sensitivity=wake_sensitivity_autocomplete,
299
314
  tts_voice=tts_voice_autocomplete,
300
315
  tts_enabled=tts_enabled_autocomplete,
301
316
  tts_speed=tts_speed_autocomplete,
@@ -306,7 +321,8 @@ class VoiceCog(commands.Cog):
306
321
  interaction: discord.Interaction,
307
322
  wake_word: str = None,
308
323
  active_times: int = None,
309
- threshold: int = None,
324
+ interrupt_threshold: int = None,
325
+ wake_sensitivity: float = None,
310
326
  tts_voice: str = None,
311
327
  tts_enabled: str = None,
312
328
  tts_speed: float = None,
@@ -321,7 +337,8 @@ class VoiceCog(commands.Cog):
321
337
  if (
322
338
  wake_word is None
323
339
  and active_times is None
324
- and threshold is None
340
+ and interrupt_threshold is None
341
+ and wake_sensitivity is None
325
342
  and tts_voice is None
326
343
  and tts_enabled is None
327
344
  and tts_speed is None
@@ -329,7 +346,8 @@ class VoiceCog(commands.Cog):
329
346
  ):
330
347
  curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
331
348
  curr_timer = self.bot_settings.get("active_timer", 60)
332
- curr_thresh = self.bot_settings.get("voice_threshold", 3000)
349
+ curr_interrupt_thresh = self.bot_settings.get("voice_threshold", 3000)
350
+ curr_wake_sens = self.bot_settings.get("wake_threshold", 0.4)
333
351
  curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
334
352
  curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
335
353
  curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
@@ -339,7 +357,8 @@ class VoiceCog(commands.Cog):
339
357
  embed.add_field(name="🎙️ Wake Word", value=f"`{curr_wake}`", inline=False)
340
358
  embed.add_field(name="🔒 Wake Word Required", value=f"`{'ON' if curr_required else 'OFF'}`", inline=False)
341
359
  embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
342
- embed.add_field(name="🔊 Threshold", value=f"`{curr_thresh}`", inline=False)
360
+ embed.add_field(name="🎯 Wake Sensitivity", value=f"`{curr_wake_sens}`", inline=False)
361
+ embed.add_field(name="🔊 Interrupt Threshold", value=f"`{curr_interrupt_thresh}`", inline=False)
343
362
  embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
344
363
  embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
345
364
  embed.add_field(name="⏩ TTS Speed", value=f"`{curr_tts_speed}x`", inline=False)
@@ -353,12 +372,25 @@ class VoiceCog(commands.Cog):
353
372
  if active_times is not None:
354
373
  self.bot_settings["active_timer"] = active_times
355
374
  updated.append(f"⏱️ Active Timer: `{active_times}s`")
356
- if threshold is not None:
357
- self.bot_settings["voice_threshold"] = threshold
358
- updated.append(f"🔊 Threshold: `{threshold}`")
375
+ if interrupt_threshold is not None:
376
+ self.bot_settings["voice_threshold"] = interrupt_threshold
377
+ updated.append(f"🔊 Interrupt Threshold: `{interrupt_threshold}`")
378
+ try:
379
+ async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
380
+ await session.post(f"{NODE_VOICE_API}/set_config", json={"voice_threshold": interrupt_threshold})
381
+ except aiohttp.ClientError as e:
382
+ self.logger.warning(f"Node.js sync failed for {interaction.guild_id}: {e}")
383
+ updated.append(f"(⚠️ Node.js Sync Failed: {e})")
384
+ except asyncio.TimeoutError:
385
+ self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
386
+ updated.append("(⚠️ Node.js Sync Timeout)")
387
+ if wake_sensitivity is not None:
388
+ clamped_wake = max(0.05, min(0.95, wake_sensitivity))
389
+ self.bot_settings["wake_threshold"] = clamped_wake
390
+ updated.append(f"🎯 Wake Sensitivity: `{clamped_wake}`")
359
391
  try:
360
392
  async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
361
- await session.post(f"{NODE_VOICE_API}/set_config", json={"voice_threshold": threshold})
393
+ await session.post(f"{NODE_VOICE_API}/set_config", json={"wake_threshold": clamped_wake})
362
394
  except aiohttp.ClientError as e:
363
395
  self.logger.warning(f"Node.js sync failed for {interaction.guild_id}: {e}")
364
396
  updated.append(f"(⚠️ Node.js Sync Failed: {e})")
package/src/config.py CHANGED
@@ -112,11 +112,15 @@ TTS_VOICE = bot_settings.get("tts_voice", "ko-KR-SunHiNeural")
112
112
 
113
113
  def is_allowed_session_channel(channel) -> bool:
114
114
  """True if a new agy session may be started from this channel (via
115
- /new). A channel is allowed if its server is in SESSION_SCOPES AND
116
- either that server has no channel restriction (whole-server access)
117
- or this specific channel is in its allowed list."""
115
+ /new). DMs have no guild to scope, so they're always allowed here -
116
+ gating for DMs is handled separately via ALLOWED_IDS. For a guild
117
+ channel, it's allowed if its server is in SESSION_SCOPES AND either
118
+ that server has no channel restriction (whole-server access) or this
119
+ specific channel is in its allowed list."""
118
120
  guild = getattr(channel, "guild", None)
119
- if not guild or guild.id not in SESSION_SCOPES:
121
+ if not guild:
122
+ return True
123
+ if guild.id not in SESSION_SCOPES:
120
124
  return False
121
125
  allowed_channels = SESSION_SCOPES[guild.id]
122
126
  return allowed_channels is None or channel.id in allowed_channels