DiscordBotLinuxMonitor 1.6.3__tar.gz → 1.6.5__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DiscordBotLinuxMonitor
3
- Version: 1.6.3
3
+ Version: 1.6.5
4
4
  Summary: From discord channels: Get information and warning status of Linux server like service, port, ping, ssl certificate, disk/folder/cpu/ram/swap usage, ip connection, ... (Python and shell library, Linux ONLY)
5
5
  Home-page: https://github.com/QuentinCG/Discord-Bot-Linux-Monitor-Python-Library
6
6
  Author: Quentin Comte-Gaz
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DiscordBotLinuxMonitor
3
- Version: 1.6.3
3
+ Version: 1.6.5
4
4
  Summary: From discord channels: Get information and warning status of Linux server like service, port, ping, ssl certificate, disk/folder/cpu/ram/swap usage, ip connection, ... (Python and shell library, Linux ONLY)
5
5
  Home-page: https://github.com/QuentinCG/Discord-Bot-Linux-Monitor-Python-Library
6
6
  Author: Quentin Comte-Gaz
@@ -33,7 +33,7 @@ __email__ = "quentin@comte-gaz.com"
33
33
  __license__ = "MIT License"
34
34
  __copyright__ = "Copyright Quentin Comte-Gaz (2026)"
35
35
  __python_version__ = "3.+"
36
- __version__ = "1.6.3 (2026/08/24)"
36
+ __version__ = "1.6.5 (2026/08/24)"
37
37
  __status__ = "Usable for any Linux project"
38
38
 
39
39
  # pyright: reportMissingTypeStubs=false
@@ -309,6 +309,87 @@ class DiscordBotLinuxMonitor:
309
309
 
310
310
  return fallback_seconds
311
311
 
