Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Build / packaging
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Tool caches
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# IDE / OS noise
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Runtime artifacts
|
||||||
|
*.log
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# TempVoice (Red-DiscordBot Cog)
|
||||||
|
|
||||||
|
Join-to-create temporary voice channels for Red-DiscordBot.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Per-guild configuration (no hardcoded global channel IDs)
|
||||||
|
- Auto-create temporary voice channel when user joins a configured start channel
|
||||||
|
- Auto-move user to their own temporary channel
|
||||||
|
- Reuse existing temporary channel for the same owner if it still exists
|
||||||
|
- Auto-delete temporary channel when it becomes empty
|
||||||
|
- Owner command to set channel user limit (`0-99`)
|
||||||
|
- Admin commands for setup and cleanup
|
||||||
|
- i18n-ready runtime messages (English + Polish translation file)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Red-DiscordBot `>= 3.5.0`
|
||||||
|
- Python `>= 3.8`
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Put this cog folder in your custom cogs path.
|
||||||
|
2. In Discord:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[p]load tempvoice
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initial Setup (per server)
|
||||||
|
|
||||||
|
Use these commands on each guild:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[p]tempvoice setstart <voice_channel>
|
||||||
|
[p]tempvoice setcategory <category>
|
||||||
|
```
|
||||||
|
|
||||||
|
Check settings:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[p]tempvoice settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Admin (`manage_guild` or admin)
|
||||||
|
|
||||||
|
- `[p]tempvoice settings` - show config and current state
|
||||||
|
- `[p]tempvoice setstart <voice_channel>` - set join-to-create channel
|
||||||
|
- `[p]tempvoice setcategory <category>` - set target category for temp channels
|
||||||
|
- `[p]tempvoice cleanup` - remove stale mappings and delete empty temp channels
|
||||||
|
|
||||||
|
### User (channel owner)
|
||||||
|
|
||||||
|
- `[p]tempvoice setlimit <0-99>` - set user limit on your own temp channel
|
||||||
|
- `[p]setlimit <0-99>` - hidden backward-compatible alias
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. User joins the configured start voice channel.
|
||||||
|
2. Cog creates (or reuses) an owner-mapped temporary voice channel in configured category.
|
||||||
|
3. User is moved to that channel.
|
||||||
|
4. When temporary channel is empty, it is deleted automatically.
|
||||||
|
|
||||||
|
## Data Stored
|
||||||
|
|
||||||
|
Per guild:
|
||||||
|
|
||||||
|
- `start_channel_id`
|
||||||
|
- `target_category_id`
|
||||||
|
- `channel_owners` (mapping: temporary channel ID -> owner user ID)
|
||||||
|
- `schema_version`
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
- Runtime messages are localized via Red i18n.
|
||||||
|
- Included translation file: `locales/pl-PL.po`.
|
||||||
|
- Fallback language is English.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If setup commands succeed but still print command errors, check bot logs and reload cog:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[p]reload tempvoice
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ensure bot has:
|
||||||
|
- Guild permission: `Move Members`
|
||||||
|
- Category/channel permissions: `View Channel`, `Connect`, `Manage Channels`
|
||||||
|
|
||||||
|
- If state looks inconsistent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[p]tempvoice cleanup
|
||||||
|
```
|
||||||
|
|
||||||
5
__init__.py
Normal file
5
__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .tempvoice import TempVoice
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot):
|
||||||
|
await bot.add_cog(TempVoice(bot))
|
||||||
23
info.json
Normal file
23
info.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"author": [
|
||||||
|
"codex"
|
||||||
|
],
|
||||||
|
"install_msg": "TempVoice loaded. Use `[p]tempvoice settings` to review the current server configuration.",
|
||||||
|
"name": "TempVoice",
|
||||||
|
"short": "Join-to-create temporary voice channels",
|
||||||
|
"description": "Creates and manages join-to-create temporary voice channels per server, including owner user-limit control and admin configuration commands.",
|
||||||
|
"end_user_data_statement": "Stores per-server configuration and temporary channel ownership mappings (channel ID to owner ID).",
|
||||||
|
"requirements": [],
|
||||||
|
"tags": [
|
||||||
|
"voice",
|
||||||
|
"temporary",
|
||||||
|
"join-to-create"
|
||||||
|
],
|
||||||
|
"min_bot_version": "3.5.0",
|
||||||
|
"min_python_version": [
|
||||||
|
3,
|
||||||
|
8,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"type": "COG"
|
||||||
|
}
|
||||||
131
locales/pl-PL.po
Normal file
131
locales/pl-PL.po
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: TempVoice 1.0\n"
|
||||||
|
"POT-Creation-Date: 2026-04-18 00:00+0000\n"
|
||||||
|
"PO-Revision-Date: 2026-04-18 00:00+0000\n"
|
||||||
|
"Last-Translator: codex\n"
|
||||||
|
"Language-Team: Polish\n"
|
||||||
|
"Language: pl_PL\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
|
msgid "{mention} your temporary channel was created: **{channel_name}**."
|
||||||
|
msgstr "{mention} utworzono Twój kanał tymczasowy: **{channel_name}**."
|
||||||
|
|
||||||
|
msgid "TempVoice is not fully configured for this server. An administrator should set the start channel and target category with `tempvoice setstart` and `tempvoice setcategory`."
|
||||||
|
msgstr "TempVoice nie jest w pełni skonfigurowany dla tego serwera. Administrator powinien ustawić kanał startowy i kategorię docelową komendami `tempvoice setstart` oraz `tempvoice setcategory`."
|
||||||
|
|
||||||
|
msgid "I can't create a temporary channel because the target category no longer exists."
|
||||||
|
msgstr "Nie mogę utworzyć kanału tymczasowego, bo kategoria docelowa już nie istnieje."
|
||||||
|
|
||||||
|
msgid "I can't move you because I am missing the `move_members` permission."
|
||||||
|
msgstr "Nie mogę Cię przenieść, bo brakuje mi uprawnienia `move_members`."
|
||||||
|
|
||||||
|
msgid "I can't create your temporary channel. I need `view_channel`, `connect`, and `manage_channels` in the target category."
|
||||||
|
msgstr "Nie mogę utworzyć Twojego kanału tymczasowego. Potrzebuję uprawnień `view_channel`, `connect` oraz `manage_channels` w kategorii docelowej."
|
||||||
|
|
||||||
|
msgid "I couldn't move you to your temporary channel."
|
||||||
|
msgstr "Nie udało mi się przenieść Cię na Twój kanał tymczasowy."
|
||||||
|
|
||||||
|
msgid "I can't create your temporary channel because I am missing permissions."
|
||||||
|
msgstr "Nie mogę utworzyć Twojego kanału tymczasowego z powodu brakujących uprawnień."
|
||||||
|
|
||||||
|
msgid "There was an error while creating your temporary channel."
|
||||||
|
msgstr "Wystąpił błąd podczas tworzenia Twojego kanału tymczasowego."
|
||||||
|
|
||||||
|
msgid "I couldn't move you to your newly created temporary channel."
|
||||||
|
msgstr "Nie udało mi się przenieść Cię na nowo utworzony kanał tymczasowy."
|
||||||
|
|
||||||
|
msgid "Invalid value. Please provide a number from 0 to 99 (0 = unlimited)."
|
||||||
|
msgstr "Nieprawidłowa wartość. Podaj liczbę od 0 do 99 (0 = brak limitu)."
|
||||||
|
|
||||||
|
msgid "You must be connected to your own temporary voice channel."
|
||||||
|
msgstr "Musisz być połączony ze swoim kanałem głosowym tymczasowym."
|
||||||
|
|
||||||
|
msgid "This is not a temporary channel managed by TempVoice."
|
||||||
|
msgstr "To nie jest kanał tymczasowy zarządzany przez TempVoice."
|
||||||
|
|
||||||
|
msgid "This temporary channel belongs to another user."
|
||||||
|
msgstr "Ten kanał tymczasowy należy do innego użytkownika."
|
||||||
|
|
||||||
|
msgid "I couldn't verify my permissions right now."
|
||||||
|
msgstr "Nie mogę teraz zweryfikować swoich uprawnień."
|
||||||
|
|
||||||
|
msgid "I can't change the limit because I am missing `manage_channels`."
|
||||||
|
msgstr "Nie mogę zmienić limitu, bo brakuje mi uprawnienia `manage_channels`."
|
||||||
|
|
||||||
|
msgid "I don't have permission to edit this channel's user limit."
|
||||||
|
msgstr "Nie mam uprawnień do edycji limitu użytkowników tego kanału."
|
||||||
|
|
||||||
|
msgid "There was an error while changing the channel user limit."
|
||||||
|
msgstr "Wystąpił błąd podczas zmiany limitu użytkowników kanału."
|
||||||
|
|
||||||
|
msgid "User limit for {channel_mention} is now set to unlimited."
|
||||||
|
msgstr "Limit użytkowników dla {channel_mention} został ustawiony na brak limitu."
|
||||||
|
|
||||||
|
msgid "User limit for {channel_mention} is now set to {limit}."
|
||||||
|
msgstr "Limit użytkowników dla {channel_mention} został ustawiony na {limit}."
|
||||||
|
|
||||||
|
msgid "TempVoice configuration:"
|
||||||
|
msgstr "Konfiguracja TempVoice:"
|
||||||
|
|
||||||
|
msgid "- Configured: {configured}"
|
||||||
|
msgstr "- Skonfigurowane: {configured}"
|
||||||
|
|
||||||
|
msgid "yes"
|
||||||
|
msgstr "tak"
|
||||||
|
|
||||||
|
msgid "no"
|
||||||
|
msgstr "nie"
|
||||||
|
|
||||||
|
msgid "- Start channel: {channel}"
|
||||||
|
msgstr "- Kanał startowy: {channel}"
|
||||||
|
|
||||||
|
msgid "not set"
|
||||||
|
msgstr "nie ustawiono"
|
||||||
|
|
||||||
|
msgid "- Target category: {category}"
|
||||||
|
msgstr "- Kategoria docelowa: {category}"
|
||||||
|
|
||||||
|
msgid "- Active temporary channels: {count}"
|
||||||
|
msgstr "- Aktywne kanały tymczasowe: {count}"
|
||||||
|
|
||||||
|
msgid "- Stale mappings (cleanup candidate): {count}"
|
||||||
|
msgstr "- Osierocone mapowania (do cleanup): {count}"
|
||||||
|
|
||||||
|
msgid "- Complete setup with: `tempvoice setstart <voice_channel>` and `tempvoice setcategory <category>`"
|
||||||
|
msgstr "- Dokończ konfigurację komendami: `tempvoice setstart <kanał_voice>` oraz `tempvoice setcategory <kategoria>`"
|
||||||
|
|
||||||
|
msgid "That channel does not belong to this server."
|
||||||
|
msgstr "Ten kanał nie należy do tego serwera."
|
||||||
|
|
||||||
|
msgid "I can't use this start channel. Missing bot permissions: {missing}."
|
||||||
|
msgstr "Nie mogę użyć tego kanału startowego. Brakujące uprawnienia bota: {missing}."
|
||||||
|
|
||||||
|
msgid "Now set the target category with `tempvoice setcategory`."
|
||||||
|
msgstr "Teraz ustaw kategorię docelową komendą `tempvoice setcategory`."
|
||||||
|
|
||||||
|
msgid "Start channel set to {channel_mention} (`{channel_id}`)."
|
||||||
|
msgstr "Kanał startowy ustawiono na {channel_mention} (`{channel_id}`)."
|
||||||
|
|
||||||
|
msgid "That category does not belong to this server."
|
||||||
|
msgstr "Ta kategoria nie należy do tego serwera."
|
||||||
|
|
||||||
|
msgid "I can't use this category. Missing bot permissions: {missing}."
|
||||||
|
msgstr "Nie mogę użyć tej kategorii. Brakujące uprawnienia bota: {missing}."
|
||||||
|
|
||||||
|
msgid "Now set the start channel with `tempvoice setstart`."
|
||||||
|
msgstr "Teraz ustaw kanał startowy komendą `tempvoice setstart`."
|
||||||
|
|
||||||
|
msgid "Target category set to {category_mention} (`{category_id}`)."
|
||||||
|
msgstr "Kategorię docelową ustawiono na {category_mention} (`{category_id}`)."
|
||||||
|
|
||||||
|
msgid "Cleanup finished. Deleted empty channels: {deleted}. Removed stale mappings: {removed}. Failed deletions: {failed}."
|
||||||
|
msgstr "Cleanup zakończony. Usunięte puste kanały: {deleted}. Usunięte osierocone mapowania: {removed}. Nieudane usunięcia: {failed}."
|
||||||
|
|
||||||
|
msgid "An unexpected error occurred while setting the start channel. Check logs for details."
|
||||||
|
msgstr "Wystąpił nieoczekiwany błąd podczas ustawiania kanału startowego. Sprawdź logi po szczegóły."
|
||||||
|
|
||||||
|
msgid "An unexpected error occurred while setting the target category. Check logs for details."
|
||||||
|
msgstr "Wystąpił nieoczekiwany błąd podczas ustawiania kategorii docelowej. Sprawdź logi po szczegóły."
|
||||||
933
tempvoice.py
Normal file
933
tempvoice.py
Normal file
@@ -0,0 +1,933 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import Config, commands
|
||||||
|
from redbot.core.bot import Red
|
||||||
|
from redbot.core.i18n import Translator, cog_i18n, set_contextual_locales_from_guild
|
||||||
|
|
||||||
|
_ = Translator("TempVoice", __file__)
|
||||||
|
log = logging.getLogger("red.tempvoice")
|
||||||
|
|
||||||
|
|
||||||
|
@cog_i18n(_)
|
||||||
|
class TempVoice(commands.Cog):
|
||||||
|
"""Join-to-create temporary voice channels."""
|
||||||
|
|
||||||
|
CONFIG_SCHEMA_VERSION = 2
|
||||||
|
UNCONFIGURED_NOTICE_COOLDOWN = 600
|
||||||
|
|
||||||
|
def __init__(self, bot: Red) -> None:
|
||||||
|
self.bot = bot
|
||||||
|
self.config = Config.get_conf(self, identifier=1495065310565236910, force_registration=True)
|
||||||
|
self.config.register_guild(
|
||||||
|
start_channel_id=None,
|
||||||
|
target_category_id=None,
|
||||||
|
channel_owners={},
|
||||||
|
schema_version=self.CONFIG_SCHEMA_VERSION,
|
||||||
|
)
|
||||||
|
self._member_locks: Dict[Tuple[int, int], asyncio.Lock] = {}
|
||||||
|
self._guild_locks: Dict[int, asyncio.Lock] = {}
|
||||||
|
self._schema_ready_guilds: Set[int] = set()
|
||||||
|
self._unconfigured_notice_ts: Dict[int, float] = {}
|
||||||
|
|
||||||
|
def cog_unload(self) -> None:
|
||||||
|
self._member_locks.clear()
|
||||||
|
self._guild_locks.clear()
|
||||||
|
self._schema_ready_guilds.clear()
|
||||||
|
self._unconfigured_notice_ts.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_int(value: object) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_voice_name(raw_name: str, fallback_id: int) -> str:
|
||||||
|
name = raw_name.strip()
|
||||||
|
name = re.sub(r"\s+", " ", name)
|
||||||
|
name = re.sub(r"[^\w\- .]", "", name, flags=re.UNICODE)
|
||||||
|
name = name.strip(" .")
|
||||||
|
if not name:
|
||||||
|
name = "user-{}".format(fallback_id)
|
||||||
|
return name[:32]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_channel_owners(raw: object) -> Dict[str, int]:
|
||||||
|
normalized: Dict[str, int] = {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
for raw_channel_id, raw_owner_id in raw.items():
|
||||||
|
channel_id = TempVoice._coerce_int(raw_channel_id)
|
||||||
|
owner_id = TempVoice._coerce_int(raw_owner_id)
|
||||||
|
if channel_id is None or owner_id is None:
|
||||||
|
continue
|
||||||
|
normalized[str(channel_id)] = owner_id
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dedupe_owner_mappings(channel_owners: Dict[str, int]) -> Dict[str, int]:
|
||||||
|
deduped: Dict[str, int] = {}
|
||||||
|
seen_owners: Set[int] = set()
|
||||||
|
for channel_id, owner_id in channel_owners.items():
|
||||||
|
if owner_id in seen_owners:
|
||||||
|
continue
|
||||||
|
deduped[channel_id] = owner_id
|
||||||
|
seen_owners.add(owner_id)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_existing_voice_channels(
|
||||||
|
guild: discord.Guild, channel_owners: Dict[str, int]
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
filtered: Dict[str, int] = {}
|
||||||
|
for channel_id_str, owner_id in channel_owners.items():
|
||||||
|
channel = guild.get_channel(int(channel_id_str))
|
||||||
|
if isinstance(channel, discord.VoiceChannel):
|
||||||
|
filtered[channel_id_str] = owner_id
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_start_channel_id(guild: discord.Guild, channel_id: Optional[int]) -> Optional[int]:
|
||||||
|
if channel_id is None:
|
||||||
|
return None
|
||||||
|
channel = guild.get_channel(channel_id)
|
||||||
|
if isinstance(channel, discord.VoiceChannel):
|
||||||
|
return channel_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_target_category_id(guild: discord.Guild, category_id: Optional[int]) -> Optional[int]:
|
||||||
|
if category_id is None:
|
||||||
|
return None
|
||||||
|
category = guild.get_channel(category_id)
|
||||||
|
if isinstance(category, discord.CategoryChannel):
|
||||||
|
return category_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bot_member(guild: discord.Guild) -> Optional[discord.Member]:
|
||||||
|
return guild.me
|
||||||
|
|
||||||
|
def _get_member_lock(self, guild_id: int, member_id: int) -> asyncio.Lock:
|
||||||
|
key = (guild_id, member_id)
|
||||||
|
lock = self._member_locks.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._member_locks[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
def _release_member_lock(self, guild_id: int, member_id: int, lock: asyncio.Lock) -> None:
|
||||||
|
key = (guild_id, member_id)
|
||||||
|
if self._member_locks.get(key) is lock and not lock.locked():
|
||||||
|
self._member_locks.pop(key, None)
|
||||||
|
|
||||||
|
def _get_guild_lock(self, guild_id: int) -> asyncio.Lock:
|
||||||
|
lock = self._guild_locks.get(guild_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._guild_locks[guild_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
async def _set_locale_context(self, guild: discord.Guild) -> None:
|
||||||
|
try:
|
||||||
|
await set_contextual_locales_from_guild(self.bot, guild)
|
||||||
|
except Exception:
|
||||||
|
log.debug("Failed to set locale context for guild %s (%s).", guild, guild.id)
|
||||||
|
|
||||||
|
def _migrate_legacy_channel_owners(self, guild: discord.Guild, data: dict) -> Dict[str, int]:
|
||||||
|
migrated: Dict[str, int] = {}
|
||||||
|
owners_seen: Set[int] = set()
|
||||||
|
|
||||||
|
raw_owner_to_channel = data.get("owner_to_channel")
|
||||||
|
if isinstance(raw_owner_to_channel, dict):
|
||||||
|
for raw_owner_id, raw_channel_id in raw_owner_to_channel.items():
|
||||||
|
owner_id = self._coerce_int(raw_owner_id)
|
||||||
|
channel_id = self._coerce_int(raw_channel_id)
|
||||||
|
if owner_id is None or channel_id is None:
|
||||||
|
continue
|
||||||
|
channel = guild.get_channel(channel_id)
|
||||||
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
|
continue
|
||||||
|
if owner_id in owners_seen:
|
||||||
|
continue
|
||||||
|
migrated[str(channel_id)] = owner_id
|
||||||
|
owners_seen.add(owner_id)
|
||||||
|
|
||||||
|
raw_channel_to_owner = data.get("channel_to_owner")
|
||||||
|
if isinstance(raw_channel_to_owner, dict):
|
||||||
|
for raw_channel_id, raw_owner_id in raw_channel_to_owner.items():
|
||||||
|
channel_id = self._coerce_int(raw_channel_id)
|
||||||
|
owner_id = self._coerce_int(raw_owner_id)
|
||||||
|
if channel_id is None or owner_id is None:
|
||||||
|
continue
|
||||||
|
if owner_id in owners_seen:
|
||||||
|
continue
|
||||||
|
channel = guild.get_channel(channel_id)
|
||||||
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
|
continue
|
||||||
|
migrated[str(channel_id)] = owner_id
|
||||||
|
owners_seen.add(owner_id)
|
||||||
|
|
||||||
|
raw_channel_owners = self._normalize_channel_owners(data.get("channel_owners"))
|
||||||
|
for channel_id_str, owner_id in raw_channel_owners.items():
|
||||||
|
if owner_id in owners_seen:
|
||||||
|
continue
|
||||||
|
channel = guild.get_channel(int(channel_id_str))
|
||||||
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
|
continue
|
||||||
|
migrated[channel_id_str] = owner_id
|
||||||
|
owners_seen.add(owner_id)
|
||||||
|
|
||||||
|
return migrated
|
||||||
|
|
||||||
|
async def _ensure_guild_schema(self, guild: discord.Guild) -> None:
|
||||||
|
if guild.id in self._schema_ready_guilds:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._get_guild_lock(guild.id):
|
||||||
|
if guild.id in self._schema_ready_guilds:
|
||||||
|
return
|
||||||
|
|
||||||
|
conf = self.config.guild(guild)
|
||||||
|
data = await conf.all()
|
||||||
|
|
||||||
|
schema_version = self._coerce_int(data.get("schema_version"))
|
||||||
|
if schema_version is None:
|
||||||
|
schema_version = 0
|
||||||
|
|
||||||
|
start_channel_id = self._validate_start_channel_id(
|
||||||
|
guild, self._coerce_int(data.get("start_channel_id"))
|
||||||
|
)
|
||||||
|
target_category_id = self._validate_target_category_id(
|
||||||
|
guild, self._coerce_int(data.get("target_category_id"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if schema_version < self.CONFIG_SCHEMA_VERSION:
|
||||||
|
channel_owners = self._migrate_legacy_channel_owners(guild, data)
|
||||||
|
else:
|
||||||
|
channel_owners = self._normalize_channel_owners(data.get("channel_owners"))
|
||||||
|
channel_owners = self._filter_existing_voice_channels(guild, channel_owners)
|
||||||
|
channel_owners = self._dedupe_owner_mappings(channel_owners)
|
||||||
|
|
||||||
|
await conf.start_channel_id.set(start_channel_id)
|
||||||
|
await conf.target_category_id.set(target_category_id)
|
||||||
|
await conf.channel_owners.set(channel_owners)
|
||||||
|
await conf.schema_version.set(self.CONFIG_SCHEMA_VERSION)
|
||||||
|
self._schema_ready_guilds.add(guild.id)
|
||||||
|
|
||||||
|
async def _get_runtime_config(self, guild: discord.Guild) -> Tuple[Optional[int], Optional[int]]:
|
||||||
|
await self._ensure_guild_schema(guild)
|
||||||
|
|
||||||
|
conf = self.config.guild(guild)
|
||||||
|
raw_start_channel_id = self._coerce_int(await conf.start_channel_id())
|
||||||
|
raw_target_category_id = self._coerce_int(await conf.target_category_id())
|
||||||
|
|
||||||
|
start_channel_id = self._validate_start_channel_id(guild, raw_start_channel_id)
|
||||||
|
target_category_id = self._validate_target_category_id(guild, raw_target_category_id)
|
||||||
|
|
||||||
|
if start_channel_id != raw_start_channel_id or target_category_id != raw_target_category_id:
|
||||||
|
async with self._get_guild_lock(guild.id):
|
||||||
|
await conf.start_channel_id.set(start_channel_id)
|
||||||
|
await conf.target_category_id.set(target_category_id)
|
||||||
|
|
||||||
|
return start_channel_id, target_category_id
|
||||||
|
|
||||||
|
async def _get_channel_owners_snapshot(self, guild: discord.Guild) -> Dict[str, int]:
|
||||||
|
await self._ensure_guild_schema(guild)
|
||||||
|
raw = await self.config.guild(guild).channel_owners()
|
||||||
|
normalized = self._normalize_channel_owners(raw)
|
||||||
|
normalized = self._filter_existing_voice_channels(guild, normalized)
|
||||||
|
normalized = self._dedupe_owner_mappings(normalized)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
async def _set_owner_channel_mapping(self, guild: discord.Guild, owner_id: int, channel_id: int) -> None:
|
||||||
|
await self._ensure_guild_schema(guild)
|
||||||
|
async with self._get_guild_lock(guild.id):
|
||||||
|
conf = self.config.guild(guild)
|
||||||
|
channel_owners = self._normalize_channel_owners(await conf.channel_owners())
|
||||||
|
channel_owners = self._filter_existing_voice_channels(guild, channel_owners)
|
||||||
|
channel_owners = self._dedupe_owner_mappings(channel_owners)
|
||||||
|
|
||||||
|
for existing_channel_id_str, mapped_owner_id in list(channel_owners.items()):
|
||||||
|
if mapped_owner_id == owner_id and int(existing_channel_id_str) != channel_id:
|
||||||
|
channel_owners.pop(existing_channel_id_str, None)
|
||||||
|
|
||||||
|
channel_owners[str(channel_id)] = owner_id
|
||||||
|
channel_owners = self._dedupe_owner_mappings(channel_owners)
|
||||||
|
await conf.channel_owners.set(channel_owners)
|
||||||
|
|
||||||
|
async def _remove_channel_mappings_bulk(self, guild: discord.Guild, channel_ids: Iterable[int]) -> int:
|
||||||
|
ids = {int(channel_id) for channel_id in channel_ids}
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
await self._ensure_guild_schema(guild)
|
||||||
|
async with self._get_guild_lock(guild.id):
|
||||||
|
conf = self.config.guild(guild)
|
||||||
|
channel_owners = self._normalize_channel_owners(await conf.channel_owners())
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
for channel_id in ids:
|
||||||
|
if channel_owners.pop(str(channel_id), None) is not None:
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
channel_owners = self._dedupe_owner_mappings(channel_owners)
|
||||||
|
await conf.channel_owners.set(channel_owners)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
async def _remove_mapping_by_owner(self, guild: discord.Guild, owner_id: int) -> None:
|
||||||
|
await self._ensure_guild_schema(guild)
|
||||||
|
async with self._get_guild_lock(guild.id):
|
||||||
|
conf = self.config.guild(guild)
|
||||||
|
channel_owners = self._normalize_channel_owners(await conf.channel_owners())
|
||||||
|
changed = False
|
||||||
|
for channel_id_str, mapped_owner_id in list(channel_owners.items()):
|
||||||
|
if mapped_owner_id == owner_id:
|
||||||
|
channel_owners.pop(channel_id_str, None)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
channel_owners = self._dedupe_owner_mappings(channel_owners)
|
||||||
|
await conf.channel_owners.set(channel_owners)
|
||||||
|
|
||||||
|
async def _get_channel_for_owner(self, guild: discord.Guild, owner_id: int) -> Optional[int]:
|
||||||
|
channel_owners = await self._get_channel_owners_snapshot(guild)
|
||||||
|
stale_channels: Set[int] = set()
|
||||||
|
|
||||||
|
for channel_id_str, mapped_owner_id in channel_owners.items():
|
||||||
|
if mapped_owner_id != owner_id:
|
||||||
|
continue
|
||||||
|
channel_id = int(channel_id_str)
|
||||||
|
channel = guild.get_channel(channel_id)
|
||||||
|
if isinstance(channel, discord.VoiceChannel):
|
||||||
|
return channel_id
|
||||||
|
stale_channels.add(channel_id)
|
||||||
|
|
||||||
|
if stale_channels:
|
||||||
|
await self._remove_channel_mappings_bulk(guild, stale_channels)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_owner_for_channel(self, guild: discord.Guild, channel_id: int) -> Optional[int]:
|
||||||
|
channel_owners = await self._get_channel_owners_snapshot(guild)
|
||||||
|
owner_id = channel_owners.get(str(channel_id))
|
||||||
|
if owner_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel = guild.get_channel(channel_id)
|
||||||
|
if channel is None:
|
||||||
|
await self._remove_channel_mappings_bulk(guild, {channel_id})
|
||||||
|
return None
|
||||||
|
return owner_id
|
||||||
|
|
||||||
|
def _find_fallback_text_channel(self, guild: discord.Guild) -> Optional[discord.TextChannel]:
|
||||||
|
bot_member = self._bot_member(guild)
|
||||||
|
if bot_member is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if guild.system_channel:
|
||||||
|
perms = guild.system_channel.permissions_for(bot_member)
|
||||||
|
if perms.view_channel and perms.send_messages:
|
||||||
|
return guild.system_channel
|
||||||
|
|
||||||
|
for text_channel in guild.text_channels:
|
||||||
|
perms = text_channel.permissions_for(bot_member)
|
||||||
|
if perms.view_channel and perms.send_messages:
|
||||||
|
return text_channel
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_guild_message(
|
||||||
|
self,
|
||||||
|
guild: discord.Guild,
|
||||||
|
content: str,
|
||||||
|
preferred_channel: Optional[discord.abc.GuildChannel] = None,
|
||||||
|
) -> bool:
|
||||||
|
await self._set_locale_context(guild)
|
||||||
|
|
||||||
|
candidates: List[discord.abc.GuildChannel] = []
|
||||||
|
if preferred_channel is not None and callable(getattr(preferred_channel, "send", None)):
|
||||||
|
candidates.append(preferred_channel)
|
||||||
|
|
||||||
|
fallback = self._find_fallback_text_channel(guild)
|
||||||
|
if fallback is not None:
|
||||||
|
candidates.append(fallback)
|
||||||
|
|
||||||
|
used_ids: Set[int] = set()
|
||||||
|
for channel in candidates:
|
||||||
|
channel_id = getattr(channel, "id", None)
|
||||||
|
if channel_id is not None and channel_id in used_ids:
|
||||||
|
continue
|
||||||
|
if channel_id is not None:
|
||||||
|
used_ids.add(channel_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await channel.send(content)
|
||||||
|
return True
|
||||||
|
except (discord.Forbidden, discord.HTTPException):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _send_creation_message(self, member: discord.Member, voice_channel: discord.VoiceChannel) -> None:
|
||||||
|
content = _("{mention} your temporary channel was created: **{channel_name}**.").format(
|
||||||
|
mention=member.mention, channel_name=voice_channel.name
|
||||||
|
)
|
||||||
|
await self._send_guild_message(member.guild, content, preferred_channel=voice_channel)
|
||||||
|
|
||||||
|
async def _send_error_message(self, guild: discord.Guild, user_mention: str, message: str) -> None:
|
||||||
|
await self._send_guild_message(guild, "{mention} {message}".format(mention=user_mention, message=message))
|
||||||
|
|
||||||
|
async def _maybe_notify_unconfigured(self, guild: discord.Guild) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._unconfigured_notice_ts.get(guild.id, 0.0)
|
||||||
|
if now - last < self.UNCONFIGURED_NOTICE_COOLDOWN:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._unconfigured_notice_ts[guild.id] = now
|
||||||
|
await self._send_guild_message(
|
||||||
|
guild,
|
||||||
|
_(
|
||||||
|
"TempVoice is not fully configured for this server. "
|
||||||
|
"An administrator should set the start channel and target category "
|
||||||
|
"with `tempvoice setstart` and `tempvoice setcategory`."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _move_member_to_channel(self, member: discord.Member, channel: discord.VoiceChannel) -> bool:
|
||||||
|
try:
|
||||||
|
await member.move_to(channel, reason="TempVoice: move user to temporary channel")
|
||||||
|
return True
|
||||||
|
except discord.Forbidden:
|
||||||
|
log.warning(
|
||||||
|
"Missing permissions to move user %s (%s) to channel %s (%s).",
|
||||||
|
member,
|
||||||
|
member.id,
|
||||||
|
channel,
|
||||||
|
channel.id,
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
log.exception(
|
||||||
|
"HTTP error while moving user %s (%s) to channel %s (%s).",
|
||||||
|
member,
|
||||||
|
member.id,
|
||||||
|
channel,
|
||||||
|
channel.id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _handle_join_to_create(self, member: discord.Member, target_category_id: int) -> None:
|
||||||
|
guild = member.guild
|
||||||
|
bot_member = self._bot_member(guild)
|
||||||
|
if bot_member is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_category = guild.get_channel(target_category_id)
|
||||||
|
if not isinstance(target_category, discord.CategoryChannel):
|
||||||
|
await self._send_error_message(
|
||||||
|
guild,
|
||||||
|
member.mention,
|
||||||
|
_("I can't create a temporary channel because the target category no longer exists."),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not bot_member.guild_permissions.move_members:
|
||||||
|
await self._send_error_message(
|
||||||
|
guild,
|
||||||
|
member.mention,
|
||||||
|
_("I can't move you because I am missing the `move_members` permission."),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
category_perms = target_category.permissions_for(bot_member)
|
||||||
|
if not (category_perms.view_channel and category_perms.connect and category_perms.manage_channels):
|
||||||
|
await self._send_error_message(
|
||||||
|
guild,
|
||||||
|
member.mention,
|
||||||
|
_(
|
||||||
|
"I can't create your temporary channel. I need `view_channel`, `connect`, "
|
||||||
|
"and `manage_channels` in the target category."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_channel_id = await self._get_channel_for_owner(guild, member.id)
|
||||||
|
if existing_channel_id is not None:
|
||||||
|
existing_channel = guild.get_channel(existing_channel_id)
|
||||||
|
if (
|
||||||
|
isinstance(existing_channel, discord.VoiceChannel)
|
||||||
|
and existing_channel.category_id == target_category_id
|
||||||
|
):
|
||||||
|
moved = await self._move_member_to_channel(member, existing_channel)
|
||||||
|
if not moved:
|
||||||
|
await self._send_error_message(
|
||||||
|
guild, member.mention, _("I couldn't move you to your temporary channel.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._remove_mapping_by_owner(guild, member.id)
|
||||||
|
|
||||||
|
channel_name = self._safe_voice_name(member.display_name or member.name, member.id)
|
||||||
|
try:
|
||||||
|
new_channel = await guild.create_voice_channel(
|
||||||
|
name=channel_name,
|
||||||
|
category=target_category,
|
||||||
|
reason="TempVoice: create temporary voice for {} ({})".format(member, member.id),
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
await self._send_error_message(
|
||||||
|
guild,
|
||||||
|
member.mention,
|
||||||
|
_("I can't create your temporary channel because I am missing permissions."),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except discord.HTTPException:
|
||||||
|
log.exception(
|
||||||
|
"HTTP error while creating a temporary channel for user %s (%s).",
|
||||||
|
member,
|
||||||
|
member.id,
|
||||||
|
)
|
||||||
|
await self._send_error_message(
|
||||||
|
guild,
|
||||||
|
member.mention,
|
||||||
|
_("There was an error while creating your temporary channel."),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._set_owner_channel_mapping(guild, member.id, new_channel.id)
|
||||||
|
|
||||||
|
moved = await self._move_member_to_channel(member, new_channel)
|
||||||
|
if not moved:
|
||||||
|
await self._send_error_message(
|
||||||
|
guild,
|
||||||
|
member.mention,
|
||||||
|
_("I couldn't move you to your newly created temporary channel."),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await new_channel.delete(reason="TempVoice: cleanup after failed member move")
|
||||||
|
except (discord.Forbidden, discord.HTTPException):
|
||||||
|
log.debug(
|
||||||
|
"Failed to delete temporary channel %s (%s) after failed member move.",
|
||||||
|
new_channel,
|
||||||
|
new_channel.id,
|
||||||
|
)
|
||||||
|
await self._remove_channel_mappings_bulk(guild, {new_channel.id})
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._send_creation_message(member, new_channel)
|
||||||
|
|
||||||
|
async def _delete_temp_channel_if_empty(self, channel: discord.abc.GuildChannel) -> None:
|
||||||
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
|
return
|
||||||
|
|
||||||
|
owner_id = await self._get_owner_for_channel(channel.guild, channel.id)
|
||||||
|
if owner_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if channel.members:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await channel.delete(reason="TempVoice: deleting empty temporary channel")
|
||||||
|
except discord.NotFound:
|
||||||
|
await self._remove_channel_mappings_bulk(channel.guild, {channel.id})
|
||||||
|
except discord.Forbidden:
|
||||||
|
log.warning(
|
||||||
|
"Missing permissions to delete temporary channel %s (%s).",
|
||||||
|
channel,
|
||||||
|
channel.id,
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
log.exception(
|
||||||
|
"HTTP error while deleting temporary channel %s (%s).",
|
||||||
|
channel,
|
||||||
|
channel.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._remove_channel_mappings_bulk(channel.guild, {channel.id})
|
||||||
|
|
||||||
|
async def _setlimit_impl(self, ctx: commands.Context, limit: int) -> None:
|
||||||
|
await self._set_locale_context(ctx.guild)
|
||||||
|
|
||||||
|
if limit < 0 or limit > 99:
|
||||||
|
await ctx.send(_("Invalid value. Please provide a number from 0 to 99 (0 = unlimited)."))
|
||||||
|
return
|
||||||
|
|
||||||
|
if ctx.author.voice is None or not isinstance(ctx.author.voice.channel, discord.VoiceChannel):
|
||||||
|
await ctx.send(_("You must be connected to your own temporary voice channel."))
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = ctx.author.voice.channel
|
||||||
|
owner_id = await self._get_owner_for_channel(ctx.guild, channel.id)
|
||||||
|
if owner_id is None:
|
||||||
|
await ctx.send(_("This is not a temporary channel managed by TempVoice."))
|
||||||
|
return
|
||||||
|
|
||||||
|
if owner_id != ctx.author.id:
|
||||||
|
await ctx.send(_("This temporary channel belongs to another user."))
|
||||||
|
return
|
||||||
|
|
||||||
|
bot_member = self._bot_member(ctx.guild)
|
||||||
|
if bot_member is None:
|
||||||
|
await ctx.send(_("I couldn't verify my permissions right now."))
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_perms = channel.permissions_for(bot_member)
|
||||||
|
if not channel_perms.manage_channels:
|
||||||
|
await ctx.send(_("I can't change the limit because I am missing `manage_channels`."))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await channel.edit(
|
||||||
|
user_limit=limit,
|
||||||
|
reason="TempVoice setlimit by {} ({})".format(ctx.author, ctx.author.id),
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
await ctx.send(_("I don't have permission to edit this channel's user limit."))
|
||||||
|
return
|
||||||
|
except discord.HTTPException:
|
||||||
|
log.exception(
|
||||||
|
"HTTP error while changing user limit in channel %s (%s) by %s (%s).",
|
||||||
|
channel,
|
||||||
|
channel.id,
|
||||||
|
ctx.author,
|
||||||
|
ctx.author.id,
|
||||||
|
)
|
||||||
|
await ctx.send(_("There was an error while changing the channel user limit."))
|
||||||
|
return
|
||||||
|
|
||||||
|
if limit == 0:
|
||||||
|
await ctx.send(
|
||||||
|
_("User limit for {channel_mention} is now set to unlimited.").format(
|
||||||
|
channel_mention=channel.mention
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.send(
|
||||||
|
_("User limit for {channel_mention} is now set to {limit}.").format(
|
||||||
|
channel_mention=channel.mention, limit=limit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_voice_state_update(
|
||||||
|
self,
|
||||||
|
member: discord.Member,
|
||||||
|
before: discord.VoiceState,
|
||||||
|
after: discord.VoiceState,
|
||||||
|
) -> None:
|
||||||
|
if member.bot or before.channel == after.channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
guild = member.guild
|
||||||
|
member_lock = self._get_member_lock(guild.id, member.id)
|
||||||
|
try:
|
||||||
|
start_channel_id, target_category_id = await self._get_runtime_config(guild)
|
||||||
|
|
||||||
|
if start_channel_id is None or target_category_id is None:
|
||||||
|
await self._maybe_notify_unconfigured(guild)
|
||||||
|
elif after.channel is not None and after.channel.id == start_channel_id:
|
||||||
|
async with member_lock:
|
||||||
|
current_voice = member.voice.channel if member.voice is not None else None
|
||||||
|
if current_voice is not None and current_voice.id == start_channel_id:
|
||||||
|
await self._handle_join_to_create(member, target_category_id)
|
||||||
|
|
||||||
|
if before.channel is not None:
|
||||||
|
await self._delete_temp_channel_if_empty(before.channel)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"Unexpected exception in on_voice_state_update for member %s (%s).",
|
||||||
|
member,
|
||||||
|
member.id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._release_member_lock(guild.id, member.id, member_lock)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_guild_channel_delete(self, channel: discord.abc.GuildChannel) -> None:
|
||||||
|
try:
|
||||||
|
guild = channel.guild
|
||||||
|
await self._ensure_guild_schema(guild)
|
||||||
|
conf = self.config.guild(guild)
|
||||||
|
|
||||||
|
if isinstance(channel, discord.VoiceChannel):
|
||||||
|
await self._remove_channel_mappings_bulk(guild, {channel.id})
|
||||||
|
|
||||||
|
start_channel_id = self._coerce_int(await conf.start_channel_id())
|
||||||
|
if start_channel_id == channel.id:
|
||||||
|
await conf.start_channel_id.set(None)
|
||||||
|
|
||||||
|
if isinstance(channel, discord.CategoryChannel):
|
||||||
|
target_category_id = self._coerce_int(await conf.target_category_id())
|
||||||
|
if target_category_id == channel.id:
|
||||||
|
await conf.target_category_id.set(None)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"Unexpected exception in on_guild_channel_delete for channel %s (%s).",
|
||||||
|
channel,
|
||||||
|
channel.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_guild_remove(self, guild: discord.Guild) -> None:
|
||||||
|
self._schema_ready_guilds.discard(guild.id)
|
||||||
|
self._unconfigured_notice_ts.pop(guild.id, None)
|
||||||
|
self._guild_locks.pop(guild.id, None)
|
||||||
|
|
||||||
|
@commands.group(name="tempvoice", invoke_without_command=True)
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.admin_or_permissions(manage_guild=True)
|
||||||
|
async def tempvoice_group(self, ctx: commands.Context) -> None:
|
||||||
|
"""Administrative commands for TempVoice."""
|
||||||
|
await ctx.send_help()
|
||||||
|
|
||||||
|
@tempvoice_group.command(name="settings")
|
||||||
|
async def tempvoice_settings(self, ctx: commands.Context) -> None:
|
||||||
|
"""Show TempVoice configuration for this server."""
|
||||||
|
await self._set_locale_context(ctx.guild)
|
||||||
|
|
||||||
|
start_channel_id, target_category_id = await self._get_runtime_config(ctx.guild)
|
||||||
|
channel_owners = await self._get_channel_owners_snapshot(ctx.guild)
|
||||||
|
|
||||||
|
start_channel = ctx.guild.get_channel(start_channel_id) if start_channel_id is not None else None
|
||||||
|
target_category = ctx.guild.get_channel(target_category_id) if target_category_id is not None else None
|
||||||
|
|
||||||
|
active_channels = 0
|
||||||
|
stale_mappings = 0
|
||||||
|
for channel_id_str in channel_owners:
|
||||||
|
channel = ctx.guild.get_channel(int(channel_id_str))
|
||||||
|
if isinstance(channel, discord.VoiceChannel):
|
||||||
|
active_channels += 1
|
||||||
|
else:
|
||||||
|
stale_mappings += 1
|
||||||
|
|
||||||
|
configured = start_channel is not None and target_category is not None
|
||||||
|
lines = [
|
||||||
|
_("TempVoice configuration:"),
|
||||||
|
_("- Configured: {configured}").format(configured=_("yes") if configured else _("no")),
|
||||||
|
_("- Start channel: {channel}").format(
|
||||||
|
channel=start_channel.mention if isinstance(start_channel, discord.VoiceChannel) else _("not set")
|
||||||
|
),
|
||||||
|
_("- Target category: {category}").format(
|
||||||
|
category=target_category.mention
|
||||||
|
if isinstance(target_category, discord.CategoryChannel)
|
||||||
|
else _("not set")
|
||||||
|
),
|
||||||
|
_("- Active temporary channels: {count}").format(count=active_channels),
|
||||||
|
_("- Stale mappings (cleanup candidate): {count}").format(count=stale_mappings),
|
||||||
|
]
|
||||||
|
|
||||||
|
if not configured:
|
||||||
|
lines.append(
|
||||||
|
_(
|
||||||
|
"- Complete setup with: `tempvoice setstart <voice_channel>` "
|
||||||
|
"and `tempvoice setcategory <category>`"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.send("\n".join(lines))
|
||||||
|
|
||||||
|
@tempvoice_group.command(name="setstart")
|
||||||
|
async def tempvoice_setstart(self, ctx: commands.Context, channel: discord.VoiceChannel) -> None:
|
||||||
|
"""Set the join-to-create start voice channel for this server."""
|
||||||
|
await self._set_locale_context(ctx.guild)
|
||||||
|
try:
|
||||||
|
if channel.guild.id != ctx.guild.id:
|
||||||
|
await ctx.send(_("That channel does not belong to this server."))
|
||||||
|
return
|
||||||
|
|
||||||
|
bot_member = self._bot_member(ctx.guild)
|
||||||
|
if bot_member is None:
|
||||||
|
await ctx.send(_("I couldn't verify my permissions right now."))
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_perms = channel.permissions_for(bot_member)
|
||||||
|
missing = []
|
||||||
|
if not channel_perms.view_channel:
|
||||||
|
missing.append("view_channel")
|
||||||
|
if not channel_perms.connect:
|
||||||
|
missing.append("connect")
|
||||||
|
if not bot_member.guild_permissions.move_members:
|
||||||
|
missing.append("move_members")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
await ctx.send(
|
||||||
|
_("I can't use this start channel. Missing bot permissions: {missing}.").format(
|
||||||
|
missing=", ".join(missing)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._ensure_guild_schema(ctx.guild)
|
||||||
|
await self.config.guild(ctx.guild).start_channel_id.set(channel.id)
|
||||||
|
|
||||||
|
suffix = ""
|
||||||
|
try:
|
||||||
|
current_start_channel_id, target_category_id = await self._get_runtime_config(ctx.guild)
|
||||||
|
if target_category_id is None:
|
||||||
|
suffix = " " + _("Now set the target category with `tempvoice setcategory`.")
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"TempVoice setstart post-save validation failed for guild %s (%s).",
|
||||||
|
ctx.guild,
|
||||||
|
ctx.guild.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.send(
|
||||||
|
_("Start channel set to {channel_mention} (`{channel_id}`).").format(
|
||||||
|
channel_mention=channel.mention,
|
||||||
|
channel_id=channel.id,
|
||||||
|
)
|
||||||
|
+ suffix
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"Unexpected exception in tempvoice setstart for guild %s (%s).",
|
||||||
|
ctx.guild,
|
||||||
|
ctx.guild.id,
|
||||||
|
)
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"An unexpected error occurred while setting the start channel. "
|
||||||
|
"Check logs for details."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@tempvoice_group.command(name="setcategory")
|
||||||
|
async def tempvoice_setcategory(self, ctx: commands.Context, category: discord.CategoryChannel) -> None:
|
||||||
|
"""Set the target category for temporary channels in this server."""
|
||||||
|
await self._set_locale_context(ctx.guild)
|
||||||
|
try:
|
||||||
|
if category.guild.id != ctx.guild.id:
|
||||||
|
await ctx.send(_("That category does not belong to this server."))
|
||||||
|
return
|
||||||
|
|
||||||
|
bot_member = self._bot_member(ctx.guild)
|
||||||
|
if bot_member is None:
|
||||||
|
await ctx.send(_("I couldn't verify my permissions right now."))
|
||||||
|
return
|
||||||
|
|
||||||
|
perms = category.permissions_for(bot_member)
|
||||||
|
missing = []
|
||||||
|
if not perms.view_channel:
|
||||||
|
missing.append("view_channel")
|
||||||
|
if not perms.connect:
|
||||||
|
missing.append("connect")
|
||||||
|
if not perms.manage_channels:
|
||||||
|
missing.append("manage_channels")
|
||||||
|
if not bot_member.guild_permissions.move_members:
|
||||||
|
missing.append("move_members")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
await ctx.send(
|
||||||
|
_("I can't use this category. Missing bot permissions: {missing}.").format(
|
||||||
|
missing=", ".join(missing)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._ensure_guild_schema(ctx.guild)
|
||||||
|
await self.config.guild(ctx.guild).target_category_id.set(category.id)
|
||||||
|
|
||||||
|
suffix = ""
|
||||||
|
try:
|
||||||
|
start_channel_id, current_target_category_id = await self._get_runtime_config(ctx.guild)
|
||||||
|
if start_channel_id is None:
|
||||||
|
suffix = " " + _("Now set the start channel with `tempvoice setstart`.")
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"TempVoice setcategory post-save validation failed for guild %s (%s).",
|
||||||
|
ctx.guild,
|
||||||
|
ctx.guild.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.send(
|
||||||
|
_("Target category set to {category_mention} (`{category_id}`).").format(
|
||||||
|
category_mention=category.mention,
|
||||||
|
category_id=category.id,
|
||||||
|
)
|
||||||
|
+ suffix
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"Unexpected exception in tempvoice setcategory for guild %s (%s).",
|
||||||
|
ctx.guild,
|
||||||
|
ctx.guild.id,
|
||||||
|
)
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"An unexpected error occurred while setting the target category. "
|
||||||
|
"Check logs for details."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@tempvoice_group.command(name="cleanup")
|
||||||
|
async def tempvoice_cleanup(self, ctx: commands.Context) -> None:
|
||||||
|
"""Clean stale mappings and remove empty temporary channels."""
|
||||||
|
await self._set_locale_context(ctx.guild)
|
||||||
|
|
||||||
|
guild = ctx.guild
|
||||||
|
current_start_channel_id, target_category_id = await self._get_runtime_config(guild)
|
||||||
|
|
||||||
|
channel_owners = await self._get_channel_owners_snapshot(guild)
|
||||||
|
stale_channel_ids: Set[int] = set()
|
||||||
|
channels_to_delete: List[discord.VoiceChannel] = []
|
||||||
|
|
||||||
|
for channel_id_str in channel_owners:
|
||||||
|
channel_id = int(channel_id_str)
|
||||||
|
channel = guild.get_channel(channel_id)
|
||||||
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
|
stale_channel_ids.add(channel_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_category_id is not None and channel.category_id != target_category_id:
|
||||||
|
stale_channel_ids.add(channel_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not channel.members:
|
||||||
|
channels_to_delete.append(channel)
|
||||||
|
|
||||||
|
deleted_channel_ids: Set[int] = set()
|
||||||
|
failed_deletions = 0
|
||||||
|
for channel in channels_to_delete:
|
||||||
|
try:
|
||||||
|
await channel.delete(reason="TempVoice cleanup command: delete empty temporary channel")
|
||||||
|
deleted_channel_ids.add(channel.id)
|
||||||
|
except (discord.Forbidden, discord.HTTPException):
|
||||||
|
failed_deletions += 1
|
||||||
|
|
||||||
|
removed_mapping_count = await self._remove_channel_mappings_bulk(
|
||||||
|
guild,
|
||||||
|
stale_channel_ids.union(deleted_channel_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.send(
|
||||||
|
_(
|
||||||
|
"Cleanup finished. Deleted empty channels: {deleted}. "
|
||||||
|
"Removed stale mappings: {removed}. Failed deletions: {failed}."
|
||||||
|
).format(
|
||||||
|
deleted=len(deleted_channel_ids),
|
||||||
|
removed=removed_mapping_count,
|
||||||
|
failed=failed_deletions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@tempvoice_group.command(name="setlimit")
|
||||||
|
@commands.guild_only()
|
||||||
|
async def tempvoice_setlimit(self, ctx: commands.Context, limit: int) -> None:
|
||||||
|
"""Set user limit (0-99) on your own temporary voice channel."""
|
||||||
|
await self._setlimit_impl(ctx, limit)
|
||||||
|
|
||||||
|
@commands.command(name="setlimit", hidden=True)
|
||||||
|
@commands.guild_only()
|
||||||
|
async def setlimit_alias(self, ctx: commands.Context, limit: int) -> None:
|
||||||
|
"""Backward-compatible alias for tempvoice setlimit."""
|
||||||
|
await self._setlimit_impl(ctx, limit)
|
||||||
Reference in New Issue
Block a user