Add multi-account command ACL system
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
TG_API_ID=123456
|
||||
TG_API_HASH=your_api_hash_here
|
||||
SESSION_DIR=sessions
|
||||
ACCOUNTS_JSON=[{"name":"personal","session":"personal"},{"name":"work","session":"work"}]
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
sessions/
|
||||
*.acl.json
|
||||
config.json
|
||||
.env
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""super-bot-tg package."""
|
||||
34
app/commands/__init__.py
Normal file
34
app/commands/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from app.commands.acl import RuntimeAclStore
|
||||
from app.commands.builtins import DEFAULT_REGISTRY, command_registry
|
||||
from app.commands.core import (
|
||||
CheckResult,
|
||||
CommandBuilder,
|
||||
CommandCheck,
|
||||
CommandContext,
|
||||
CommandDefinition,
|
||||
CommandRegistry,
|
||||
combine_checks,
|
||||
owner_only_check,
|
||||
require_args_check,
|
||||
require_reply_check,
|
||||
shared_access_check,
|
||||
)
|
||||
from app.commands.dispatcher import CommandDispatcher
|
||||
|
||||
__all__ = [
|
||||
"CheckResult",
|
||||
"CommandBuilder",
|
||||
"CommandCheck",
|
||||
"CommandContext",
|
||||
"CommandDefinition",
|
||||
"CommandDispatcher",
|
||||
"CommandRegistry",
|
||||
"DEFAULT_REGISTRY",
|
||||
"RuntimeAclStore",
|
||||
"command_registry",
|
||||
"combine_checks",
|
||||
"owner_only_check",
|
||||
"require_args_check",
|
||||
"require_reply_check",
|
||||
"shared_access_check",
|
||||
]
|
||||
161
app/commands/acl.py
Normal file
161
app/commands/acl.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from threading import RLock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_command(value: str) -> str:
|
||||
token = value.strip().lower()
|
||||
token = token.lstrip(".")
|
||||
if "@" in token:
|
||||
token = token.split("@", 1)[0]
|
||||
return token
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeAclStore:
|
||||
path: Path
|
||||
data: dict[str, dict[str, set[str]]] = field(init=False)
|
||||
_lock: RLock = field(init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = RLock()
|
||||
self.data = {"allow": {}, "deny": {}}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
with self._lock:
|
||||
if not self.path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
log.warning("Ignoring malformed ACL file: %s", self.path)
|
||||
return
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
log.warning("Ignoring ACL file with unexpected root type: %s", self.path)
|
||||
return
|
||||
|
||||
allow_raw = raw.get("allow", {})
|
||||
deny_raw = raw.get("deny", {})
|
||||
self.data = {
|
||||
"allow": {
|
||||
str(user_id): {str(command) for command in commands}
|
||||
for user_id, commands in allow_raw.items()
|
||||
},
|
||||
"deny": {
|
||||
str(user_id): {str(command) for command in commands}
|
||||
for user_id, commands in deny_raw.items()
|
||||
},
|
||||
}
|
||||
|
||||
def save(self) -> None:
|
||||
with self._lock:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"allow": {
|
||||
user_id: sorted(commands)
|
||||
for user_id, commands in sorted(self.data["allow"].items())
|
||||
if commands
|
||||
},
|
||||
"deny": {
|
||||
user_id: sorted(commands)
|
||||
for user_id, commands in sorted(self.data["deny"].items())
|
||||
if commands
|
||||
},
|
||||
}
|
||||
|
||||
serialized = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
with NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
dir=self.path.parent,
|
||||
prefix=self.path.name,
|
||||
suffix=".tmp",
|
||||
) as handle:
|
||||
handle.write(serialized)
|
||||
temp_path = Path(handle.name)
|
||||
temp_path.replace(self.path)
|
||||
|
||||
def _allow_set(self, user_id: int) -> set[str]:
|
||||
return self.data["allow"].setdefault(str(user_id), set())
|
||||
|
||||
def _deny_set(self, user_id: int) -> set[str]:
|
||||
return self.data["deny"].setdefault(str(user_id), set())
|
||||
|
||||
def allow(self, user_id: int, command: str) -> bool:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
if not command_name:
|
||||
return False
|
||||
|
||||
allow_set = self._allow_set(user_id)
|
||||
deny_set = self._deny_set(user_id)
|
||||
changed = command_name not in allow_set or command_name in deny_set
|
||||
allow_set.add(command_name)
|
||||
deny_set.discard(command_name)
|
||||
if changed:
|
||||
self.save()
|
||||
return changed
|
||||
|
||||
def deny(self, user_id: int, command: str) -> bool:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
if not command_name:
|
||||
return False
|
||||
|
||||
allow_set = self._allow_set(user_id)
|
||||
deny_set = self._deny_set(user_id)
|
||||
changed = command_name not in deny_set or command_name in allow_set
|
||||
deny_set.add(command_name)
|
||||
allow_set.discard(command_name)
|
||||
if changed:
|
||||
self.save()
|
||||
return changed
|
||||
|
||||
def is_allowed(self, user_id: int, command: str) -> bool:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
user_key = str(user_id)
|
||||
deny_set = self.data["deny"].get(user_key, set())
|
||||
if command_name in deny_set:
|
||||
return False
|
||||
allow_set = self.data["allow"].get(user_key, set())
|
||||
return command_name in allow_set
|
||||
|
||||
def list_allowed(self, user_id: int) -> list[str]:
|
||||
with self._lock:
|
||||
return sorted(self.data["allow"].get(str(user_id), set()))
|
||||
|
||||
def list_denied(self, user_id: int) -> list[str]:
|
||||
with self._lock:
|
||||
return sorted(self.data["deny"].get(str(user_id), set()))
|
||||
|
||||
def count_allowed_users(self, command: str) -> int:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
return sum(1 for commands in self.data["allow"].values() if command_name in commands)
|
||||
|
||||
def count_denied_users(self, command: str) -> int:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
return sum(1 for commands in self.data["deny"].values() if command_name in commands)
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
with self._lock:
|
||||
return {
|
||||
"allowed_users": len(self.data["allow"]),
|
||||
"denied_users": len(self.data["deny"]),
|
||||
"allow_rules": sum(len(commands) for commands in self.data["allow"].values()),
|
||||
"deny_rules": sum(len(commands) for commands in self.data["deny"].values()),
|
||||
}
|
||||
220
app/commands/builtins.py
Normal file
220
app/commands/builtins.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.commands.core import CommandRegistry, normalize_command_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.commands.core import CommandContext
|
||||
|
||||
|
||||
command_registry = CommandRegistry()
|
||||
DEFAULT_REGISTRY = command_registry
|
||||
|
||||
|
||||
def _user_label(user: Any | None) -> str:
|
||||
if user is None:
|
||||
return "unknown"
|
||||
parts = [getattr(user, "first_name", None), getattr(user, "last_name", None)]
|
||||
full_name = " ".join(part for part in parts if part)
|
||||
username = getattr(user, "username", None)
|
||||
if full_name and username:
|
||||
return f"{full_name} (@{username})"
|
||||
if full_name:
|
||||
return full_name
|
||||
if username:
|
||||
return f"@{username}"
|
||||
return str(getattr(user, "id", "unknown"))
|
||||
|
||||
|
||||
def _display_command(command: str) -> str:
|
||||
return f"..{command}"
|
||||
|
||||
|
||||
def _format_permissions(ctx: "CommandContext") -> str:
|
||||
store = ctx.acl_store
|
||||
target = ctx.replied_user
|
||||
if store is None:
|
||||
return "Permission store is not initialized"
|
||||
if target is None:
|
||||
return "Reply to a user's message first"
|
||||
|
||||
allowed = store.list_allowed(target.id)
|
||||
denied = store.list_denied(target.id)
|
||||
lines = [f"Permissions for {_user_label(target)} ({target.id}):"]
|
||||
lines.append(
|
||||
"allowed: "
|
||||
+ (", ".join(_display_command(command) for command in sorted(allowed)) if allowed else "none")
|
||||
)
|
||||
lines.append(
|
||||
"denied: "
|
||||
+ (", ".join(_display_command(command) for command in sorted(denied)) if denied else "none")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _resolve_target_commands(ctx: "CommandContext") -> tuple[list[str], list[str]]:
|
||||
commands: list[str] = []
|
||||
errors: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for token in ctx.argv:
|
||||
for part in (piece.strip() for piece in token.split(",")):
|
||||
normalized = normalize_command_name(part)
|
||||
if not normalized:
|
||||
continue
|
||||
definition = ctx.manager.command_registry.resolve(normalized)
|
||||
if definition is None:
|
||||
errors.append(f"Unknown command: {part}")
|
||||
continue
|
||||
if not definition.is_shared:
|
||||
errors.append(f"{definition.display_name} is owner-only and cannot be granted")
|
||||
continue
|
||||
if definition.name in seen:
|
||||
continue
|
||||
seen.add(definition.name)
|
||||
commands.append(definition.name)
|
||||
|
||||
return commands, errors
|
||||
|
||||
|
||||
async def _change_permissions(ctx: "CommandContext", *, allow: bool) -> None:
|
||||
target = ctx.replied_user
|
||||
if target is None:
|
||||
await ctx.reply("Reply to the user's message first")
|
||||
return
|
||||
|
||||
owner_id = ctx.owner_id
|
||||
if owner_id is not None and target.id == owner_id:
|
||||
await ctx.reply("Owner access cannot be changed")
|
||||
return
|
||||
|
||||
commands, errors = _resolve_target_commands(ctx)
|
||||
if not commands and not errors:
|
||||
await ctx.reply("Provide at least one command name")
|
||||
return
|
||||
|
||||
store = ctx.acl_store
|
||||
if store is None:
|
||||
await ctx.reply("Permission store is not initialized")
|
||||
return
|
||||
|
||||
changed: list[str] = []
|
||||
unchanged: list[str] = []
|
||||
|
||||
for command in commands:
|
||||
did_change = store.allow(target.id, command) if allow else store.deny(target.id, command)
|
||||
if did_change:
|
||||
changed.append(command)
|
||||
else:
|
||||
unchanged.append(command)
|
||||
|
||||
lines = [
|
||||
f"Target: {_user_label(target)} ({target.id})",
|
||||
f"Action: {'allow' if allow else 'deny'}",
|
||||
]
|
||||
if changed:
|
||||
lines.append("Changed: " + ", ".join(_display_command(command) for command in sorted(changed)))
|
||||
if unchanged:
|
||||
lines.append(
|
||||
"Already set: " + ", ".join(_display_command(command) for command in sorted(unchanged))
|
||||
)
|
||||
if errors:
|
||||
lines.append("Errors:")
|
||||
lines.extend(f"- {error}" for error in errors)
|
||||
await ctx.reply("\n".join(lines))
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"ping",
|
||||
"Check that the account is alive",
|
||||
usage=".ping",
|
||||
category="system",
|
||||
)
|
||||
async def ping(ctx: "CommandContext") -> None:
|
||||
me = ctx.account.me
|
||||
await ctx.reply(
|
||||
"\n".join(
|
||||
[
|
||||
"pong",
|
||||
f"account: {ctx.account.config.name}",
|
||||
f"session: {ctx.account.config.session}",
|
||||
f"user_id: {getattr(me, 'id', 'unknown')}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"status",
|
||||
"Show account and ACL state",
|
||||
usage=".status",
|
||||
category="system",
|
||||
)
|
||||
async def status(ctx: "CommandContext") -> None:
|
||||
lines = [ctx.manager.build_status_report()]
|
||||
store = ctx.acl_store
|
||||
if store is not None:
|
||||
stats = store.stats()
|
||||
lines.append(
|
||||
"ACL stats: "
|
||||
f"allowed_users={stats['allowed_users']}, "
|
||||
f"denied_users={stats['denied_users']}, "
|
||||
f"allow_rules={stats['allow_rules']}, "
|
||||
f"deny_rules={stats['deny_rules']}"
|
||||
)
|
||||
await ctx.reply("\n".join(lines))
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"help",
|
||||
"Show command catalog",
|
||||
usage=".help",
|
||||
category="system",
|
||||
)
|
||||
async def help_command(ctx: "CommandContext") -> None:
|
||||
await ctx.reply(ctx.manager.command_registry.render_help(ctx))
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"rights",
|
||||
"Show ACL state for the replied user",
|
||||
usage=".rights",
|
||||
category="access",
|
||||
).alias("acl").require_reply()
|
||||
async def rights(ctx: "CommandContext") -> None:
|
||||
await ctx.reply(_format_permissions(ctx))
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"allow",
|
||||
"Grant a command to the replied user",
|
||||
usage=".allow <command> [more...]",
|
||||
category="access",
|
||||
).require_reply().require_args()
|
||||
async def allow(ctx: "CommandContext") -> None:
|
||||
await _change_permissions(ctx, allow=True)
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"deny",
|
||||
"Revoke a command from the replied user",
|
||||
usage=".deny <command> [more...]",
|
||||
category="access",
|
||||
).require_reply().require_args()
|
||||
async def deny(ctx: "CommandContext") -> None:
|
||||
await _change_permissions(ctx, allow=False)
|
||||
|
||||
|
||||
@command_registry.command(
|
||||
"echo",
|
||||
"Echo text for granted users",
|
||||
usage="..echo <text>",
|
||||
category="shared",
|
||||
).shared()
|
||||
async def echo(ctx: "CommandContext") -> None:
|
||||
text = ctx.args.strip()
|
||||
if not text:
|
||||
await ctx.reply("Usage: ..echo <text>")
|
||||
return
|
||||
await ctx.reply(text)
|
||||
347
app/commands/core.py
Normal file
347
app/commands/core.py
Normal file
@@ -0,0 +1,347 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
import inspect
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.commands.acl import RuntimeAclStore
|
||||
from app.manager import AccountManager, AccountState
|
||||
from pyrogram import Client
|
||||
from pyrogram.types import Message
|
||||
|
||||
|
||||
CommandHandler = Callable[["CommandContext"], Awaitable[None]]
|
||||
CommandCheckFn = Callable[["CommandContext"], Any]
|
||||
|
||||
|
||||
def normalize_command_name(value: str) -> str:
|
||||
token = value.strip().lower()
|
||||
token = token.lstrip(".")
|
||||
if "@" in token:
|
||||
token = token.split("@", 1)[0]
|
||||
return token
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CheckResult:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
|
||||
@classmethod
|
||||
def allow(cls) -> "CheckResult":
|
||||
return cls(True, None)
|
||||
|
||||
@classmethod
|
||||
def deny(cls, reason: str) -> "CheckResult":
|
||||
return cls(False, reason)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommandCheck:
|
||||
name: str
|
||||
callback: CommandCheckFn
|
||||
description: str = ""
|
||||
|
||||
async def run(self, ctx: "CommandContext") -> CheckResult:
|
||||
result = self.callback(ctx)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if isinstance(result, CheckResult):
|
||||
return result
|
||||
if result is None or result is True:
|
||||
return CheckResult.allow()
|
||||
if result is False:
|
||||
return CheckResult.deny(f"Check failed: {self.name}")
|
||||
if isinstance(result, str):
|
||||
return CheckResult.deny(result)
|
||||
raise TypeError(f"Unsupported check result from {self.name}: {type(result)!r}")
|
||||
|
||||
|
||||
def owner_only_check() -> CommandCheck:
|
||||
async def _check(ctx: "CommandContext") -> CheckResult:
|
||||
if ctx.owner_id is None:
|
||||
return CheckResult.deny("Account owner is not available yet")
|
||||
if ctx.actor_id != ctx.owner_id:
|
||||
return CheckResult.deny("Only the account owner can use this command")
|
||||
return CheckResult.allow()
|
||||
|
||||
return CommandCheck("owner_only", _check, "Allow only the account owner")
|
||||
|
||||
|
||||
def shared_access_check() -> CommandCheck:
|
||||
async def _check(ctx: "CommandContext") -> CheckResult:
|
||||
if ctx.owner_id is not None and ctx.actor_id == ctx.owner_id:
|
||||
return CheckResult.allow()
|
||||
if ctx.actor_id is None:
|
||||
return CheckResult.deny("This command requires a Telegram user identity")
|
||||
store = ctx.acl_store
|
||||
if store is None:
|
||||
return CheckResult.deny("Permission store is not initialized")
|
||||
if store.is_allowed(ctx.actor_id, ctx.command.name):
|
||||
return CheckResult.allow()
|
||||
return CheckResult.deny(f"No access to {ctx.command.prefix}{ctx.command.name}")
|
||||
|
||||
return CommandCheck("shared_access", _check, "Allow owner or ACL-granted users")
|
||||
|
||||
|
||||
def require_reply_check() -> CommandCheck:
|
||||
async def _check(ctx: "CommandContext") -> CheckResult:
|
||||
if ctx.replied_user_id is None:
|
||||
return CheckResult.deny("Reply to a user's message first")
|
||||
return CheckResult.allow()
|
||||
|
||||
return CommandCheck("require_reply", _check, "Require a replied-to user message")
|
||||
|
||||
|
||||
def require_args_check() -> CommandCheck:
|
||||
async def _check(ctx: "CommandContext") -> CheckResult:
|
||||
if not ctx.args.strip():
|
||||
return CheckResult.deny("This command expects arguments")
|
||||
return CheckResult.allow()
|
||||
|
||||
return CommandCheck("require_args", _check, "Require non-empty arguments")
|
||||
|
||||
|
||||
def combine_checks(*checks: CommandCheck) -> CommandCheck:
|
||||
async def _check(ctx: "CommandContext") -> CheckResult:
|
||||
for check in checks:
|
||||
result = await check.run(ctx)
|
||||
if not result.allowed:
|
||||
return result
|
||||
return CheckResult.allow()
|
||||
|
||||
names = "+".join(check.name for check in checks) or "combined"
|
||||
return CommandCheck(names, _check, "Combined checks")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommandContext:
|
||||
client: "Client"
|
||||
manager: "AccountManager"
|
||||
account: "AccountState"
|
||||
message: "Message"
|
||||
command: "CommandDefinition"
|
||||
prefix: str
|
||||
raw_text: str
|
||||
args: str
|
||||
alias: str
|
||||
|
||||
@property
|
||||
def actor(self) -> Any | None:
|
||||
return getattr(self.message, "from_user", None)
|
||||
|
||||
@property
|
||||
def actor_id(self) -> int | None:
|
||||
actor = self.actor
|
||||
return getattr(actor, "id", None)
|
||||
|
||||
@property
|
||||
def owner_id(self) -> int | None:
|
||||
me = self.account.me
|
||||
return getattr(me, "id", None)
|
||||
|
||||
@property
|
||||
def is_owner(self) -> bool:
|
||||
return self.owner_id is not None and self.actor_id == self.owner_id
|
||||
|
||||
@property
|
||||
def acl_store(self) -> RuntimeAclStore | None:
|
||||
return self.account.acl_store
|
||||
|
||||
@property
|
||||
def replied_message(self) -> Any | None:
|
||||
return getattr(self.message, "reply_to_message", None)
|
||||
|
||||
@property
|
||||
def replied_user(self) -> Any | None:
|
||||
reply = self.replied_message
|
||||
if reply is None:
|
||||
return None
|
||||
return getattr(reply, "from_user", None)
|
||||
|
||||
@property
|
||||
def replied_user_id(self) -> int | None:
|
||||
user = self.replied_user
|
||||
return getattr(user, "id", None)
|
||||
|
||||
@property
|
||||
def argv(self) -> list[str]:
|
||||
if not self.args.strip():
|
||||
return []
|
||||
try:
|
||||
return shlex.split(self.args)
|
||||
except ValueError:
|
||||
return self.args.split()
|
||||
|
||||
async def reply(self, text: str, **kwargs: Any) -> Any:
|
||||
return await self.message.reply_text(text, **kwargs)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommandDefinition:
|
||||
name: str
|
||||
description: str
|
||||
handler: CommandHandler
|
||||
prefix: str = "."
|
||||
aliases: tuple[str, ...] = ()
|
||||
usage: str | None = None
|
||||
category: str = "general"
|
||||
checks: tuple[CommandCheck, ...] = ()
|
||||
|
||||
@property
|
||||
def is_shared(self) -> bool:
|
||||
return self.prefix == ".."
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return f"{self.prefix}{self.name}"
|
||||
|
||||
def help_line(self, ctx: CommandContext | None = None) -> str:
|
||||
usage = self.usage or self.display_name
|
||||
details: list[str] = []
|
||||
if self.aliases:
|
||||
details.append(f"aliases: {', '.join(self.aliases)}")
|
||||
details.append("shared" if self.is_shared else "owner-only")
|
||||
if ctx is not None and self.is_shared and ctx.acl_store is not None:
|
||||
allowed = ctx.acl_store.count_allowed_users(self.name)
|
||||
denied = ctx.acl_store.count_denied_users(self.name)
|
||||
details.append(f"allow={allowed}")
|
||||
details.append(f"deny={denied}")
|
||||
suffix = f" ({'; '.join(details)})" if details else ""
|
||||
return f"{usage} - {self.description}{suffix}"
|
||||
|
||||
|
||||
class CommandBuilder:
|
||||
def __init__(
|
||||
self,
|
||||
registry: "CommandRegistry",
|
||||
name: str,
|
||||
description: str,
|
||||
*,
|
||||
aliases: tuple[str, ...] = (),
|
||||
usage: str | None = None,
|
||||
category: str = "general",
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self.name = normalize_command_name(name)
|
||||
self.description = description.strip()
|
||||
self.aliases = [normalize_command_name(alias) for alias in aliases]
|
||||
self.usage = usage
|
||||
self.category = category
|
||||
self.prefix = "."
|
||||
self.checks: list[CommandCheck] = []
|
||||
|
||||
def private(self) -> "CommandBuilder":
|
||||
self.prefix = "."
|
||||
return self
|
||||
|
||||
def shared(self) -> "CommandBuilder":
|
||||
self.prefix = ".."
|
||||
return self
|
||||
|
||||
def alias(self, *aliases: str) -> "CommandBuilder":
|
||||
for alias in aliases:
|
||||
normalized = normalize_command_name(alias)
|
||||
if normalized and normalized not in self.aliases and normalized != self.name:
|
||||
self.aliases.append(normalized)
|
||||
return self
|
||||
|
||||
def check(self, *checks: CommandCheck) -> "CommandBuilder":
|
||||
self.checks.extend(checks)
|
||||
return self
|
||||
|
||||
def owner_only(self) -> "CommandBuilder":
|
||||
return self.check(owner_only_check())
|
||||
|
||||
def require_reply(self) -> "CommandBuilder":
|
||||
return self.check(require_reply_check())
|
||||
|
||||
def require_args(self) -> "CommandBuilder":
|
||||
return self.check(require_args_check())
|
||||
|
||||
def build(self, handler: CommandHandler) -> CommandDefinition:
|
||||
default_checks: list[CommandCheck] = []
|
||||
if self.prefix == "..":
|
||||
default_checks.append(shared_access_check())
|
||||
else:
|
||||
default_checks.append(owner_only_check())
|
||||
|
||||
return CommandDefinition(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
handler=handler,
|
||||
prefix=self.prefix,
|
||||
aliases=tuple(self.aliases),
|
||||
usage=self.usage,
|
||||
category=self.category,
|
||||
checks=tuple(default_checks + self.checks),
|
||||
)
|
||||
|
||||
def __call__(self, handler: CommandHandler) -> CommandHandler:
|
||||
self.registry.register(self.build(handler))
|
||||
return handler
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommandRegistry:
|
||||
_commands: dict[str, CommandDefinition] = field(default_factory=dict)
|
||||
_aliases: dict[str, str] = field(default_factory=dict)
|
||||
_order: list[str] = field(default_factory=list)
|
||||
|
||||
def command(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
*,
|
||||
aliases: tuple[str, ...] = (),
|
||||
usage: str | None = None,
|
||||
category: str = "general",
|
||||
) -> CommandBuilder:
|
||||
return CommandBuilder(
|
||||
self,
|
||||
name,
|
||||
description,
|
||||
aliases=aliases,
|
||||
usage=usage,
|
||||
category=category,
|
||||
)
|
||||
|
||||
def register(self, definition: CommandDefinition) -> None:
|
||||
if definition.name in self._commands:
|
||||
raise ValueError(f"Command already registered: {definition.name}")
|
||||
|
||||
for alias in definition.aliases:
|
||||
if alias in self._aliases or alias in self._commands:
|
||||
raise ValueError(f"Command alias already registered: {alias}")
|
||||
|
||||
self._commands[definition.name] = definition
|
||||
self._order.append(definition.name)
|
||||
|
||||
for alias in definition.aliases:
|
||||
self._aliases[alias] = definition.name
|
||||
|
||||
def resolve(self, token: str) -> CommandDefinition | None:
|
||||
normalized = normalize_command_name(token)
|
||||
canonical = self._aliases.get(normalized, normalized)
|
||||
return self._commands.get(canonical)
|
||||
|
||||
def has_name(self, token: str) -> bool:
|
||||
return self.resolve(token) is not None
|
||||
|
||||
def iter_commands(self) -> list[CommandDefinition]:
|
||||
return [self._commands[name] for name in self._order]
|
||||
|
||||
def render_help(self, ctx: CommandContext) -> str:
|
||||
grouped: dict[str, list[CommandDefinition]] = defaultdict(list)
|
||||
for definition in self.iter_commands():
|
||||
grouped[definition.category].append(definition)
|
||||
|
||||
lines: list[str] = ["Available commands:"]
|
||||
for category in sorted(grouped):
|
||||
lines.append(f"[{category}]")
|
||||
for definition in grouped[category]:
|
||||
lines.append(f"- {definition.help_line(ctx)}")
|
||||
return "\n".join(lines)
|
||||
101
app/commands/dispatcher.py
Normal file
101
app/commands/dispatcher.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.commands.core import (
|
||||
CommandContext,
|
||||
CommandRegistry,
|
||||
normalize_command_name,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.manager import AccountManager, AccountState
|
||||
from pyrogram import Client
|
||||
from pyrogram.types import Message
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ParsedCommand:
|
||||
prefix: str
|
||||
name: str
|
||||
args: str
|
||||
alias: str
|
||||
|
||||
|
||||
def parse_command(text: str) -> ParsedCommand | None:
|
||||
stripped = text.lstrip()
|
||||
if not stripped.startswith("."):
|
||||
return None
|
||||
|
||||
prefix = ".." if stripped.startswith("..") else "."
|
||||
payload = stripped[len(prefix) :]
|
||||
if not payload.strip():
|
||||
return None
|
||||
|
||||
parts = payload.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
alias = parts[0]
|
||||
name = normalize_command_name(alias)
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
return ParsedCommand(prefix=prefix, name=name, args=args, alias=alias)
|
||||
|
||||
|
||||
class CommandDispatcher:
|
||||
def __init__(self, registry: CommandRegistry) -> None:
|
||||
self.registry = registry
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
client: "Client",
|
||||
message: "Message",
|
||||
manager: "AccountManager",
|
||||
account: "AccountState",
|
||||
) -> bool:
|
||||
text = getattr(message, "text", None)
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
|
||||
parsed = parse_command(text)
|
||||
if parsed is None:
|
||||
return False
|
||||
|
||||
definition = self.registry.resolve(parsed.name)
|
||||
if definition is None:
|
||||
await message.reply_text(f"Unknown command: {parsed.prefix}{parsed.alias}")
|
||||
return True
|
||||
|
||||
if parsed.prefix != definition.prefix:
|
||||
await message.reply_text(f"Use {definition.display_name} for this command")
|
||||
return True
|
||||
|
||||
ctx = CommandContext(
|
||||
client=client,
|
||||
manager=manager,
|
||||
account=account,
|
||||
message=message,
|
||||
command=definition,
|
||||
prefix=parsed.prefix,
|
||||
raw_text=text,
|
||||
args=parsed.args,
|
||||
alias=parsed.alias,
|
||||
)
|
||||
|
||||
for check in definition.checks:
|
||||
result = await check.run(ctx)
|
||||
if not result.allowed:
|
||||
await message.reply_text(result.reason or "Access denied")
|
||||
return True
|
||||
|
||||
try:
|
||||
await definition.handler(ctx)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
log.exception("Command %s failed: %s", definition.display_name, exc)
|
||||
await ctx.reply("Command failed with an internal error")
|
||||
return True
|
||||
205
app/config.py
Normal file
205
app/config.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AccountConfig:
|
||||
name: str
|
||||
session: str
|
||||
api_id: int | None = None
|
||||
api_hash: str | None = None
|
||||
session_string: str | None = None
|
||||
phone_number: str | None = None
|
||||
password: str | None = None
|
||||
phone_code: str | None = None
|
||||
bot_token: str | None = None
|
||||
proxy: dict[str, Any] | str | None = None
|
||||
workdir: Path | None = None
|
||||
in_memory: bool = False
|
||||
no_updates: bool = False
|
||||
takeout: bool = False
|
||||
hide_password: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
api_id: int | None
|
||||
api_hash: str | None
|
||||
session_dir: Path
|
||||
accounts: list[AccountConfig]
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parse_int(value: Any, *, field_name: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field_name} must be an integer") from exc
|
||||
|
||||
|
||||
def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_api_hash: str | None, default_session_dir: Path) -> list[AccountConfig]:
|
||||
if raw_accounts is None:
|
||||
return []
|
||||
|
||||
if not isinstance(raw_accounts, list):
|
||||
raise ValueError("accounts must be a list")
|
||||
|
||||
accounts: list[AccountConfig] = []
|
||||
for index, raw_account in enumerate(raw_accounts, start=1):
|
||||
if isinstance(raw_account, str):
|
||||
raw_account = {"name": raw_account, "session": raw_account}
|
||||
if not isinstance(raw_account, dict):
|
||||
raise ValueError(f"accounts[{index}] must be a string or object")
|
||||
|
||||
name = str(
|
||||
raw_account.get("name")
|
||||
or raw_account.get("label")
|
||||
or raw_account.get("session")
|
||||
or raw_account.get("session_name")
|
||||
or ""
|
||||
).strip()
|
||||
session = str(raw_account.get("session") or raw_account.get("session_name") or name).strip()
|
||||
|
||||
if not name:
|
||||
raise ValueError(f"accounts[{index}] is missing name")
|
||||
if not session:
|
||||
raise ValueError(f"accounts[{index}] is missing session")
|
||||
|
||||
account_api_id = raw_account.get("api_id", default_api_id)
|
||||
account_api_hash = raw_account.get("api_hash", default_api_hash)
|
||||
|
||||
if account_api_id is not None:
|
||||
account_api_id = _parse_int(account_api_id, field_name=f"accounts[{index}].api_id")
|
||||
if account_api_hash is not None:
|
||||
account_api_hash = str(account_api_hash)
|
||||
|
||||
account_workdir = raw_account.get("workdir")
|
||||
if account_workdir is not None:
|
||||
account_workdir = Path(str(account_workdir)).expanduser()
|
||||
if not account_workdir.is_absolute():
|
||||
account_workdir = (default_session_dir.parent / account_workdir).resolve()
|
||||
|
||||
accounts.append(
|
||||
AccountConfig(
|
||||
name=name,
|
||||
session=session,
|
||||
api_id=account_api_id,
|
||||
api_hash=account_api_hash,
|
||||
session_string=raw_account.get("session_string"),
|
||||
phone_number=raw_account.get("phone_number"),
|
||||
password=raw_account.get("password"),
|
||||
phone_code=raw_account.get("phone_code"),
|
||||
bot_token=raw_account.get("bot_token"),
|
||||
proxy=raw_account.get("proxy"),
|
||||
workdir=account_workdir,
|
||||
in_memory=bool(raw_account.get("in_memory", False) or raw_account.get("session_string")),
|
||||
no_updates=bool(raw_account.get("no_updates", False)),
|
||||
takeout=bool(raw_account.get("takeout", False)),
|
||||
hide_password=bool(raw_account.get("hide_password", False)),
|
||||
)
|
||||
)
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def _accounts_from_env() -> list[dict[str, Any]] | None:
|
||||
raw_accounts = os.environ.get("ACCOUNTS_JSON")
|
||||
if raw_accounts:
|
||||
parsed = json.loads(raw_accounts)
|
||||
if not isinstance(parsed, list):
|
||||
raise ValueError("ACCOUNTS_JSON must contain a JSON list")
|
||||
return parsed
|
||||
|
||||
raw_sessions = os.environ.get("ACCOUNT_SESSIONS")
|
||||
if raw_sessions:
|
||||
sessions = [item.strip() for item in raw_sessions.split(",") if item.strip()]
|
||||
return [{"name": session, "session": session} for session in sessions]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_app_config(root: Path) -> AppConfig:
|
||||
dotenv_path = os.environ.get("BOT_DOTENV_PATH")
|
||||
if dotenv_path:
|
||||
candidate_dotenv = Path(dotenv_path).expanduser()
|
||||
if not candidate_dotenv.is_absolute():
|
||||
candidate_dotenv = (root / candidate_dotenv).resolve()
|
||||
else:
|
||||
candidate_dotenv = root / ".env"
|
||||
|
||||
load_dotenv(candidate_dotenv, override=False)
|
||||
|
||||
config_path = os.environ.get("BOT_CONFIG")
|
||||
if config_path:
|
||||
candidate = Path(config_path).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = (root / candidate).resolve()
|
||||
else:
|
||||
candidate = root / "config.json"
|
||||
|
||||
raw: dict[str, Any] = {}
|
||||
if candidate.exists():
|
||||
raw = _read_json_file(candidate)
|
||||
|
||||
env_api_id = os.environ.get("TG_API_ID")
|
||||
env_api_hash = os.environ.get("TG_API_HASH")
|
||||
env_session_dir = os.environ.get("SESSION_DIR")
|
||||
|
||||
if env_api_id is not None:
|
||||
raw["api_id"] = env_api_id
|
||||
if env_api_hash is not None:
|
||||
raw["api_hash"] = env_api_hash
|
||||
if env_session_dir is not None:
|
||||
raw["session_dir"] = env_session_dir
|
||||
|
||||
env_accounts = _accounts_from_env()
|
||||
if env_accounts is not None:
|
||||
raw["accounts"] = env_accounts
|
||||
|
||||
if "accounts" not in raw:
|
||||
raise ValueError(
|
||||
"Missing accounts configuration. Provide accounts in config.json or set ACCOUNTS_JSON / ACCOUNT_SESSIONS."
|
||||
)
|
||||
|
||||
api_id = _parse_int(raw["api_id"], field_name="api_id") if "api_id" in raw else None
|
||||
api_hash = str(raw["api_hash"]) if "api_hash" in raw else None
|
||||
session_dir = Path(str(raw.get("session_dir", "sessions"))).expanduser()
|
||||
if not session_dir.is_absolute():
|
||||
session_dir = (root / session_dir).resolve()
|
||||
|
||||
accounts = _parse_accounts(
|
||||
raw["accounts"],
|
||||
default_api_id=api_id,
|
||||
default_api_hash=api_hash,
|
||||
default_session_dir=session_dir,
|
||||
)
|
||||
|
||||
if not accounts:
|
||||
raise ValueError("At least one account must be configured")
|
||||
|
||||
seen_names: set[str] = set()
|
||||
seen_sessions: set[str] = set()
|
||||
for account in accounts:
|
||||
if account.name in seen_names:
|
||||
raise ValueError(f"Duplicate account name: {account.name}")
|
||||
if account.session in seen_sessions:
|
||||
raise ValueError(f"Duplicate session name: {account.session}")
|
||||
seen_names.add(account.name)
|
||||
seen_sessions.add(account.session)
|
||||
|
||||
return AppConfig(
|
||||
api_id=api_id,
|
||||
api_hash=api_hash,
|
||||
session_dir=session_dir,
|
||||
accounts=accounts,
|
||||
)
|
||||
21
app/handlers.py
Normal file
21
app/handlers.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyrogram import filters
|
||||
from pyrogram.handlers import MessageHandler
|
||||
|
||||
from app.commands import CommandDispatcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.manager import AccountManager, AccountState
|
||||
from pyrogram import Client
|
||||
|
||||
|
||||
def register_common_handlers(client: "Client", manager: "AccountManager", account: "AccountState") -> None:
|
||||
dispatcher = CommandDispatcher(manager.command_registry)
|
||||
|
||||
async def command_handler(client_: "Client", message) -> None:
|
||||
await dispatcher.dispatch(client_, message, manager, account)
|
||||
|
||||
client.add_handler(MessageHandler(command_handler, filters.text))
|
||||
148
app/manager.py
Normal file
148
app/manager.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pyrogram import Client, idle
|
||||
|
||||
from app.commands import DEFAULT_REGISTRY, CommandRegistry, RuntimeAclStore
|
||||
from app.config import AccountConfig, AppConfig
|
||||
from app.handlers import register_common_handlers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AccountState:
|
||||
config: AccountConfig
|
||||
client: Client | None = None
|
||||
acl_store: RuntimeAclStore | None = None
|
||||
status: str = "pending"
|
||||
error: str | None = None
|
||||
started_at: datetime | None = None
|
||||
me: Any | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AccountManager:
|
||||
config: AppConfig
|
||||
command_registry: CommandRegistry = field(init=False)
|
||||
states: list[AccountState] = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.command_registry = DEFAULT_REGISTRY
|
||||
self.states = [AccountState(config=account) for account in self.config.accounts]
|
||||
|
||||
def _make_client(self, account: AccountConfig) -> Client:
|
||||
api_id = account.api_id if account.api_id is not None else self.config.api_id
|
||||
api_hash = account.api_hash if account.api_hash is not None else self.config.api_hash
|
||||
if api_id is None or api_hash is None:
|
||||
raise ValueError(f"Missing API credentials for account {account.name}")
|
||||
|
||||
workdir = account.workdir or self.config.session_dir
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
client = Client(
|
||||
account.session,
|
||||
api_id=api_id,
|
||||
api_hash=api_hash,
|
||||
workdir=str(workdir),
|
||||
session_string=account.session_string,
|
||||
phone_number=account.phone_number,
|
||||
password=account.password,
|
||||
phone_code=account.phone_code,
|
||||
bot_token=account.bot_token,
|
||||
proxy=account.proxy,
|
||||
in_memory=account.in_memory,
|
||||
no_updates=account.no_updates,
|
||||
takeout=account.takeout,
|
||||
hide_password=account.hide_password,
|
||||
)
|
||||
return client
|
||||
|
||||
async def start_all(self) -> None:
|
||||
for state in self.states:
|
||||
await self.start_account(state)
|
||||
|
||||
async def start_account(self, state: AccountState) -> None:
|
||||
account = state.config
|
||||
state.status = "starting"
|
||||
state.error = None
|
||||
|
||||
acl_path = self.config.session_dir / f"{account.session}.acl.json"
|
||||
state.acl_store = RuntimeAclStore(acl_path)
|
||||
client = self._make_client(account)
|
||||
state.client = client
|
||||
register_common_handlers(client, self, state)
|
||||
|
||||
try:
|
||||
await client.start()
|
||||
state.me = await client.get_me()
|
||||
state.started_at = datetime.now(timezone.utc)
|
||||
state.status = "running"
|
||||
log.info("Started account %s (%s)", account.name, account.session)
|
||||
except Exception as exc:
|
||||
state.status = "failed"
|
||||
state.error = str(exc)
|
||||
log.exception("Failed to start account %s (%s)", account.name, account.session)
|
||||
await self._safe_stop(client)
|
||||
|
||||
async def _safe_stop(self, client: Client) -> None:
|
||||
try:
|
||||
await client.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
for state in reversed(self.states):
|
||||
if state.client is None:
|
||||
continue
|
||||
if state.status not in {"running", "starting"}:
|
||||
continue
|
||||
try:
|
||||
await state.client.stop()
|
||||
state.status = "stopped"
|
||||
log.info("Stopped account %s (%s)", state.config.name, state.config.session)
|
||||
except Exception:
|
||||
log.exception("Failed to stop account %s (%s)", state.config.name, state.config.session)
|
||||
|
||||
def build_status_report(self) -> str:
|
||||
lines = ["Configured accounts:"]
|
||||
for state in self.states:
|
||||
account = state.config
|
||||
parts = [
|
||||
f"{account.name} [{account.session}]",
|
||||
f"status={state.status}",
|
||||
]
|
||||
if state.acl_store is not None:
|
||||
stats = state.acl_store.stats()
|
||||
parts.append(
|
||||
f"acl={stats['allow_rules']} allow / {stats['deny_rules']} deny"
|
||||
)
|
||||
if state.me is not None:
|
||||
display_name = (
|
||||
getattr(state.me, "full_name", None)
|
||||
or getattr(state.me, "first_name", None)
|
||||
or getattr(state.me, "username", None)
|
||||
)
|
||||
if display_name:
|
||||
parts.append(f"user={display_name}")
|
||||
parts.append(f"user_id={getattr(state.me, 'id', 'unknown')}")
|
||||
if state.error:
|
||||
parts.append(f"error={state.error}")
|
||||
lines.append(" - " + ", ".join(parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
async def run(self) -> None:
|
||||
await self.start_all()
|
||||
|
||||
if not any(state.status == "running" for state in self.states):
|
||||
await self.stop_all()
|
||||
raise RuntimeError("No accounts were started successfully")
|
||||
|
||||
try:
|
||||
await idle()
|
||||
finally:
|
||||
await self.stop_all()
|
||||
15
config.example.json
Normal file
15
config.example.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"api_id": 123456,
|
||||
"api_hash": "your_api_hash_here",
|
||||
"session_dir": "sessions",
|
||||
"accounts": [
|
||||
{
|
||||
"name": "personal",
|
||||
"session": "personal"
|
||||
},
|
||||
{
|
||||
"name": "work",
|
||||
"session": "work"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
main.py
Normal file
27
main.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import load_app_config
|
||||
from app.manager import AccountManager
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
root = Path(__file__).resolve().parent
|
||||
config = load_app_config(root)
|
||||
manager = AccountManager(config)
|
||||
await manager.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
configure_logging()
|
||||
asyncio.run(main())
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
kurigram
|
||||
tgcrypto
|
||||
python-dotenv
|
||||
openai
|
||||
elevenlabs
|
||||
Reference in New Issue
Block a user