312
+ def _format_duration(self, total_seconds: float) -> str:
313
+ seconds: int = max(0, int(total_seconds))
314
+
315
+ days, remainder = divmod(seconds, 86400)
316
+ hours, remainder = divmod(remainder, 3600)
317
+ minutes, secs = divmod(remainder, 60)
318
+
319
+ parts: List[str] = []
320
+ if days > 0:
321
+ parts.append(f"{days}d")
322
+ if hours > 0:
323
+ parts.append(f"{hours}h")
324
+ if minutes > 0:
325
+ parts.append(f"{minutes}m")
326
+ if secs > 0 or len(parts) == 0:
327
+ parts.append(f"{secs}s")
328
+
329
+ return " ".join(parts)
330
+
331
+ def _get_message_preview(self, message: discord.Message, max_length: int = 80) -> str:
332
+ content: str = str(getattr(message, "content", "") or "").strip()
333
+
334
+ if content == "":
335
+ attachments = getattr(message, "attachments", [])
336
+ embeds = getattr(message, "embeds", [])
337
+ if len(attachments) > 0:
338
+ content = f"[{len(attachments)} attachment(s)]"
339
+ elif len(embeds) > 0:
340
+ content = f"[{len(embeds)} embed(s)]"
341
+ else:
342
+ content = "[no text]"
343
+
344
+ content = content.replace("\n", " ").replace("\r", " ")
345
+ created_at = getattr(message, "created_at", None)
346
+ if created_at is not None:
347
+ try:
348
+ timestamp = created_at.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
349
+ except Exception:
350
+ timestamp = str(created_at)
351
+ else:
352
+ timestamp = "unknown-time"
353
+
354
+ if len(content) > max_length:
355
+ content = content[:max_length - 3] + "..."
356
+
357
+ return f"[{timestamp}] {content}"
358
+
359
+ def _build_cleanup_embed(self, channel_name: str, state: str, deleted_count: int, rate_limit_retries: int, elapsed_seconds: float, last_deleted_preview: str, spinner_frame: str = "", scanned_count: Optional[int] = None, error_text: str = "") -> "discord.Embed":
360
+ # state is one of: "running", "done", "error".
361
+ if state == "done":
362
+ color = discord.Color.green()
363
+ title = "🧹 Channel Cleanup — Completed"
364
+ status_value = "✅ Completed"
365
+ elif state == "error":
366
+ color = discord.Color.red()
367
+ title = "🧹 Channel Cleanup — Failed"
368
+ status_value = "❌ Failed"
369
+ else:
370
+ color = discord.Color.blurple()
371
+ title = "🧹 Channel Cleanup — In progress"
372
+ status_value = f"{spinner_frame} Working…".strip()
373
+
374
+ embed = discord.Embed(title=title, color=color)
375
+ embed.description = f"Target channel: **#{channel_name}**"
376
+
377
+ embed.add_field(name="Status", value=status_value, inline=True)
378
+ embed.add_field(name="🗑️ Deleted", value=f"**{deleted_count}** message(s)", inline=True)
379
+ embed.add_field(name="⏳ Elapsed", value=self._format_duration(elapsed_seconds), inline=True)
380
+
381
+ if scanned_count is not None:
382
+ embed.add_field(name="🔎 Scanned", value=f"{scanned_count} message(s)", inline=True)
383
+ embed.add_field(name="🚦 Rate-limit waits", value=str(rate_limit_retries), inline=True)
384
+
385
+ if error_text != "":
386
+ embed.add_field(name="⚠️ Error", value=f"```sh\n{error_text[:1000]}\n```", inline=False)
387
+
388
+ embed.add_field(name="🕗 Last deleted message", value=(last_deleted_preview if last_deleted_preview != "" else "N/A")[:1024], inline=False)
389
+
390
+ embed.set_footer(text=f"Bot v{__version__} • Last update: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
391
+ return embed
392
+
312
393
  async def _delete_message_with_rate_limit_retry(self, message: discord.Message, reason: str, max_retries: int = 10, on_rate_limit: Optional[Callable[[], None]] = None) -> None:
313
394
  for attempt in range(max_retries + 1):
314
395
  try:
@@ -903,6 +984,12 @@ class DiscordBotLinuxMonitor:
903
984
  started_monotonic: float = asyncio.get_running_loop().time()
904
985
  last_progress_monotonic: float = started_monotonic
905
986
  last_reported_deleted_count: int = 0
987
+ heartbeat_frames: List[str] = ["◐", "◓", "◑", "◒"]
988
+ heartbeat_index: int = 0
989
+ heartbeat_stop_event = asyncio.Event()
990
+ heartbeat_task: Optional[asyncio.Task] = None
991
+ last_deleted_message_preview: str = "N/A"
992
+ scanned_count: int = 0
906
993
 
907
994
  def _on_rate_limit_hit() -> None:
908
995
  nonlocal rate_limit_retries
@@ -911,6 +998,7 @@ class DiscordBotLinuxMonitor:
911
998
  async def _update_progress(force: bool = False) -> None:
912
999
  nonlocal last_progress_monotonic
913
1000
  nonlocal last_reported_deleted_count
1001
+ nonlocal heartbeat_index
914
1002
 
915
1003
  now = asyncio.get_running_loop().time()
916
1004
  should_update = force
@@ -922,16 +1010,21 @@ class DiscordBotLinuxMonitor:
922
1010
  if not should_update:
923
1011
  return
924
1012
 
925
- elapsed_seconds = int(now - started_monotonic)
926
- progress_msg = (
927
- f"🧹 Cleaning channel '{channel.name}'...\n"
928
- f"Deleted messages: {deleted_count}\n"
929
- f"Rate-limit waits: {rate_limit_retries}\n"
930
- f"Elapsed: {elapsed_seconds}s"
1013
+ heartbeat_frame = heartbeat_frames[heartbeat_index % len(heartbeat_frames)]
1014
+ heartbeat_index += 1
1015
+ embed = self._build_cleanup_embed(
1016
+ channel_name=channel.name,
1017
+ state="running",
1018
+ deleted_count=deleted_count,
1019
+ rate_limit_retries=rate_limit_retries,
1020
+ elapsed_seconds=now - started_monotonic,
1021
+ last_deleted_preview=last_deleted_message_preview,
1022
+ spinner_frame=heartbeat_frame,
1023
+ scanned_count=scanned_count,
931
1024
  )
932
1025
 
933
1026
  try:
934
- await interaction.edit_original_response(content=progress_msg)
1027
+ await interaction.edit_original_response(content=None, embed=embed)
935
1028
  last_progress_monotonic = now
936
1029
  last_reported_deleted_count = deleted_count
937
1030
  except discord.HTTPException as e:
@@ -942,48 +1035,88 @@ class DiscordBotLinuxMonitor:
942
1035
  else:
943
1036
  logging.warning(msg=f"Failed to update cleanup progress message: {e}")
944
1037
 
1038
+ async def _heartbeat_loop() -> None:
1039
+ while not heartbeat_stop_event.is_set():
1040
+ await asyncio.sleep(5)
1041
+ if heartbeat_stop_event.is_set():
1042
+ return
1043
+ await _update_progress(force=True)
1044
+
945
1045
  try:
946
1046
  await _update_progress(force=True)
1047
+ heartbeat_task = asyncio.create_task(_heartbeat_loop())
947
1048
  recent_batch: List[discord.Message] = []
948
1049
 
949
1050
  async for message in channel.history(limit=None, oldest_first=False):
1051
+ scanned_count += 1
950
1052
  if message.created_at >= two_weeks_ago:
951
1053
  recent_batch.append(message)
952
1054
 
953
1055
  # Bulk delete by chunks of 100 to reduce API calls and rate-limit pressure.
954
1056
  if len(recent_batch) == 100:
1057
+ last_deleted_message_preview = self._get_message_preview(recent_batch[-1])
955
1058
  await self._bulk_delete_messages_with_rate_limit_retry(channel=channel, messages=recent_batch, reason=delete_reason, on_rate_limit=_on_rate_limit_hit)
956
1059
  deleted_count += len(recent_batch)
957
1060
  recent_batch = []
958
1061
  await _update_progress()
959
1062
  else:
1063
+ last_deleted_message_preview = self._get_message_preview(message)
960
1064
  await self._delete_message_with_rate_limit_retry(message=message, reason=delete_reason, on_rate_limit=_on_rate_limit_hit)
961
1065
  deleted_count += 1
962
1066
  await _update_progress()
963
1067
 
964
1068
  if len(recent_batch) > 0:
1069
+ last_deleted_message_preview = self._get_message_preview(recent_batch[-1])
965
1070
  await self._bulk_delete_messages_with_rate_limit_retry(channel=channel, messages=recent_batch, reason=delete_reason, on_rate_limit=_on_rate_limit_hit)
966
1071
  deleted_count += len(recent_batch)
967
1072
  await _update_progress()
968
1073
 
969
- elapsed_seconds = int(asyncio.get_running_loop().time() - started_monotonic)
970
- out_msg = (
971
- f"✅ Cleared channel '{channel.name}'.\n"
972
- f"Deleted messages: {deleted_count}\n"
973
- f"Rate-limit waits: {rate_limit_retries}\n"
974
- f"Elapsed: {elapsed_seconds}s"
1074
+ embed = self._build_cleanup_embed(
1075
+ channel_name=channel.name,
1076
+ state="done",
1077
+ deleted_count=deleted_count,
1078
+ rate_limit_retries=rate_limit_retries,
1079
+ elapsed_seconds=asyncio.get_running_loop().time() - started_monotonic,
1080
+ last_deleted_preview=last_deleted_message_preview,
1081
+ scanned_count=scanned_count,
975
1082
  )
976
- await interaction.edit_original_response(content=out_msg)
1083
+ await interaction.edit_original_response(content=None, embed=embed)
977
1084
  except discord.HTTPException as e:
978
1085
  if e.status == 429:
979
1086
  _on_rate_limit_hit()
980
- out_msg = f"**Discord API error while clearing messages in channel '{channel.name}'** (status {e.status}, retries: {rate_limit_retries}):\n```sh\n{e}\n```"
981
- logging.exception(msg=out_msg)
982
- await interaction.edit_original_response(content=out_msg)
1087
+ embed = self._build_cleanup_embed(
1088
+ channel_name=channel.name,
1089
+ state="error",
1090
+ deleted_count=deleted_count,
1091
+ rate_limit_retries=rate_limit_retries,
1092
+ elapsed_seconds=asyncio.get_running_loop().time() - started_monotonic,
1093
+ last_deleted_preview=last_deleted_message_preview,
1094
+ scanned_count=scanned_count,
1095
+ error_text=f"Discord API error (status {e.status}): {e}",
1096
+ )
1097
+ logging.exception(msg=f"Discord API error while clearing messages in channel '{channel.name}': {e}")
1098
+ await interaction.edit_original_response(content=None, embed=embed)
983
1099
  except Exception as e:
984
- out_msg = f"**Internal error while clearing messages in channel '{channel.name}'**:\n```sh\n{e}\n```"
985
- logging.exception(msg=out_msg)
986
- await interaction.edit_original_response(content=out_msg)
1100
+ embed = self._build_cleanup_embed(
1101
+ channel_name=channel.name,
1102
+ state="error",
1103
+ deleted_count=deleted_count,
1104
+ rate_limit_retries=rate_limit_retries,
1105
+ elapsed_seconds=asyncio.get_running_loop().time() - started_monotonic,
1106
+ last_deleted_preview=last_deleted_message_preview,
1107
+ scanned_count=scanned_count,
1108
+ error_text=str(e),
1109
+ )
1110
+ logging.exception(msg=f"Internal error while clearing messages in channel '{channel.name}': {e}")
1111
+ await interaction.edit_original_response(content=None, embed=embed)
1112
+ finally:
1113
+ heartbeat_stop_event.set()
1114
+ if heartbeat_task is not None and not heartbeat_task.done():
1115
+ heartbeat_task.cancel()
1116
+ try:
1117
+ await heartbeat_task
1118
+ except asyncio.CancelledError:
1119
+ pass
987
1120
 
988
1121
  async def list_commands(self, interaction: discord.Interaction) -> None:
989
1122
  if not self._check_if_valid_guild(guild=interaction.guild):
@@ -6,7 +6,7 @@ with io.open(file='README.md', mode='r', encoding='utf-8') as readme_file:
6
6
 
7
7
  setup(
8
8
  name="DiscordBotLinuxMonitor",
9
- version="1.6.3",
9
+ version="1.6.5",
10
10
  description="From discord channels: Get information and warning status of Linux server like service, port, ping, ssl certificate, disk/folder/cpu/ram/swap usage, ip connection, ... (Python and shell library, Linux ONLY)",
11
11
  long_description=readme,
12
12
  long_description_content_type="text/markdown",