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 <noreply@anthropic.com>
This commit is contained in:
MiTHRAL 2026-04-21 20:27:54 -04:00
parent 7fd487ad66
commit 0898003749
4 changed files with 105 additions and 64 deletions

View file

@ -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. **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. >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) #### Setup the bots as per this [guide](BOT-SETUP.md)
### Option 1: Using Pre-built Binaries (Easiest) ### 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**: 2. **Run**:
- **Linux**: Run the `disco-reaper` binary (e.g., `./launch.sh` or double-click). - **Linux**: Run the `disco-reaper` binary (e.g., `./launch.sh` or double-click).
- **Windows**: Run `disco-reaper.exe`. - **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) ### Option 2: Running from Source (To use latest unstable code)
1. **Clone**: Clone the repository to your local machine: 1. **Clone**: Clone the repository to your local machine:
```bash ```bash
git clone https://github.com/rambros3d/disco-reaper.git git clone https://git.mithraic.cloud/ad3laid3/disco-reaper.git
cd disco-reaper cd disco-reaper
``` ```
2. **Launch**: Run the appropriate launcher script for your OS. It will automatically create a virtual environment and install dependencies: 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 ## Contributors
<a href="https://github.com/rambros3d/disco-reaper/graphs/contributors"> MiTHRAL — fork maintainer, Stoat/Revolt integration & bug fixes.
<img src="https://contrib.rocks/image?repo=rambros3d/disco-reaper" />
</a>
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)

View file

@ -10,9 +10,7 @@ from src.core.utils import get_app_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REPO_OWNER = "rambros3d" API_URL = "https://git.mithraic.cloud/api/v1/repos/ad3laid3/disco-reaper/releases"
REPO_NAME = "disco-reaper"
API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases"
def get_current_version() -> str: def get_current_version() -> str:
"""Returns the current version string, e.g., '1.0.0'. Strips 'Reaper-' and 'v'.""" """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: try:
async with aiohttp.ClientSession() as session: 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: if resp.status == 200:
releases = await resp.json() releases = await resp.json()
if not isinstance(releases, list) or not releases: if not isinstance(releases, list) or not releases:

View file

@ -211,7 +211,7 @@ async def migrate_channels(context: MigrationContext, progress_callback: Callabl
parent_id=parent_id 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 current_idx += 1
if progress_callback: await progress_callback(channel.name, "Syncing", current_idx, total) 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 # Resolve Stoat categories
# We iterate over the categories from the server to ensure we don't drop any # 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 # 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) 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) target_categories.sort(key=get_cat_position)
try: try:
await server.edit(categories=target_categories) import aiohttp
logger.info("Successfully parented all channels.") 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: except Exception as ex:
logger.error(f"Failed to mass parent channels: {ex}") logger.error(f"Failed to mass parent channels: {ex}")

View file

@ -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: 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) server = await self._get_server(populate_channels=True)
try: try:
if type == 4: # Category if type == 4: # Category — use direct HTTP to avoid stoat.py auth issues
# The POST /categories endpoint throws 404 on some server versions, so we use server.edit(categories) import aiohttp, random, time
import random
import time
chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
# Mock a ULID ts = int(time.time() * 1000)
new_id = "01" + "".join(random.choice(chars) for _ in range(24)) 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("/")
categories = list(server.categories) if hasattr(server, "categories") and server.categories else [] # Fetch current server to get existing categories
# Workaround for stoat.py bug: existing categories may fail to_dict() if slots are uninitialized async with aiohttp.ClientSession() as session:
for c in categories: async with session.get(
if not hasattr(c, "default_permissions"): c.default_permissions = None f"{api_base}/servers/{self.community_id}",
if not hasattr(c, "role_permissions"): c.role_permissions = {} headers={"X-Bot-Token": self.token.strip()},
) as resp:
new_cat = stoat.Category(id=new_id, title=name, channels=[]) sdata = await resp.json()
if not hasattr(new_cat, "default_permissions"): new_cat.default_permissions = None existing = sdata.get("categories") or []
if not hasattr(new_cat, "role_permissions"): new_cat.role_permissions = {} existing.append({"id": new_id, "title": name, "channels": []})
categories.append(new_cat) async with session.patch(
f"{api_base}/servers/{self.community_id}",
await server.edit(categories=categories) json={"categories": existing},
self._server = None # Clear cache after structural change 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 return new_id
elif type == 2: # Voice Channel else:
ch = await server.create_voice_channel(name=name) # Use direct HTTP instead of stoat.py client to avoid aiohttp session issues
self._server = None # Clear cache import aiohttp
return str(ch.id) api_base = (self.api_url or "https://api.stoat.chat/0.8").rstrip("/")
else: # Text Channel channel_type = "Voice" if type == 2 else "Text"
ch = await server.create_text_channel(name=name, description=topic) payload: Dict[str, Any] = {"name": name, "type": channel_type}
self._server = None # Clear cache if topic:
return str(ch.id) 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: except Exception as e:
print(f"Failed to create Stoat channel {name}: {e}") print(f"Failed to create Stoat channel {name}: {e}")
logger.error(f"Failed to create Stoat channel {name}: {e}") logger.error(f"Failed to create Stoat channel {name}: {e}")
return "" 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: 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: try:
channel = next((c for c in server.channels if str(c.id) == channel_id), None) async with aiohttp.ClientSession() as session:
if not channel: async with session.patch(
return False f"{api_base}/channels/{channel_id}",
json=payload,
edit_kwargs = {} headers={"X-Bot-Token": self.token.strip()},
if name is not None: ) as resp:
edit_kwargs["name"] = name if resp.status not in (200, 204):
if topic is not None: data = await resp.json()
edit_kwargs["description"] = topic raise Exception(f"HTTP {resp.status}: {data}")
if nsfw is not None: self._server = 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
return True return True
except Exception as e: except Exception as e:
print(f"Failed to modify Stoat channel {channel_id}: {e}") print(f"Failed to modify Stoat channel {channel_id}: {e}")