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 +2 -0
- package/package.json +1 -1
- package/scripts/daemon.py +109 -9
- package/scripts/dcli.py +96 -4
- package/server.json +2 -2
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
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
16
|
+
"version": "1.2.8",
|
|
17
17
|
"packageArguments": [],
|
|
18
18
|
"environmentVariables": [
|
|
19
19
|
{
|