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:
parent
7fd487ad66
commit
0898003749
4 changed files with 105 additions and 64 deletions
14
README.md
14
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.
|
**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: [](https://github.com/rambros3d/disco-reaper/releases/latest/download/disco-reaper-linux.zip) [](https://github.com/rambros3d/disco-reaper/releases/latest/download/disco-reaper-windows.zip) [](https://github.com/rambros3d/disco-reaper/releases/latest/download/disco-reaper-macos.zip)
|
### Get it here: [](https://git.mithraic.cloud/ad3laid3/disco-reaper/releases/latest) [](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).
|
|
||||||
|
|
||||||
[](https://www.star-history.com/?repos=rambros3d%2Fdisco-reaper&type=date&legend=top-left)
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
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.")
|
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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
try:
|
api_base = (self.api_url or "https://api.stoat.chat/0.8").rstrip("/")
|
||||||
channel = next((c for c in server.channels if str(c.id) == channel_id), None)
|
payload: Dict[str, Any] = {}
|
||||||
if not channel:
|
|
||||||
return False
|
|
||||||
|
|
||||||
edit_kwargs = {}
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
edit_kwargs["name"] = name
|
payload["name"] = name
|
||||||
if topic is not None:
|
if topic is not None:
|
||||||
edit_kwargs["description"] = topic
|
payload["description"] = topic
|
||||||
if nsfw is not None:
|
if nsfw is not None:
|
||||||
edit_kwargs["nsfw"] = nsfw
|
payload["nsfw"] = nsfw
|
||||||
|
if not payload:
|
||||||
if edit_kwargs:
|
return True
|
||||||
await channel.edit(**edit_kwargs)
|
try:
|
||||||
self._server = None # Clear cache
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.patch(
|
||||||
# clone_server.py now handles all parenting bulk logic
|
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
|
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}")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue