discord-selfbot-mcp 1.2.7 → 1.3.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/README.md CHANGED
@@ -186,6 +186,7 @@ powered by the robust `discord.py-self` library.
186
186
  | **system** | 2 | get_user_info, list_guilds |
187
187
  | **messages** | 6 | send_message, read_messages, search_messages, edit_message, delete_message, get_message_attachments |
188
188
  | **channels** | 3 | create_channel, delete_channel, list_channels |
189
+ | **dms** | 1 | list_dm_channels |
189
190
  | **voice** | 2 | join_voice_channel, leave_voice_channel |
190
191
  | **relationships** | 4 | list_friends, send_friend_request, add_friend, remove_friend |
191
192
  | **presence** | 2 | set_status, set_activity |
@@ -197,11 +198,42 @@ powered by the robust `discord.py-self` library.
197
198
  | **reactions** | 2 | add_reaction, remove_reaction |
198
199
  | **discrawl** | 7 | run_discrawl, discrawl_doctor, discrawl_status, discrawl_sync, discrawl_search, discrawl_messages, discrawl_mentions |
199
200
 
201
+ ### direct messages
202
+
203
+ `list_dm_channels` enumerates your open 1:1 and group DM channels so you can
204
+ discover a `channel_id` instead of needing to know it in advance. Each row is
205
+ `<channel_id> - [dm|group] <recipient display> (id=<user_id>) [BOT]`. Optional
206
+ args: `include_groups` (default `true`) and `name_contains` (case-insensitive
207
+ filter on recipient name/handle), e.g. find the DM with a person by name and
208
+ feed the id straight into `read_messages` / `send_message`.
209
+
210
+ ### slash commands
211
+
212
+ `send_slash_command` invokes an application command in a channel or DM. Pass
213
+ `application_id` (the bot's user/application ID) for reliable resolution; in a DM
214
+ with a bot it is inferred automatically. The command and any options are
215
+ resolved from the application's registered command list
216
+ (`GET /applications/{id}/commands`), which works for ordinary guild-installed
217
+ bots whose commands the per-channel `/` search index does not return. Subcommands
218
+ are space-separated in `command_name` (e.g. `"config set"`). Example:
219
+
220
+ ```json
221
+ {
222
+ "channel_id": "123456789012345678",
223
+ "command_name": "remind",
224
+ "application_id": "987654321098765432",
225
+ "options": { "text": "stand up", "when": "in 10 minutes" }
226
+ }
227
+ ```
228
+
200
229
  ### discrawl integration
201
230
 
202
231
  Use `run_discrawl` to execute local `discrawl` commands directly from MCP.
203
232
  Use typed tools for common operations (`discrawl_sync`, `discrawl_search`, `discrawl_messages`, etc.) when you want structured params.
204
- This fork prefers a sibling checkout at `../discrawl-self/bin/discrawl` when present, then falls back to `discrawl` in `PATH`.
233
+ By default this MCP uses the Microck fork at `../discrawl-self/bin/discrawl`:
234
+ `https://github.com/Microck/discrawl-self`. It does not silently fall back to a
235
+ global `discrawl` from `PATH`; set `DISCRAWL_BIN` or the tool `binary` argument
236
+ when you intentionally want another executable.
205
237
 
206
238
  Example tool call payload:
207
239
 
@@ -229,7 +261,7 @@ Typed tool payload example:
229
261
 
230
262
  Optional env var:
231
263
 
232
- - `DISCRAWL_BIN` - custom path to discrawl executable. This overrides the default fork-first lookup.
264
+ - `DISCRAWL_BIN` - custom path to discrawl executable. This overrides the default Microck fork lookup.
233
265
 
234
266
  ### attachment access
235
267
 
@@ -336,6 +368,7 @@ discord_py_self_mcp/
336
368
  │ └── solver.py
337
369
  └── tools/
338
370
  ├── channels.py
371
+ ├── dms.py
339
372
  ├── discrawl.py
340
373
  ├── embed.py
341
374
  ├── guilds.py
@@ -377,6 +410,8 @@ python3 scripts/dcli.py daemon status # check daemon status
377
410
  python3 scripts/dcli.py send-message --channel CHANNEL_ID --content "Hello"
378
411
  python3 scripts/dcli.py list-guilds
379
412
  python3 scripts/dcli.py read-messages --channel CHANNEL_ID --limit 20
413
+ python3 scripts/dcli.py get-message-attachments --channel CHANNEL_ID --message MESSAGE_ID
414
+ python3 scripts/dcli.py get-message-attachments --channel CHANNEL_ID --message MESSAGE_ID --download --output-dir ./attachments
380
415
  ```
381
416
 
382
417
  **when to use skill mode**:
package/SKILL.md CHANGED
@@ -66,8 +66,11 @@ This server also exposes dedicated MCP tools to run local `discrawl` commands:
66
66
  - `discrawl_messages`
67
67
  - `discrawl_mentions`
68
68
 
69
- By default this MCP prefers a sibling fork build at `../discrawl-self/bin/discrawl`, then falls back to `discrawl` in `PATH`.
70
- Use `DISCRAWL_BIN` if you want to override that:
69
+ By default this MCP uses the Microck fork build at `../discrawl-self/bin/discrawl`
70
+ from `https://github.com/Microck/discrawl-self`. It does not silently fall back
71
+ to a global `discrawl` from `PATH`.
72
+
73
+ Use `DISCRAWL_BIN` if you want to intentionally override that:
71
74
 
72
75
  ```bash
73
76
  DISCRAWL_BIN=/absolute/path/to/discrawl
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discord-selfbot-mcp",
3
- "version": "1.2.7",
3
+ "version": "1.3.0",
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.3.0",
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.3.0",
17
17
  "packageArguments": [],
18
18
  "environmentVariables": [
19
19
  {
@@ -38,7 +38,7 @@
38
38
  },
39
39
  {
40
40
  "name": "DISCRAWL_BIN",
41
- "description": "Optional path to discrawl executable for the run_discrawl MCP tool. Overrides the default preference for ../discrawl-self/bin/discrawl.",
41
+ "description": "Optional path to discrawl executable for the run_discrawl MCP tool. Overrides the default Microck fork path at ../discrawl-self/bin/discrawl.",
42
42
  "format": "string",
43
43
  "isRequired": false
44
44
  }