linkgravity 1.5.1 → 1.5.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/bin/cli.js +20 -3
- package/package.json +1 -1
- package/src/cogs/general_cog.py +1 -1
- package/src/cogs/voice_cog.py +25 -21
- package/src/core/agy_runner.py +58 -45
- package/src/main_slack.py +1 -2
- package/src/main_telegram.py +5 -2
- package/voice-service/index.js +12 -11
package/bin/cli.js
CHANGED
|
@@ -246,10 +246,12 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
246
246
|
} else if (cmd === 'stop') {
|
|
247
247
|
info('Stopping LinkGravity daemon...');
|
|
248
248
|
runPm2(['stop', LGY_PM2_NAME]);
|
|
249
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
249
250
|
success('Daemon stopped successfully.\n');
|
|
250
251
|
} else if (cmd === 'restart') {
|
|
251
252
|
info('Restarting LinkGravity daemon...');
|
|
252
253
|
runPm2(['restart', LGY_PM2_NAME, '--update-env']);
|
|
254
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
253
255
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
254
256
|
} else if (cmd === 'logs') {
|
|
255
257
|
const SHORT_FLAGS = ['-f', '-n', '-t'];
|
|
@@ -310,9 +312,21 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
310
312
|
const uptime =
|
|
311
313
|
proc.pm2_env.status === 'online' ? formatUptime(proc.pm2_env.pm_uptime) : '-';
|
|
312
314
|
const restarts = proc.pm2_env.restart_time;
|
|
315
|
+
const statusColor = proc.pm2_env.status === 'online' ? color.green : color.red;
|
|
316
|
+
|
|
313
317
|
console.log(
|
|
314
|
-
`\n${color.cyan}▶${color.reset} daemon: ${proc.pm2_env.status}
|
|
318
|
+
`\n${color.cyan}▶${color.reset} daemon: ${statusColor}${proc.pm2_env.status}${color.reset}`,
|
|
315
319
|
);
|
|
320
|
+
for (const [label, value] of [
|
|
321
|
+
['uptime', uptime],
|
|
322
|
+
['restarts', restarts],
|
|
323
|
+
['cpu', cpu],
|
|
324
|
+
['mem', mem],
|
|
325
|
+
]) {
|
|
326
|
+
console.log(` ${label.padEnd(9)} ${value}`);
|
|
327
|
+
}
|
|
328
|
+
console.log();
|
|
329
|
+
|
|
316
330
|
// Flag "many restarts" only alongside a short current uptime - restart_time alone is cumulative, not live.
|
|
317
331
|
if (
|
|
318
332
|
proc.pm2_env.status === 'online' &&
|
|
@@ -331,16 +345,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
331
345
|
const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
|
|
332
346
|
const h = health[key];
|
|
333
347
|
let connection = '-';
|
|
348
|
+
let since = '-';
|
|
334
349
|
if (enabled) {
|
|
335
350
|
if (!h) connection = 'unknown';
|
|
336
351
|
else if (h.status === 'running') connection = 'connected';
|
|
337
352
|
else if (h.status === 'connecting') connection = 'connecting...';
|
|
338
353
|
else if (h.status === 'error') connection = `error: ${h.detail || '?'}`;
|
|
339
354
|
else if (h.status === 'stopped') connection = 'stopped';
|
|
355
|
+
if (h && h.at) since = formatUptime(new Date(h.at).getTime());
|
|
340
356
|
}
|
|
341
|
-
return [def.label, enabled ? 'yes' : 'no', connection, String(sessionCount)];
|
|
357
|
+
return [def.label, enabled ? 'yes' : 'no', connection, since, String(sessionCount)];
|
|
342
358
|
});
|
|
343
|
-
console.log(renderTable(['platform', 'enabled', 'connection', 'sessions'], rows));
|
|
359
|
+
console.log(renderTable(['platform', 'enabled', 'connection', 'since', 'sessions'], rows));
|
|
344
360
|
console.log();
|
|
345
361
|
} else if (cmd === 'enable') {
|
|
346
362
|
if (isWin) {
|
|
@@ -409,6 +425,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
409
425
|
});
|
|
410
426
|
|
|
411
427
|
if (restartResult.status === 0) {
|
|
428
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
412
429
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
413
430
|
} else if ((restartResult.stderr || '').toString().includes('not found')) {
|
|
414
431
|
// Wasn't running before the update - start fresh instead of a false "restarted".
|
package/package.json
CHANGED
package/src/cogs/general_cog.py
CHANGED
|
@@ -242,7 +242,7 @@ class GeneralCog(commands.Cog):
|
|
|
242
242
|
if not allowed(interaction.user.id):
|
|
243
243
|
return await interaction.response.send_message("❌ Denied", ephemeral=True)
|
|
244
244
|
|
|
245
|
-
settings_path = Path
|
|
245
|
+
settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
|
|
246
246
|
try:
|
|
247
247
|
data = safe_load_json(settings_path, {}, logger=logger)
|
|
248
248
|
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -202,7 +202,7 @@ class VoiceCog(commands.Cog):
|
|
|
202
202
|
await interaction.response.send_message("❌ Please join a voice channel first.", ephemeral=True)
|
|
203
203
|
return
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
voice_channel = interaction.user.voice.channel
|
|
206
206
|
guild_id = interaction.guild_id
|
|
207
207
|
|
|
208
208
|
wake_word_map = self.bot_settings.get("wake_words") or {}
|
|
@@ -210,23 +210,26 @@ class VoiceCog(commands.Cog):
|
|
|
210
210
|
active_timer = self.bot_settings.get("active_timer", 60)
|
|
211
211
|
required = self._wake_word_required(interaction.user.id)
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
header = f"🎤 Connected to `{voice_channel.name}`."
|
|
214
|
+
sound_tip = "⚙️ You can customize settings using `/sound`."
|
|
215
|
+
|
|
216
|
+
if not required:
|
|
217
|
+
body = "💡 Wake word is off for you - just talk, I'm listening."
|
|
218
|
+
elif own_word:
|
|
219
|
+
body = (
|
|
220
|
+
f"💡 Say `{own_word}` to activate me. Once awake, "
|
|
221
|
+
f"I'll keep listening for {active_timer} seconds after each interaction."
|
|
218
222
|
)
|
|
219
|
-
elif not required:
|
|
220
|
-
msg = f"🎤 Connected to `{vc_chan.name}`.\n💡 Wake word is off for you - just talk, I'm listening."
|
|
221
223
|
else:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
f"turning it off with `/sound require_wake_word:off` is recommended instead."
|
|
224
|
+
body = (
|
|
225
|
+
"🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
|
|
226
|
+
"and say your chosen word a few times to register it in your voice.\n"
|
|
227
|
+
"💡 A wake word keeps everyone else's side conversation from triggering me by accident, and "
|
|
228
|
+
"avoids running speech recognition on audio that isn't meant for me. If you use push-to-talk, "
|
|
229
|
+
"turning it off with `/sound require_wake_word:off` is recommended instead."
|
|
229
230
|
)
|
|
231
|
+
|
|
232
|
+
msg = "\n".join([header, body, sound_tip])
|
|
230
233
|
await interaction.response.send_message(msg)
|
|
231
234
|
|
|
232
235
|
self.stt_session.clear_active_window(str(guild_id)) # don't inherit a window left open by a prior /join
|
|
@@ -239,7 +242,7 @@ class VoiceCog(commands.Cog):
|
|
|
239
242
|
self.logger.warning(f"Failed to call /leave before /join: {e}")
|
|
240
243
|
|
|
241
244
|
resp = await session.post(
|
|
242
|
-
f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(
|
|
245
|
+
f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(voice_channel.id)}
|
|
243
246
|
)
|
|
244
247
|
if not required:
|
|
245
248
|
# Node's opt-out set is in-memory and won't survive a Node restart, unlike our own bot_settings.
|
|
@@ -554,15 +557,16 @@ class VoiceCog(commands.Cog):
|
|
|
554
557
|
self.logger.warning(f"Failed to interrupt playback for guild {guild_id}: {e}")
|
|
555
558
|
self._active_turns[str(guild_id)] = asyncio.current_task()
|
|
556
559
|
|
|
557
|
-
|
|
558
|
-
if
|
|
560
|
+
guild = self.bot.get_guild(int(guild_id))
|
|
561
|
+
member = guild.get_member(int(user_id)) if guild else None
|
|
562
|
+
if guild and not member:
|
|
559
563
|
try:
|
|
560
|
-
|
|
564
|
+
member = await guild.fetch_member(int(user_id))
|
|
561
565
|
except discord.NotFound:
|
|
562
566
|
pass
|
|
563
567
|
except discord.HTTPException as e:
|
|
564
|
-
self.logger.warning(f"
|
|
565
|
-
username =
|
|
568
|
+
self.logger.warning(f"fetch_member failed for {user_id}: {e}")
|
|
569
|
+
username = member.display_name if member else f"User {user_id}"
|
|
566
570
|
await self.stt_session.finalize_partial_msg(str(guild_id), thread, f"🎤 **{username}**: {text}")
|
|
567
571
|
|
|
568
572
|
if not text_to_ai:
|
package/src/core/agy_runner.py
CHANGED
|
@@ -3,70 +3,70 @@ import functools
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import signal
|
|
6
|
+
import sys
|
|
6
7
|
|
|
7
8
|
from config import logger
|
|
8
9
|
|
|
9
10
|
active_processes = {}
|
|
10
11
|
agy_start_lock = asyncio.Lock()
|
|
11
|
-
# Threads killed intentionally - agy's SIGTERM exit code isn't reliable enough to tell otherwise.
|
|
12
12
|
_intentionally_stopped = set()
|
|
13
13
|
|
|
14
|
-
# Forced stdout buffer size (see _find_libstdbuf) - big enough for one write, small enough not to delay polling.
|
|
15
14
|
_STDOUT_BUFFER_SIZE = 65536
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
@functools.lru_cache(maxsize=1)
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
18
|
+
def _find_preload_lib() -> tuple[str, str] | None:
|
|
19
|
+
if sys.platform == "darwin":
|
|
20
|
+
env_var = "DYLD_INSERT_LIBRARIES"
|
|
21
|
+
lib_name = "libstdbuf.dylib"
|
|
22
|
+
candidates = [
|
|
23
|
+
"/opt/homebrew/opt/coreutils/lib/libstdbuf.dylib",
|
|
24
|
+
"/usr/local/opt/coreutils/lib/libstdbuf.dylib",
|
|
25
|
+
]
|
|
26
|
+
search_root = "/opt/homebrew" if os.path.isdir("/opt/homebrew") else "/usr/local"
|
|
27
|
+
elif os.name == "nt":
|
|
28
|
+
return None
|
|
29
|
+
else:
|
|
30
|
+
env_var = "LD_PRELOAD"
|
|
31
|
+
lib_name = "libstdbuf.so"
|
|
32
|
+
candidates = [
|
|
33
|
+
"/usr/lib/x86_64-linux-gnu/coreutils/libstdbuf.so",
|
|
34
|
+
"/usr/lib/aarch64-linux-gnu/coreutils/libstdbuf.so",
|
|
35
|
+
"/usr/libexec/coreutils/libstdbuf.so",
|
|
36
|
+
"/usr/lib/coreutils/libstdbuf.so",
|
|
37
|
+
"/usr/lib/libstdbuf.so",
|
|
38
|
+
]
|
|
39
|
+
search_root = "/usr"
|
|
40
|
+
|
|
36
41
|
for path in candidates:
|
|
37
42
|
if os.path.isfile(path):
|
|
38
|
-
return path
|
|
43
|
+
return env_var, path
|
|
39
44
|
|
|
40
|
-
# lru_cache: only runs once per process.
|
|
41
45
|
try:
|
|
42
46
|
import subprocess
|
|
43
47
|
|
|
44
48
|
result = subprocess.run(
|
|
45
|
-
["find",
|
|
49
|
+
["find", search_root, "-name", lib_name],
|
|
46
50
|
capture_output=True,
|
|
47
51
|
text=True,
|
|
48
52
|
timeout=5,
|
|
49
53
|
)
|
|
50
54
|
found = [line for line in result.stdout.strip().splitlines() if line]
|
|
51
55
|
if found:
|
|
52
|
-
return found[0]
|
|
56
|
+
return env_var, found[0]
|
|
53
57
|
except Exception:
|
|
54
58
|
pass
|
|
55
59
|
|
|
60
|
+
install_hint = "brew install coreutils" if sys.platform == "darwin" else "install coreutils"
|
|
56
61
|
logger.warning(
|
|
57
|
-
"[AGY ENV]
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"somewhere nonstandard."
|
|
62
|
+
f"[AGY ENV] {lib_name} not found - run_command output for multi-line "
|
|
63
|
+
f"commands may come back truncated. Try `{install_hint}`, or add its path "
|
|
64
|
+
"to _find_preload_lib()'s candidates list if installed somewhere nonstandard."
|
|
61
65
|
)
|
|
62
66
|
return None
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
def stop_active_process(thread_id: str) -> bool:
|
|
66
|
-
"""Kill the agy subprocess for this thread, if any. Also used when a
|
|
67
|
-
new voice utterance interrupts a still-in-flight turn. Returns
|
|
68
|
-
whether a process was actually found and signaled.
|
|
69
|
-
"""
|
|
70
70
|
target_proc = active_processes.get(thread_id)
|
|
71
71
|
if not target_proc:
|
|
72
72
|
return False
|
|
@@ -100,6 +100,24 @@ async def _get_latest_conversation_id() -> str:
|
|
|
100
100
|
return ""
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def _snapshot_conversation_dirs() -> set[str]:
|
|
104
|
+
from pathlib import Path
|
|
105
|
+
|
|
106
|
+
history_dir = Path.home() / ".gemini/antigravity-cli/brain"
|
|
107
|
+
if not history_dir.exists():
|
|
108
|
+
return set()
|
|
109
|
+
return {d.name for d in history_dir.iterdir() if d.is_dir()}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _poll_new_conversation_id(before: set[str], attempts: int = 30, interval: float = 0.1) -> str:
|
|
113
|
+
for _ in range(attempts):
|
|
114
|
+
await asyncio.sleep(interval)
|
|
115
|
+
new_dirs = _snapshot_conversation_dirs() - before
|
|
116
|
+
if new_dirs:
|
|
117
|
+
return next(iter(new_dirs))
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
103
121
|
async def run_agy(
|
|
104
122
|
*args, timeout: int = 300, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
|
|
105
123
|
) -> str:
|
|
@@ -123,10 +141,11 @@ async def run_agy(
|
|
|
123
141
|
if thread_id:
|
|
124
142
|
env["LGY_THREAD_ID"] = thread_id
|
|
125
143
|
|
|
126
|
-
|
|
127
|
-
if
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
preload = _find_preload_lib() # works around agy's output-truncation bug
|
|
145
|
+
if preload:
|
|
146
|
+
env_var, lib_path = preload
|
|
147
|
+
existing_preload = env.get(env_var, "")
|
|
148
|
+
env[env_var] = f"{lib_path}:{existing_preload}" if existing_preload else lib_path
|
|
130
149
|
env["_STDBUF_O"] = str(_STDOUT_BUFFER_SIZE)
|
|
131
150
|
|
|
132
151
|
kwargs = {
|
|
@@ -149,15 +168,15 @@ async def run_agy(
|
|
|
149
168
|
cmd = [AGY_BIN] + args_list
|
|
150
169
|
|
|
151
170
|
async with agy_start_lock:
|
|
171
|
+
before_dirs = _snapshot_conversation_dirs()
|
|
152
172
|
proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
153
173
|
if thread_id:
|
|
154
174
|
active_processes[thread_id] = proc
|
|
155
175
|
|
|
156
176
|
if stream_queue and "--conversation" not in args:
|
|
157
|
-
await
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await stream_queue.put(("__CONV_ID__:" + latest_conv_id, False))
|
|
177
|
+
new_conv_id = await _poll_new_conversation_id(before_dirs)
|
|
178
|
+
if new_conv_id:
|
|
179
|
+
await stream_queue.put(("__CONV_ID__:" + new_conv_id, False))
|
|
161
180
|
|
|
162
181
|
stdout_chunks = []
|
|
163
182
|
stderr_chunks = []
|
|
@@ -267,7 +286,6 @@ async def run_agy(
|
|
|
267
286
|
await stream_queue.put(("\n\n" + error_msg, True))
|
|
268
287
|
return error_msg
|
|
269
288
|
|
|
270
|
-
# DEBUG-only (LOG_LEVEL) raw stdout capture.
|
|
271
289
|
logger.debug(f"[AGY RAW STDOUT] {text!r}")
|
|
272
290
|
return text or "(Empty response)"
|
|
273
291
|
|
|
@@ -379,11 +397,6 @@ async def generate_thread_title(user_input: str, response: str) -> str:
|
|
|
379
397
|
|
|
380
398
|
|
|
381
399
|
async def update_agy_conversation_title(conv_id: str, title: str) -> None:
|
|
382
|
-
"""Antigravity CLI's own conversation list reads its display name from
|
|
383
|
-
conversation_summaries.db's `preview` column (the `title` column exists
|
|
384
|
-
but is unused/always empty - confirmed by inspecting the db directly).
|
|
385
|
-
Without this, agy shows its own auto-generated name while the Discord
|
|
386
|
-
thread shows ours, and the two drift apart for the same conversation."""
|
|
387
400
|
if not conv_id:
|
|
388
401
|
return
|
|
389
402
|
|
package/src/main_slack.py
CHANGED
|
@@ -139,12 +139,11 @@ async def cmd_credit(ack, body, respond, context) -> None:
|
|
|
139
139
|
await respond("❌ Denied")
|
|
140
140
|
return
|
|
141
141
|
|
|
142
|
-
import os
|
|
143
142
|
from pathlib import Path
|
|
144
143
|
|
|
145
144
|
from core.atomic_io import atomic_write_json, safe_load_json
|
|
146
145
|
|
|
147
|
-
settings_path = Path
|
|
146
|
+
settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
|
|
148
147
|
current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
|
|
149
148
|
|
|
150
149
|
async def set_credit(use_credits: bool, action_body, client) -> None:
|
package/src/main_telegram.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
which starts both concurrently when both platforms are enabled."""
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
-
import os
|
|
6
5
|
import uuid
|
|
7
6
|
from datetime import datetime
|
|
8
7
|
from pathlib import Path
|
|
@@ -144,7 +143,7 @@ async def cmd_credit(update: Update, context) -> None:
|
|
|
144
143
|
await update.message.reply_text("❌ Denied")
|
|
145
144
|
return
|
|
146
145
|
|
|
147
|
-
settings_path = Path
|
|
146
|
+
settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
|
|
148
147
|
current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
|
|
149
148
|
|
|
150
149
|
async def set_credit(use_credits: bool, query) -> None:
|
|
@@ -182,6 +181,10 @@ async def cmd_credit(update: Update, context) -> None:
|
|
|
182
181
|
|
|
183
182
|
|
|
184
183
|
async def on_message(update: Update, context) -> None:
|
|
184
|
+
chat_id = update.effective_chat.id
|
|
185
|
+
user = update.effective_user
|
|
186
|
+
if user and allowed(user.id, "telegram") and not session_manager.get_session(str(chat_id)):
|
|
187
|
+
_start_session(chat_id, user.id)
|
|
185
188
|
await handle_message(None, update, context.bot_data["adapter"])
|
|
186
189
|
|
|
187
190
|
|
package/voice-service/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
require('./logger');
|
|
1
|
+
require('./logger');
|
|
2
2
|
|
|
3
3
|
const { Client, GatewayIntentBits, Events } = require('discord.js');
|
|
4
4
|
const express = require('express');
|
|
5
5
|
|
|
6
6
|
const { aglConfig } = require('./config');
|
|
7
7
|
const state = require('./state');
|
|
8
|
+
|
|
9
|
+
process.on('unhandledRejection', (reason) => {
|
|
10
|
+
console.error('Unhandled promise rejection (voice service stays alive):', reason);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
process.on('uncaughtException', (err) => {
|
|
14
|
+
// Logs the cause before exiting - an unhandled sync error used to kill the process with no trace.
|
|
15
|
+
console.error('Uncaught exception - voice service is exiting:', err);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
8
19
|
const { registerRoutes } = require('./routes');
|
|
9
20
|
|
|
10
21
|
if (aglConfig.voice_threshold) {
|
|
@@ -24,16 +35,6 @@ client.once(Events.ClientReady, () => {
|
|
|
24
35
|
|
|
25
36
|
registerRoutes(app, client);
|
|
26
37
|
|
|
27
|
-
process.on('unhandledRejection', (reason) => {
|
|
28
|
-
console.error('Unhandled promise rejection (voice service stays alive):', reason);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
process.on('uncaughtException', (err) => {
|
|
32
|
-
// Logs the cause before exiting - an unhandled sync error used to kill the process with no trace.
|
|
33
|
-
console.error('Uncaught exception - voice service is exiting:', err);
|
|
34
|
-
process.exit(1);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
38
|
// Without this, SIGTERM (lgy stop/restart) kills the process mid-connection and Discord never gets a clean leave.
|
|
38
39
|
function shutdownGracefully() {
|
|
39
40
|
console.log(
|