From 089800374957f3dc549e48dc72f68e8d4f97a308 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Tue, 21 Apr 2026 20:27:54 -0400 Subject: [PATCH] fix: stoat 401/429 handling, point updater+README to Forgejo - writer: use content_type=None on resp.json() to handle non-JSON 401 bodies - clone_server: retry mass parent PATCH on 429 with retry_after sleep - updater: switch API_URL from GitHub to git.mithraic.cloud - README: replace all GitHub links with Forgejo, drop contributors widget Co-Authored-By: Claude Sonnet 4.6 --- README.md | 14 ++--- src/core/updater.py | 6 +- src/stoat/clone_server.py | 35 ++++++++++-- src/stoat/writer.py | 114 +++++++++++++++++++++++--------------- 4 files changed, 105 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 486c5dc..0018df3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **DiscoReaper** is a powerful tool designed to help you migrate your entire Discord server to Fluxer or Stoat. It clones channels, roles, emojis, permissions, and also your community's full message history. -### Get it here: [![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black)](https://github.com/rambros3d/disco-reaper/releases/latest/download/disco-reaper-linux.zip) [![Windows](https://custom-icon-badges.demolab.com/badge/Windows-0078D6?logo=windows11&logoColor=white)](https://github.com/rambros3d/disco-reaper/releases/latest/download/disco-reaper-windows.zip) [![macOS](https://img.shields.io/badge/macOS-000000?logo=apple&logoColor=F0F0F0)](https://github.com/rambros3d/disco-reaper/releases/latest/download/disco-reaper-macos.zip) +### Get it here: [![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black)](https://git.mithraic.cloud/ad3laid3/disco-reaper/releases/latest) [![Windows](https://custom-icon-badges.demolab.com/badge/Windows-0078D6?logo=windows11&logoColor=white)](https://git.mithraic.cloud/ad3laid3/disco-reaper/releases/latest) >Join our [**Reaper Community**](https://fluxer.gg/9KxDP8WH) if you need help or have any questions. @@ -81,7 +81,7 @@ Migrate directly from Discord or your Local Backups to the target community. #### Setup the bots as per this [guide](BOT-SETUP.md) ### Option 1: Using Pre-built Binaries (Easiest) -1. **Download**: Grab the latest version from the [Releases](https://github.com/rambros3d/disco-reaper/releases) page. +1. **Download**: Grab the latest version from the [Releases](https://git.mithraic.cloud/ad3laid3/disco-reaper/releases) page. 2. **Run**: - **Linux**: Run the `disco-reaper` binary (e.g., `./launch.sh` or double-click). - **Windows**: Run `disco-reaper.exe`. @@ -89,7 +89,7 @@ Migrate directly from Discord or your Local Backups to the target community. ### Option 2: Running from Source (To use latest unstable code) 1. **Clone**: Clone the repository to your local machine: ```bash - git clone https://github.com/rambros3d/disco-reaper.git + git clone https://git.mithraic.cloud/ad3laid3/disco-reaper.git cd disco-reaper ``` 2. **Launch**: Run the appropriate launcher script for your OS. It will automatically create a virtual environment and install dependencies: @@ -153,10 +153,4 @@ But now their own website states that **Persona** will be used in some countries ## Contributors - - - - -Made with [contrib.rocks](https://contrib.rocks). - -[![Star History Chart](https://api.star-history.com/image?repos=rambros3d/disco-reaper&type=date&legend=top-left)](https://www.star-history.com/?repos=rambros3d%2Fdisco-reaper&type=date&legend=top-left) +MiTHRAL — fork maintainer, Stoat/Revolt integration & bug fixes. diff --git a/src/core/updater.py b/src/core/updater.py index 671c43f..5480574 100644 --- a/src/core/updater.py +++ b/src/core/updater.py @@ -10,9 +10,7 @@ from src.core.utils import get_app_version logger = logging.getLogger(__name__) -REPO_OWNER = "rambros3d" -REPO_NAME = "disco-reaper" -API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases" +API_URL = "https://git.mithraic.cloud/api/v1/repos/ad3laid3/disco-reaper/releases" def get_current_version() -> str: """Returns the current version string, e.g., '1.0.0'. Strips 'Reaper-' and 'v'.""" @@ -52,7 +50,7 @@ async def check_for_updates() -> Optional[Dict[str, Any]]: try: async with aiohttp.ClientSession() as session: - async with session.get(API_URL, headers={"Accept": "application/vnd.github.v3+json"}) as resp: + async with session.get(API_URL) as resp: if resp.status == 200: releases = await resp.json() if not isinstance(releases, list) or not releases: diff --git a/src/stoat/clone_server.py b/src/stoat/clone_server.py index 32aae67..0c88fbc 100644 --- a/src/stoat/clone_server.py +++ b/src/stoat/clone_server.py @@ -211,7 +211,7 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl parent_id=parent_id ) - cloned_info["cloned_info" if "cloned_info" in locals() else "channels_synced"].append(channel.name) + cloned_info["channels_synced"].append(channel.name) current_idx += 1 if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total) @@ -240,7 +240,7 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl # Resolve Stoat categories # We iterate over the categories from the server to ensure we don't drop any - for stoat_cat in server.categories: + for stoat_cat in (server.categories or []): # Check if this Stoat category maps to any Discord category discord_cat_id = next((d_id for d_id, t_id in context.state.category_map.items() if t_id == str(stoat_cat.id)), None) @@ -278,8 +278,35 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl target_categories.sort(key=get_cat_position) try: - await server.edit(categories=target_categories) - logger.info("Successfully parented all channels.") + import aiohttp + api_base = (context.writer.api_url or "https://api.stoat.chat/0.8").rstrip("/") + cats_payload = [] + for c in target_categories: + entry: dict = {"id": str(c.id), "title": c.title, "channels": [str(x) for x in c.channels]} + if getattr(c, "default_permissions", None) is not None: + entry["default_permissions"] = c.default_permissions + if getattr(c, "role_permissions", None): + entry["role_permissions"] = c.role_permissions + cats_payload.append(entry) + for _attempt in range(5): + async with aiohttp.ClientSession() as session: + async with session.patch( + f"{api_base}/servers/{context.writer.community_id}", + json={"categories": cats_payload}, + headers={"X-Bot-Token": context.writer.token.strip(), "Content-Type": "application/json"}, + ) as resp: + if resp.status == 429: + import re as _re + body = await resp.json(content_type=None) + wait_ms = body.get("retry_after", 10000) + logger.warning(f"Rate limited on mass parent, retrying in {wait_ms/1000:.1f}s…") + await asyncio.sleep(wait_ms / 1000 + 0.5) + continue + if resp.status not in (200, 204): + body = await resp.json(content_type=None) + raise Exception(f"HTTP {resp.status}: {body}") + logger.info("Successfully parented all channels.") + break except Exception as ex: logger.error(f"Failed to mass parent channels: {ex}") diff --git a/src/stoat/writer.py b/src/stoat/writer.py index efe7a8d..b5c2d56 100644 --- a/src/stoat/writer.py +++ b/src/stoat/writer.py @@ -344,61 +344,83 @@ class StoatWriter: async def create_channel(self, name: str, type: int = 0, topic: str = "", parent_id: Optional[str] = None, **kwargs) -> str: server = await self._get_server(populate_channels=True) try: - if type == 4: # Category - # The POST /categories endpoint throws 404 on some server versions, so we use server.edit(categories) - import random - import time + if type == 4: # Category — use direct HTTP to avoid stoat.py auth issues + import aiohttp, random, time chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" - # Mock a ULID - new_id = "01" + "".join(random.choice(chars) for _ in range(24)) - - categories = list(server.categories) if hasattr(server, "categories") and server.categories else [] - # Workaround for stoat.py bug: existing categories may fail to_dict() if slots are uninitialized - for c in categories: - if not hasattr(c, "default_permissions"): c.default_permissions = None - if not hasattr(c, "role_permissions"): c.role_permissions = {} - - new_cat = stoat.Category(id=new_id, title=name, channels=[]) - if not hasattr(new_cat, "default_permissions"): new_cat.default_permissions = None - if not hasattr(new_cat, "role_permissions"): new_cat.role_permissions = {} - categories.append(new_cat) - - await server.edit(categories=categories) - self._server = None # Clear cache after structural change + ts = int(time.time() * 1000) + new_id = format(ts, '010X') + "".join(random.choice(chars) for _ in range(16)) + api_base = (self.api_url or "https://api.stoat.chat/0.8").rstrip("/") + # Fetch current server to get existing categories + async with aiohttp.ClientSession() as session: + async with session.get( + f"{api_base}/servers/{self.community_id}", + headers={"X-Bot-Token": self.token.strip()}, + ) as resp: + sdata = await resp.json() + existing = sdata.get("categories") or [] + existing.append({"id": new_id, "title": name, "channels": []}) + async with session.patch( + f"{api_base}/servers/{self.community_id}", + json={"categories": existing}, + headers={"X-Bot-Token": self.token.strip(), "Content-Type": "application/json"}, + ) as resp: + data = await resp.json() + logger.debug(f"PATCH /servers category resp {resp.status}: {data}") + print(f"[writer] PATCH /servers category resp {resp.status}: {data}") + if resp.status not in (200, 201): + raise Exception(f"HTTP {resp.status}: {data}") + self._server = None return new_id - elif type == 2: # Voice Channel - ch = await server.create_voice_channel(name=name) - self._server = None # Clear cache - return str(ch.id) - else: # Text Channel - ch = await server.create_text_channel(name=name, description=topic) - self._server = None # Clear cache - return str(ch.id) + else: + # Use direct HTTP instead of stoat.py client to avoid aiohttp session issues + import aiohttp + api_base = (self.api_url or "https://api.stoat.chat/0.8").rstrip("/") + channel_type = "Voice" if type == 2 else "Text" + payload: Dict[str, Any] = {"name": name, "type": channel_type} + if topic: + payload["description"] = topic + async with aiohttp.ClientSession() as session: + async with session.post( + f"{api_base}/servers/{self.community_id}/channels", + json=payload, + headers={"X-Bot-Token": self.token.strip()}, + ) as resp: + try: + data = await resp.json(content_type=None) + except Exception: + data = await resp.text() + if resp.status not in (200, 201): + raise Exception(f"HTTP {resp.status}: {data}") + self._server = None + return str(data["_id"]) except Exception as e: print(f"Failed to create Stoat channel {name}: {e}") logger.error(f"Failed to create Stoat channel {name}: {e}") return "" async def modify_channel(self, channel_id: str, name: Optional[str] = None, topic: Optional[str] = None, nsfw: Optional[bool] = None, slowmode_delay: Optional[int] = None, **kwargs) -> bool: - server = await self._get_server(populate_channels=True) + import aiohttp + api_base = (self.api_url or "https://api.stoat.chat/0.8").rstrip("/") + payload: Dict[str, Any] = {} + if name is not None: + payload["name"] = name + if topic is not None: + payload["description"] = topic + if nsfw is not None: + payload["nsfw"] = nsfw + if not payload: + return True try: - channel = next((c for c in server.channels if str(c.id) == channel_id), None) - if not channel: - return False - - edit_kwargs = {} - if name is not None: - edit_kwargs["name"] = name - if topic is not None: - edit_kwargs["description"] = topic - if nsfw is not None: - edit_kwargs["nsfw"] = nsfw - - if edit_kwargs: - await channel.edit(**edit_kwargs) - self._server = None # Clear cache - - # clone_server.py now handles all parenting bulk logic + async with aiohttp.ClientSession() as session: + async with session.patch( + f"{api_base}/channels/{channel_id}", + json=payload, + headers={"X-Bot-Token": self.token.strip()}, + ) as resp: + if resp.status not in (200, 204): + data = await resp.json() + raise Exception(f"HTTP {resp.status}: {data}") + self._server = None return True except Exception as e: print(f"Failed to modify Stoat channel {channel_id}: {e}")