discord-selfbot-mcp 1.2.7 → 1.2.8

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/README.md CHANGED
@@ -377,6 +377,8 @@ python3 scripts/dcli.py daemon status # check daemon status
377
377
  python3 scripts/dcli.py send-message --channel CHANNEL_ID --content "Hello"
378
378
  python3 scripts/dcli.py list-guilds
379
379
  python3 scripts/dcli.py read-messages --channel CHANNEL_ID --limit 20
380
+ python3 scripts/dcli.py get-message-attachments --channel CHANNEL_ID --message MESSAGE_ID
381
+ python3 scripts/dcli.py get-message-attachments --channel CHANNEL_ID --message MESSAGE_ID --download --output-dir ./attachments
380
382
  ```
381
383
 
382
384
  **when to use skill mode**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discord-selfbot-mcp",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Discord Selfbot MCP server - Node.js wrapper for Python implementation",
5
5
  "main": "index.js",
6
6
  "bin": {
package/scripts/daemon.py CHANGED
@@ -5,6 +5,7 @@ Auto-restart on code change: Enabled
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import base64
8
9
  import hashlib
9
10
  import json
10
11
  import os
@@ -17,8 +18,13 @@ from datetime import datetime, timedelta, timezone
17
18
  from pathlib import Path
18
19
  from secrets import compare_digest
19
20
 
21
+ SCRIPT_DIR = Path(__file__).resolve().parent
22
+ PROJECT_ROOT = SCRIPT_DIR.parent
23
+ if str(PROJECT_ROOT) not in sys.path:
24
+ sys.path.insert(0, str(PROJECT_ROOT))
25
+
20
26
  env_paths = [
21
- Path(__file__).parent.parent / ".env",
27
+ PROJECT_ROOT / ".env",
22
28
  Path.cwd() / ".env",
23
29
  ]
24
30
 
@@ -29,11 +35,6 @@ for env_path in env_paths:
29
35
  load_dotenv(env_path)
30
36
  break
31
37
 
32
- TOKEN = os.getenv("DISCORD_TOKEN")
33
- if not TOKEN:
34
- print("Error: DISCORD_TOKEN not found in .env file")
35
- sys.exit(1)
36
-
37
38
  import discord
38
39
 
39
40
  from discord_py_self_mcp.cli_runtime import (
@@ -46,11 +47,11 @@ from discord_py_self_mcp.cli_runtime import (
46
47
  )
47
48
  from discord_py_self_mcp.logging_utils import log_to_stderr
48
49
  from discord_py_self_mcp.tool_utils import NON_MESSAGEABLE_TEXT, validate_message_content
49
- from discord_py_self_mcp.tools.embed import serialize_message
50
+ from discord_py_self_mcp.tools.embed import serialize_attachment, serialize_message
50
51
 
51
- SCRIPT_DIR = Path(__file__).parent
52
52
  DAEMON_SCRIPT = SCRIPT_DIR / "daemon.py"
53
53
  CHECK_INTERVAL = 2
54
+ MAX_ATTACHMENT_BYTES_DEFAULT = 10 * 1024 * 1024
54
55
 
55
56
 
56
57
  def _safe_unlink(path: Path) -> None:
@@ -74,6 +75,15 @@ def _load_or_create_auth_token() -> str:
74
75
  return token
75
76
 
76
77
 
78
+ def _get_token_or_exit() -> str:
79
+ token = os.getenv("DISCORD_TOKEN")
80
+ if token:
81
+ return token
82
+
83
+ print("Error: DISCORD_TOKEN not found in .env file")
84
+ raise SystemExit(1)
85
+
86
+
77
87
  class DiscordDaemon:
78
88
  def __init__(self):
79
89
  self.client = discord.Client()
@@ -163,6 +173,7 @@ class DiscordDaemon:
163
173
 
164
174
  async def connect(self):
165
175
  """Connect to Discord and wait for the initial ready signal."""
176
+ token = _get_token_or_exit()
166
177
 
167
178
  @self.client.event
168
179
  async def on_ready():
@@ -173,7 +184,7 @@ class DiscordDaemon:
173
184
  async def on_disconnect():
174
185
  log_to_stderr(f"[{datetime.now()}] Disconnected from Discord")
175
186
 
176
- asyncio.create_task(self.client.start(TOKEN))
187
+ asyncio.create_task(self.client.start(token))
177
188
  await asyncio.wait_for(self._connected.wait(), timeout=30)
178
189
 
179
190
  async def handle_command(self, command_data):
@@ -194,6 +205,14 @@ class DiscordDaemon:
194
205
  return await self._send_message(
195
206
  args.get("channel_id"), args.get("content")
196
207
  )
208
+ if cmd == "get_message_attachments":
209
+ return await self._get_message_attachments(
210
+ args.get("channel_id"),
211
+ args.get("message_id"),
212
+ args.get("attachment_index"),
213
+ args.get("download_content", False),
214
+ args.get("max_bytes", MAX_ATTACHMENT_BYTES_DEFAULT),
215
+ )
197
216
  if cmd == "list_threads":
198
217
  return await self._list_threads(
199
218
  args.get("channel_id"), args.get("archived", False)
@@ -310,6 +329,87 @@ class DiscordDaemon:
310
329
  message = await channel.send(content)
311
330
  return {"message_id": message.id, "success": True}
312
331
 
332
+ async def _get_message_attachments(
333
+ self,
334
+ channel_id,
335
+ message_id,
336
+ attachment_index=None,
337
+ download_content=False,
338
+ max_bytes=MAX_ATTACHMENT_BYTES_DEFAULT,
339
+ ):
340
+ channel = self.client.get_channel(channel_id) or await self.client.fetch_channel(
341
+ channel_id
342
+ )
343
+ if not channel:
344
+ return {"error": "Channel not found"}
345
+ if not isinstance(channel, discord.abc.Messageable):
346
+ return {"error": NON_MESSAGEABLE_TEXT}
347
+
348
+ try:
349
+ message = await channel.fetch_message(message_id)
350
+ except discord.NotFound:
351
+ return {"error": "Message not found"}
352
+ except discord.Forbidden:
353
+ return {"error": "Access denied to message"}
354
+
355
+ attachments = list(message.attachments)
356
+ if not attachments:
357
+ return {"error": "Message has no attachments"}
358
+
359
+ if attachment_index is not None:
360
+ if attachment_index < 0 or attachment_index >= len(attachments):
361
+ return {
362
+ "error": (
363
+ f"Attachment index {attachment_index} is out of range for "
364
+ f"{len(attachments)} attachment(s)"
365
+ )
366
+ }
367
+ indexed_attachments = [(attachment_index, attachments[attachment_index])]
368
+ else:
369
+ indexed_attachments = list(enumerate(attachments))
370
+
371
+ result = {
372
+ "message_id": message.id,
373
+ "attachments": [
374
+ {"index": index, **serialize_attachment(attachment)}
375
+ for index, attachment in indexed_attachments
376
+ ],
377
+ }
378
+
379
+ if not download_content:
380
+ return result
381
+
382
+ max_bytes = int(max_bytes)
383
+ downloads = []
384
+ skipped = []
385
+ for index, attachment in indexed_attachments:
386
+ if attachment.size is not None and attachment.size > max_bytes:
387
+ skipped.append(
388
+ {
389
+ "index": index,
390
+ "filename": attachment.filename,
391
+ "reason": f"size={attachment.size} exceeds max_bytes={max_bytes}",
392
+ }
393
+ )
394
+ continue
395
+
396
+ blob = await attachment.read()
397
+ downloads.append(
398
+ {
399
+ "index": index,
400
+ "filename": attachment.filename,
401
+ "content_type": attachment.content_type
402
+ or "application/octet-stream",
403
+ "content_base64": base64.b64encode(blob).decode("ascii"),
404
+ }
405
+ )
406
+
407
+ if downloads:
408
+ result["downloads"] = downloads
409
+ if skipped:
410
+ result["skipped"] = skipped
411
+ return result
412
+
313
413
  async def _list_threads(self, channel_id, archived=False):
314
414
  channel = self.client.get_channel(channel_id) or await self.client.fetch_channel(
315
415
  channel_id
package/scripts/dcli.py CHANGED
@@ -6,6 +6,7 @@ Commands:
6
6
  daemon <start|stop|restart|status> - Manage daemon process
7
7
  send-message --channel ID --content "msg"
8
8
  read-messages --channel ID [--limit N] [--after TIME]
9
+ get-message-attachments --channel ID --message ID [--attachment-index N] [--download --output-dir DIR]
9
10
  list-guilds
10
11
  list-channels --guild ID
11
12
  list-threads --channel ID [--archived]
@@ -28,6 +29,7 @@ Time formats for --after:
28
29
  1704067200 - Unix timestamp
29
30
  """
30
31
 
32
+ import base64
31
33
  import json
32
34
  import os
33
35
  import socket
@@ -36,6 +38,11 @@ import sys
36
38
  import time
37
39
  from pathlib import Path
38
40
 
41
+ SCRIPT_DIR = Path(__file__).resolve().parent
42
+ PROJECT_ROOT = SCRIPT_DIR.parent
43
+ if str(PROJECT_ROOT) not in sys.path:
44
+ sys.path.insert(0, str(PROJECT_ROOT))
45
+
39
46
  from discord_py_self_mcp.cli_runtime import AUTH_FILE, PID_FILE, SOCKET_PATH
40
47
 
41
48
 
@@ -70,8 +77,7 @@ def start_daemon():
70
77
  """Auto-start daemon if not running."""
71
78
  if not is_daemon_running():
72
79
  print("Starting daemon...")
73
- script_dir = Path(__file__).parent
74
- daemon_script = script_dir / "daemon.py"
80
+ daemon_script = SCRIPT_DIR / "daemon.py"
75
81
  subprocess.Popen(
76
82
  [sys.executable, str(daemon_script), "start"],
77
83
  stdout=subprocess.DEVNULL,
@@ -170,6 +176,74 @@ def format_messages(messages, reverse=True, use_local_timezone=True):
170
176
  print(f"[{created_at}] {author}: {content}")
171
177
 
172
178
 
179
+ def print_attachment_metadata(attachments):
180
+ for attachment in attachments:
181
+ details = [
182
+ f"[Attachment {attachment.get('index', '?')}] {attachment.get('filename', 'unknown')}"
183
+ ]
184
+ if attachment.get("content_type"):
185
+ details.append(f"type={attachment['content_type']}")
186
+ if attachment.get("size") is not None:
187
+ details.append(f"size={attachment['size']}")
188
+ if attachment.get("width") and attachment.get("height"):
189
+ details.append(f"dimensions={attachment['width']}x{attachment['height']}")
190
+ if attachment.get("url"):
191
+ details.append(f"url={attachment['url']}")
192
+ print(" ".join(details))
193
+
194
+
195
+ def cmd_get_message_attachments(
196
+ channel_id,
197
+ message_id,
198
+ attachment_index=None,
199
+ download=False,
200
+ output_dir=None,
201
+ max_bytes=None,
202
+ ):
203
+ if download and not output_dir:
204
+ print("Error: --output-dir is required when --download is used")
205
+ return
206
+
207
+ args = {"channel_id": channel_id, "message_id": message_id}
208
+ if attachment_index is not None:
209
+ args["attachment_index"] = attachment_index
210
+ if download:
211
+ args["download_content"] = True
212
+ if max_bytes is not None:
213
+ args["max_bytes"] = max_bytes
214
+
215
+ result = send_request({"command": "get_message_attachments", "args": args})
216
+ if "error" in result:
217
+ print(f"Error: {result['error']}")
218
+ return
219
+
220
+ attachments = result.get("attachments", [])
221
+ print(f"Found {len(attachments)} attachment(s) on message {result.get('message_id')}:")
222
+ print("-" * 60)
223
+ print_attachment_metadata(attachments)
224
+
225
+ skipped = result.get("skipped", [])
226
+ if skipped:
227
+ print("\nSkipped downloads:")
228
+ for item in skipped:
229
+ print(
230
+ f" [Attachment {item.get('index', '?')}] {item.get('filename', 'unknown')}: {item.get('reason', 'Unknown reason')}"
231
+ )
232
+
233
+ downloads = result.get("downloads", [])
234
+ if not downloads:
235
+ return
236
+
237
+ output_path = Path(output_dir)
238
+ output_path.mkdir(parents=True, exist_ok=True)
239
+ print(f"\nSaved downloads to {output_path}:")
240
+ for item in downloads:
241
+ filename = item.get("filename") or f"attachment-{item.get('index', 'unknown')}"
242
+ destination = output_path / f"attachment-{item.get('index', 'unknown')}-{filename}"
243
+ destination.write_bytes(base64.b64decode(item["content_base64"]))
244
+ print(f" {destination}")
245
+
246
+
173
247
  def cmd_send_message(channel_id, content):
174
248
  result = send_request(
175
249
  {
@@ -450,8 +524,7 @@ def cmd_create_thread(channel_id, name, message_id, content=None):
450
524
 
451
525
 
452
526
  def cmd_daemon(action):
453
- script_dir = Path(__file__).parent
454
- daemon_script = script_dir / "daemon.py"
527
+ daemon_script = SCRIPT_DIR / "daemon.py"
455
528
 
456
529
  if action == "start":
457
530
  if is_daemon_running():
@@ -487,6 +560,16 @@ def main():
487
560
  read_parser.add_argument("--limit", "-l", type=int, default=10, help="Number of messages")
488
561
  read_parser.add_argument("--after", "-a", type=str, help="Only show messages after this time (e.g. '4h', '30m', '2024-01-01T00:00:00')")
489
562
 
563
+ attachments_parser = subparsers.add_parser(
564
+ "get-message-attachments", help="Show attachment metadata for a message"
565
+ )
566
+ attachments_parser.add_argument("--channel", "-c", required=True, type=int, help="Channel ID")
567
+ attachments_parser.add_argument("--message", "-m", required=True, type=int, help="Message ID")
568
+ attachments_parser.add_argument("--attachment-index", type=int, help="Optional zero-based attachment index")
569
+ attachments_parser.add_argument("--download", action="store_true", help="Download selected attachments to disk")
570
+ attachments_parser.add_argument("--output-dir", type=str, help="Directory to write downloads into when --download is used")
571
+ attachments_parser.add_argument("--max-bytes", type=int, help="Skip downloads larger than this many bytes")
572
+
490
573
  subparsers.add_parser("list-guilds", help="List all guilds")
491
574
 
492
575
  channels_parser = subparsers.add_parser("list-channels", help="List channels in a guild")
@@ -555,6 +638,15 @@ def main():
555
638
  cmd_send_message(args.channel, args.content)
556
639
  elif args.command == "read-messages":
557
640
  cmd_read_messages(args.channel, args.limit, args.after)
641
+ elif args.command == "get-message-attachments":
642
+ cmd_get_message_attachments(
643
+ args.channel,
644
+ args.message,
645
+ args.attachment_index,
646
+ args.download,
647
+ args.output_dir,
648
+ args.max_bytes,
649
+ )
558
650
  elif args.command == "list-guilds":
559
651
  cmd_list_guilds()
560
652
  elif args.command == "list-channels":
package/server.json CHANGED
@@ -6,14 +6,14 @@
6
6
  "url": "https://github.com/Microck/discord.py-self-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.2.7",
9
+ "version": "1.2.8",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "registryBaseUrl": "https://registry.npmjs.org",
14
14
  "identifier": "discord-selfbot-mcp",
15
15
  "runtimeHint": "python",
16
- "version": "1.2.7",
16
+ "version": "1.2.8",
17
17
  "packageArguments": [],
18
18
  "environmentVariables": [
19
19
  {