diff --git a/ai/agent.py b/ai/agent.py index 9e6ca1f..f019967 100644 --- a/ai/agent.py +++ b/ai/agent.py @@ -142,7 +142,15 @@ async def get_group_info(ctx: RunContext[BotDependencies], group_jid: str = "") return "Could not get group info" -bot_agent = _create_agent() +_bot_agent: Agent | None = None + + +def get_agent() -> Agent: + """Get or create the global agent instance.""" + global _bot_agent + if _bot_agent is None: + _bot_agent = _create_agent() + return _bot_agent class AgenticAI: @@ -422,7 +430,7 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None: if image_data: user_prompt_parts.append(BinaryContent(data=image_data, media_type="image/jpeg")) - result = await bot_agent.run( + result = await get_agent().run( user_prompt_parts, deps=deps, model=model_str, diff --git a/commands/downloader/cancel.py b/commands/downloader/cancel.py new file mode 100644 index 0000000..c2c09d1 --- /dev/null +++ b/commands/downloader/cancel.py @@ -0,0 +1,45 @@ +from core import symbols as sym +from core.command import Command, CommandContext +from core.downloader import downloader +from core.i18n import t +from core.permissions import check_admin_permission +from core.runtime_config import runtime_config + + +class CancelCommand(Command): + name = "cancel" + description = "Cancel your active download in this chat" + usage = "cancel [all]" + category = "downloader" + + async def execute(self, ctx: CommandContext) -> None: + """Cancel an active download.""" + chat_jid = ctx.message.chat_jid + sender_jid = ctx.message.sender_jid + + if ctx.args and ctx.args[0].lower() == "all": + is_owner = await runtime_config.is_owner_async(sender_jid, ctx.client) + is_admin = False + if ctx.message.is_group: + is_admin = await check_admin_permission(ctx.client, chat_jid, sender_jid) + + if not (is_owner or is_admin): + await ctx.client.reply(ctx.message, t("errors.admin_required")) + return + + count = downloader.cancel_all_in_chat(chat_jid) + if count > 0: + await ctx.client.reply( + ctx.message, f"{sym.SUCCESS} Cancelled {count} active download(s)." + ) + else: + await ctx.client.reply(ctx.message, f"{sym.INFO} No active downloads in this chat.") + return + + cancelled = downloader.cancel_download(chat_jid, sender_jid) + if cancelled: + await ctx.client.reply(ctx.message, f"{sym.SUCCESS} {t('downloader.cancelled')}") + else: + await ctx.client.reply( + ctx.message, f"{sym.INFO} You don't have any active downloads in this chat." + ) diff --git a/commands/downloader/common.py b/commands/downloader/common.py index 0d1487e..b4cb175 100644 --- a/commands/downloader/common.py +++ b/commands/downloader/common.py @@ -8,7 +8,13 @@ from core import symbols as sym from core.command import Command, CommandContext -from core.downloader import DownloadError, FileTooLargeError, _format_size, downloader +from core.downloader import ( + DownloadAbortedError, + DownloadError, + FileTooLargeError, + _format_size, + downloader, +) from core.errors import report_error from core.i18n import t, t_error @@ -86,7 +92,12 @@ def _progress_hook(downloaded_bytes, total_bytes, speed, eta): loop, ) - filepath = await self.download_func(url, progress_hook=_progress_hook) + filepath = await self.download_func( + url, + progress_hook=_progress_hook, + chat_jid=ctx.message.chat_jid, + sender_jid=ctx.message.sender_jid, + ) await ctx.client.edit_message( ctx.message.chat_jid, @@ -119,6 +130,13 @@ def _progress_hook(downloaded_bytes, total_bytes, speed, eta): ctx.message, t_error("downloader.too_large", size=f"{e.size_mb:.1f}", max=f"{e.max_mb:.0f}"), ) + except DownloadAbortedError: + await ctx.client.edit_message( + ctx.message.chat_jid, + progress_msg_id, + f"{header}{sym.INFO} {t('downloader.cancelled')}", + ) + await ctx.client.send_reaction(ctx.message, "🚫") except DownloadError as e: await ctx.client.reply(ctx.message, t_error("downloader.failed", error=str(e))) except Exception as e: diff --git a/commands/downloader/dl.py b/commands/downloader/dl.py index 0d6a464..1b0a7b9 100644 --- a/commands/downloader/dl.py +++ b/commands/downloader/dl.py @@ -19,7 +19,7 @@ from core import symbols as sym from core.command import Command, CommandContext -from core.downloader import DownloadError, FileTooLargeError, downloader +from core.downloader import DownloadAbortedError, DownloadError, FileTooLargeError, downloader from core.i18n import t, t_error from core.logger import log_info, log_warning from core.pending_store import ( @@ -165,6 +165,8 @@ async def _auto_download(self, ctx: CommandContext, info, fmt) -> None: fmt.format_id, merge_audio=not fmt.has_audio, is_audio=fmt.type == "audio", + chat_jid=ctx.message.chat_jid, + sender_jid=ctx.message.sender_jid, ) media_type = "audio" if fmt.type == "audio" else "video" @@ -188,6 +190,9 @@ async def _auto_download(self, ctx: CommandContext, info, fmt) -> None: ctx.message, t_error("downloader.too_large", size=f"{e.size_mb:.1f}", max=f"{e.max_mb:.0f}"), ) + except DownloadAbortedError: + await ctx.client.send_reaction(ctx.message, "🚫") + await ctx.client.reply(ctx.message, f"{sym.INFO} {t('downloader.cancelled')}") except (DownloadError, Exception) as e: await ctx.client.send_reaction(ctx.message, "❌") await ctx.client.reply(ctx.message, t_error("downloader.failed", error=str(e))) diff --git a/config.json b/config.json index 8abf07f..f4682d7 100644 --- a/config.json +++ b/config.json @@ -53,7 +53,10 @@ "action": "kick" }, "downloader": { - "max_file_size_mb": 50 + "max_file_size_mb": 180 }, - "disabled_commands": [] + "disabled_commands": [], + "dashboard": { + "enabled": false + } } \ No newline at end of file diff --git a/config.schema.json b/config.schema.json index fa3ff03..9292f0c 100644 --- a/config.schema.json +++ b/config.schema.json @@ -309,6 +309,17 @@ }, "description": "List of command names that are disabled", "default": [] + }, + "dashboard": { + "type": "object", + "description": "Dashboard API and UI settings", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the Dashboard API server", + "default": false + } + } } }, "required": [ diff --git a/core/downloader.py b/core/downloader.py index aad1ce5..f7490ef 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -15,10 +15,10 @@ from pathlib import Path from core.constants import DOWNLOADS_DIR -from core.logger import log_error, log_info +from core.logger import log_debug, log_error, log_info, log_warning from core.runtime_config import runtime_config -MAX_FILE_SIZE_MB = runtime_config.get_nested("downloader", "max_file_size_mb", default=50) +MAX_FILE_SIZE_MB = runtime_config.get_nested("downloader", "max_file_size_mb", default=180) def _format_size(size_bytes: int | float) -> str: @@ -127,6 +127,12 @@ def __init__(self, size_mb: float, max_mb: float): super().__init__(f"File too large: {size_mb:.1f} MB (max {max_mb:.0f} MB)") +class DownloadAbortedError(DownloadError): + """Raised when a download is cancelled by the user.""" + + pass + + class Downloader: """yt-dlp wrapper for downloading media.""" @@ -134,12 +140,26 @@ def __init__(self, download_dir: Path | None = None, max_size_mb: float = MAX_FI self.download_dir = download_dir or DOWNLOADS_DIR self.max_size_mb = max_size_mb self.download_dir.mkdir(parents=True, exist_ok=True) + self._active_downloads: dict[str, bool] = {} def _make_output_path(self, prefix: str = "dl") -> str: """Generate a unique output path template for yt-dlp.""" ts = int(time.time() * 1000) return str(self.download_dir / f"{prefix}_{ts}_%(id)s.%(ext)s") + def _add_cookies(self, ydl_opts: dict) -> None: + """Inject cookiefile into ydl_opts if env var is set.""" + cookies_path_raw = os.getenv("YOUTUBE_COOKIES_PATH") + if cookies_path_raw: + project_root = Path(__file__).parent.parent + cookies_path = project_root / cookies_path_raw + + if cookies_path.exists(): + ydl_opts["cookiefile"] = str(cookies_path.absolute()) + log_debug(f"[DOWNLOADER] Using cookies: {cookies_path.name}") + else: + log_debug(f"[DOWNLOADER] Cookie file not found at: {cookies_path}") + @staticmethod def _parse_formats(raw_formats: list[dict]) -> list[FormatOption]: """Parse yt-dlp format list into clean FormatOption objects.""" @@ -259,6 +279,7 @@ async def search_youtube(self, query: str, count: int = 5) -> list[dict]: "extract_flat": True, "skip_download": True, } + self._add_cookies(ydl_opts) search_url = f"ytsearch{count}:{query}" @@ -318,6 +339,7 @@ async def get_playlist_info(self, url: str, max_entries: int = 25) -> PlaylistIn "ignoreerrors": True, "playlistend": max_entries, } + self._add_cookies(ydl_opts) def _extract(): with yt_dlp.YoutubeDL(ydl_opts) as ydl: @@ -371,7 +393,12 @@ async def get_info(self, url: str) -> MediaInfo: "extract_flat": True, "skip_download": True, "ignoreerrors": True, + "remote-components": "ejs:github", + "extractor_args": { + "youtube": {"player_js_variant": "tv"} + }, # https://github.com/yt-dlp/yt-dlp/issues/15814 } + self._add_cookies(flat_opts) def _flat_extract(): with yt_dlp.YoutubeDL(flat_opts) as ydl: @@ -400,6 +427,7 @@ def _flat_extract(): "extract_flat": False, "skip_download": True, } + self._add_cookies(full_opts) def _full_extract(): with yt_dlp.YoutubeDL(full_opts) as ydl: @@ -414,6 +442,8 @@ def _full_extract(): raise DownloadError("No info returned for URL") raw_formats = info.get("formats", []) + if not raw_formats: + log_warning(f"[DOWNLOADER] No formats found for {url}. Info: {list(info.keys())}") format_options = self._parse_formats(raw_formats) if raw_formats else [] actual_url = info.get("webpage_url", url) @@ -437,6 +467,8 @@ async def download_format( merge_audio: bool = False, is_audio: bool = False, progress_hook: callable | None = None, + chat_jid: str | None = None, + sender_jid: str | None = None, ) -> Path: """ Download a specific format by its format_id. @@ -451,7 +483,10 @@ async def download_format( limit = max_size_mb or self.max_size_mb output_template = self._make_output_path("dl") - fmt = f"{format_id}+bestaudio/{format_id}" if merge_audio else format_id + if merge_audio: + fmt = f"{format_id}+bestaudio/{format_id}/best" + else: + fmt = format_id ydl_opts = { "format": fmt, @@ -459,9 +494,17 @@ async def download_format( "quiet": True, "no_warnings": True, "merge_output_format": "mp4", - "max_filesize": int(limit * 1024 * 1024), + "extractor_args": { + "youtube": {"player_client": ["ios", "android", "mweb", "tv", "web"]} + }, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", } + if limit: + ydl_opts["format"] = f"({fmt})[filesize<=?{int(limit)}M]" + + self._add_cookies(ydl_opts) + if is_audio: ydl_opts["writethumbnail"] = True ydl_opts["postprocessors"] = [ @@ -479,13 +522,15 @@ async def download_format( }, ] - return await self._download(url, ydl_opts, limit, progress_hook) + return await self._download(url, ydl_opts, limit, progress_hook, chat_jid, sender_jid) async def download_audio( self, url: str, max_size_mb: float | None = None, progress_hook: callable | None = None, + chat_jid: str | None = None, + sender_jid: str | None = None, ) -> Path: """ Download audio from URL (best quality, MP3) with metadata + thumbnail. @@ -496,11 +541,15 @@ async def download_audio( output_template = self._make_output_path("audio") ydl_opts = { - "format": f"bestaudio[filesize Path: """ Download video from URL (best quality, MP4). @@ -535,19 +586,16 @@ async def download_video( output_template = self._make_output_path("video") ydl_opts = { - "format": ( - f"bestvideo[filesize Path: """Internal download method.""" import yt_dlp downloaded_file = None + dl_key = f"{chat_jid}:{sender_jid}" if chat_jid and sender_jid else None + + base_output = ydl_opts.get("outtmpl", "") + if isinstance(base_output, dict): + base_output = base_output.get("default", "") + base_output = str(base_output).replace(".%(ext)s", "") + + if dl_key: + self._active_downloads[dl_key] = False def _progress(d): """yt-dlp progress hook.""" + if dl_key and self._active_downloads.get(dl_key): + raise DownloadAbortedError("Download cancelled by user") + if progress_hook and d.get("status") == "downloading": try: progress_hook( @@ -581,25 +643,68 @@ def _run(): nonlocal downloaded_file with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=True) - if info: - filename = ydl.prepare_filename(info) - base = os.path.splitext(filename)[0] - for ext in [".mp3", ".m4a", ".mp4", ".webm", ".mkv", ".opus", ".ogg"]: - candidate = base + ext - if os.path.exists(candidate): - downloaded_file = candidate + if not info: + return + + if "requested_downloads" in info: + for rd in info["requested_downloads"]: + path = rd.get("filepath") + if path and os.path.exists(path): + downloaded_file = path return - if os.path.exists(filename): - downloaded_file = filename + + filename = ydl.prepare_filename(info) + base = os.path.splitext(filename)[0] + for ext in [".mp3", ".m4a", ".mp4", ".webm", ".mkv", ".opus", ".ogg", ".ts"]: + candidate = base + ext + if os.path.exists(candidate): + downloaded_file = candidate + return + + if os.path.exists(filename): + downloaded_file = filename + + async def _cleanup_partial(): + import glob + + if base_output and base_output != "None": + for f in glob.glob(f"{base_output}*"): + try: + if os.path.exists(f): + os.remove(f) + log_info(f"[DOWNLOADER] Cleaned up partial file: {f}") + except Exception: + log_warning(f"[DOWNLOADER] Failed to cleanup {f}") try: log_info(f"[DOWNLOADER] Starting download: {url}") await asyncio.to_thread(_run) + except DownloadAbortedError: + log_info(f"[DOWNLOADER] Download aborted locally: {url}") + await _cleanup_partial() + raise except Exception as e: - raise DownloadError(f"Download failed: {e}") from e + await _cleanup_partial() + if "Download cancelled by user" in str(e) or isinstance(e, DownloadAbortedError): + raise DownloadAbortedError("Download cancelled by user") from e + + error_msg = str(e) + if "Requested format is not available" in error_msg and os.getenv( + "YOUTUBE_COOKIES_PATH" + ): + error_msg += f"\nTIP: This error is often caused by invalid/expired cookies in {os.getenv('YOUTUBE_COOKIES_PATH')}. Try updating them or disabling cookies." + + raise DownloadError(f"Download failed: {error_msg}") from e + finally: + if dl_key: + self._active_downloads.pop(dl_key, None) if not downloaded_file or not os.path.exists(downloaded_file): - raise DownloadError("Download completed but no file was found") + raise DownloadError( + f"Download failed: No file found after download. " + f"This often happens if the file exceeds the size limit ({max_size_mb}MB) " + f"or the format is temporary unavailable." + ) file_size = os.path.getsize(downloaded_file) file_size_mb = file_size / (1024 * 1024) @@ -630,5 +735,28 @@ def cleanup_all(self) -> None: except Exception as e: log_error(f"[DOWNLOADER] Cleanup all failed: {e}") + def cancel_download(self, chat_jid: str, sender_jid: str) -> bool: + """ + Mark an active download as cancelled. + Returns True if a matching download was found. + """ + dl_key = f"{chat_jid}:{sender_jid}" + if dl_key in self._active_downloads: + self._active_downloads[dl_key] = True + return True + return False + + def cancel_all_in_chat(self, chat_jid: str) -> int: + """ + Cancel all active downloads in a specific chat. + Returns the number of downloads cancelled. + """ + count = 0 + for key in list(self._active_downloads.keys()): + if key.startswith(f"{chat_jid}:"): + self._active_downloads[key] = True + count += 1 + return count + downloader = Downloader() diff --git a/core/env.py b/core/env.py index a17b801..3f1ee9b 100644 --- a/core/env.py +++ b/core/env.py @@ -61,11 +61,15 @@ def validate_environment(): if ai_config.get("enabled"): provider = ai_config.get("provider", "openai") api_key = ( - ai_config.get("AI_API_KEY") + ai_config.get("API_KEY") + or os.getenv("AI_API_KEY") or os.getenv("GEMINI_API_KEY") or os.getenv("OPENAI_API_KEY") ) + if api_key and not os.getenv("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = api_key + if not api_key: log_warning(f"AI is enabled but no API key found for provider '{provider}'.") log_bullet("AI features may fail to initialize.") diff --git a/core/logger.py b/core/logger.py index 1172877..1dc2fce 100644 --- a/core/logger.py +++ b/core/logger.py @@ -196,27 +196,28 @@ def log_debug(message: str) -> None: def show_banner(title: str, subtitle: str = "") -> None: """Show a styled ASCII art banner.""" - logo = ( - " ████████\n" - " ██████████████████\n" - " ████ ████\n" - " ███ ███\n" - " ███ ██ ███ ███ ███\n" - " ███ █████ ██████████ ███\n" - " ██ ████ ███████ ██\n" - " ██ ████ █ ███ ██\n" - " ██ ███ █ ███ ██\n" - " ██ ███ █ ███ ██\n" - " ██ ████ █ ███ ██\n" - " ██ ████ ███ ███ ██\n" - " ███ █████ ██████ ███ ███\n" - " ███ ██ ██ ███ ███\n" - " ███ ███\n" - " ████ ████\n" - " ██████████████████\n" - " ████████" + logo_raw = ( + "████████\n" + "██████████████████\n" + "████ ████\n" + "███ ███\n" + "███ ██ ███ ███ ███\n" + "███ █████ ██████████ ███\n" + "██ ████ ███████ ██\n" + "██ ████ █ ███ ██\n" + "██ ███ █ ███ ██\n" + "██ ███ █ ███ ██\n" + "██ ████ █ ███ ██\n" + "██ ████ ███ ███ ██\n" + "███ █████ ██████ ███ ███\n" + "███ ██ ██ ███ ███\n" + "███ ███\n" + "████ ████\n" + "██████████████████\n" + "████████" ) - console.print(logo, style="bold green", highlight=False) + logo = Text(logo_raw, style="bold green") + console.print(logo, justify="center") console.print() text = Text(title, style="bold green") if subtitle: diff --git a/core/middlewares/download_reply.py b/core/middlewares/download_reply.py index 168fc02..3ba867e 100644 --- a/core/middlewares/download_reply.py +++ b/core/middlewares/download_reply.py @@ -6,7 +6,13 @@ import httpx from core import symbols as sym -from core.downloader import DownloadError, FileTooLargeError, _format_size, downloader +from core.downloader import ( + DownloadAbortedError, + DownloadError, + FileTooLargeError, + _format_size, + downloader, +) from core.errors import report_error from core.i18n import t, t_error from core.pending_store import PendingDownload, PendingPlaylist, PendingSearch, pending_downloads @@ -91,6 +97,8 @@ async def _handle_selection_reply(ctx, pending, stanza_id, choice_num, items): fmt.format_id, merge_audio=not fmt.has_audio, is_audio=fmt.type == "audio", + chat_jid=ctx.msg.chat_jid, + sender_jid=ctx.msg.sender_jid, ) media_type = "audio" if fmt.type == "audio" else "video" caption = f"{sym.SPARKLE} {info.title}" @@ -200,6 +208,8 @@ def _progress_hook(downloaded_bytes, total_bytes, speed, eta): merge_audio=not selected.has_audio, is_audio=selected.type == "audio", progress_hook=_progress_hook, + chat_jid=ctx.msg.chat_jid, + sender_jid=ctx.msg.sender_jid, ) await ctx.bot.edit_message( @@ -237,6 +247,14 @@ def _progress_hook(downloaded_bytes, total_bytes, speed, eta): ctx.msg, t_error("downloader.too_large", size=f"{e.size_mb:.1f}", max=f"{e.max_mb:.0f}"), ) + except DownloadAbortedError: + await ctx.bot.edit_message( + ctx.msg.chat_jid, + progress_msg_id, + f"{sym.ARROW} {t('downloader.downloading', title=pending.info.title, quality=quality_label)}\n\n" + f"{sym.INFO} {t('downloader.cancelled')}", + ) + await ctx.bot.send_reaction(ctx.msg, "🚫") except DownloadError as e: await ctx.bot.send_reaction(ctx.msg, "❌") await ctx.bot.reply(ctx.msg, t_error("downloader.failed", error=str(e))) diff --git a/core/runtime_config.py b/core/runtime_config.py index d34cb54..bbb6611 100644 --- a/core/runtime_config.py +++ b/core/runtime_config.py @@ -66,6 +66,9 @@ "owner_only": True, }, "disabled_commands": [], + "dashboard": { + "enabled": False, + }, } diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index bf2f6ef..4f1fe62 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -8,11 +8,15 @@ export const WS_BASE = API_BASE.replace(/^http/, "ws"); export interface TopCommand { command: string; count: number; + command: string; + count: number; } export interface TimelineEntry { date: string; count: number; + date: string; + count: number; } export interface AnalyticsCommands { @@ -20,6 +24,10 @@ export interface AnalyticsCommands { total: number; days: number; group_id?: string; + top_commands: TopCommand[]; + total: number; + days: number; + group_id?: string; } export interface AnalyticsTimeline { @@ -27,6 +35,10 @@ export interface AnalyticsTimeline { command: string; days: number; group_id?: string; + timeline: TimelineEntry[]; + command: string; + days: number; + group_id?: string; } export interface BotStatus { @@ -34,6 +46,10 @@ export interface BotStatus { bot_name: string; prefix: string; uptime: string; + status: "online" | "offline"; + bot_name: string; + prefix: string; + uptime: string; } export interface Config { @@ -67,6 +83,36 @@ export interface Config { limit: number; action: string; }; + bot: { + name: string; + prefix: string; + login_method: string; + owner_jid: string; + auto_read: boolean; + auto_react: boolean; + }; + features: { + anti_delete: boolean; + anti_link: boolean; + welcome: boolean; + notes: boolean; + filters: boolean; + blacklist: boolean; + warnings: boolean; + }; + logging: { + log_messages: boolean; + verbose: boolean; + level: string; + }; + anti_delete: { + forward_to: string; + cache_ttl: number; + }; + warnings: { + limit: number; + action: string; + }; } export interface AIConfig { @@ -76,6 +122,12 @@ export interface AIConfig { trigger_mode: string; owner_only: boolean; has_api_key: boolean; + enabled: boolean; + provider: string; + model: string; + trigger_mode: string; + owner_only: boolean; + has_api_key: boolean; } export interface Command { @@ -83,6 +135,10 @@ export interface Command { description: string; category: string; enabled: boolean; + name: string; + description: string; + category: string; + enabled: boolean; } export interface Group { @@ -95,6 +151,15 @@ export interface Group { welcome: boolean; mute: boolean; }; + id: string; + name: string; + memberCount: number; + isAdmin: boolean; + settings: { + antilink: boolean; + welcome: boolean; + mute: boolean; + }; } export interface LogEntry { @@ -102,6 +167,10 @@ export interface LogEntry { timestamp: string; level: "info" | "warning" | "error" | "debug"; message: string; + id: string; + timestamp: string; + level: "info" | "warning" | "error" | "debug"; + message: string; } export interface Stats { @@ -110,6 +179,11 @@ export interface Stats { activeGroups: number; scheduledTasks: number; uptime: string; + messagesTotal: number; + commandsUsed: number; + activeGroups: number; + scheduledTasks: number; + uptime: string; } export interface ScheduledTask { @@ -122,6 +196,15 @@ export interface ScheduledTask { interval_minutes?: number; enabled: boolean; created_at?: string; + id: string; + type: "reminder" | "auto_message" | "recurring"; + chat_jid: string; + message: string; + trigger_time?: string; + cron_expression?: string; + interval_minutes?: number; + enabled: boolean; + created_at?: string; } export interface Note { @@ -129,26 +212,47 @@ export interface Note { content: string; media_type: "text" | "image" | "video" | "audio" | "document" | "sticker"; media_path: string | null; + name: string; + content: string; + media_type: "text" | "image" | "video" | "audio" | "document" | "sticker"; + media_path: string | null; } export interface Filter { trigger: string; response: string; + trigger: string; + response: string; } async function fetchAPI(endpoint: string, options?: RequestInit): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + const headers: Record = { + "Content-Type": "application/json", + }; const headers: Record = { "Content-Type": "application/json", }; + if (options?.headers) { + const customHeaders = options.headers as Record; + Object.assign(headers, customHeaders); + } if (options?.headers) { const customHeaders = options.headers as Record; Object.assign(headers, customHeaders); } + if (typeof window !== "undefined") { + const auth = localStorage.getItem("dashboard_auth"); + if (auth) { + headers["Authorization"] = `Basic ${auth}`; + } + } if (typeof window !== "undefined") { const auth = localStorage.getItem("dashboard_auth"); if (auth) { @@ -156,6 +260,12 @@ async function fetchAPI(endpoint: string, options?: RequestInit): Promise } } + try { + const res = await fetch(`${API_BASE}${endpoint}`, { + ...options, + signal: controller.signal, + headers, + }); try { const res = await fetch(`${API_BASE}${endpoint}`, { ...options, @@ -163,6 +273,12 @@ async function fetchAPI(endpoint: string, options?: RequestInit): Promise headers, }); + if (res.status === 401) { + if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) { + window.location.href = "/login"; + } + throw new Error("Unauthorized"); + } if (res.status === 401) { if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) { window.location.href = "/login"; @@ -170,6 +286,9 @@ async function fetchAPI(endpoint: string, options?: RequestInit): Promise throw new Error("Unauthorized"); } + if (!res.ok) { + throw new Error(`API Error: ${res.status} ${res.statusText}`); + } if (!res.ok) { throw new Error(`API Error: ${res.status} ${res.statusText}`); } @@ -178,6 +297,10 @@ async function fetchAPI(endpoint: string, options?: RequestInit): Promise } finally { clearTimeout(timeoutId); } + return await res.json(); + } finally { + clearTimeout(timeoutId); + } } export interface RateLimitConfig { @@ -186,16 +309,30 @@ export interface RateLimitConfig { command_cooldown: number; burst_limit: number; burst_window: number; + enabled: boolean; + user_cooldown: number; + command_cooldown: number; + burst_limit: number; + burst_window: number; } export interface WelcomeConfig { enabled: boolean; message: string; + enabled: boolean; + message: string; } export const api = { getStatus: () => fetchAPI("/api/status"), + getStatus: () => fetchAPI("/api/status"), + getConfig: () => fetchAPI("/api/config"), + updateConfig: (section: string, key: string, value: unknown) => + fetchAPI("/api/config", { + method: "PUT", + body: JSON.stringify({ section, key, value }), + }), getConfig: () => fetchAPI("/api/config"), updateConfig: (section: string, key: string, value: unknown) => fetchAPI("/api/config", { @@ -204,6 +341,12 @@ export const api = { }), getCommands: () => fetchAPI<{ commands: Command[] }>("/api/commands"), + toggleCommand: (name: string, enabled: boolean) => + fetchAPI(`/api/commands/${name}`, { + method: "PATCH", + body: JSON.stringify({ name, enabled }), + }), + getCommands: () => fetchAPI<{ commands: Command[] }>("/api/commands"), toggleCommand: (name: string, enabled: boolean) => fetchAPI(`/api/commands/${name}`, { method: "PATCH", @@ -212,6 +355,22 @@ export const api = { getGroups: () => fetchAPI<{ groups: Group[] }>("/api/groups"), getGroup: (id: string) => fetchAPI(`/api/groups/${id}`), + updateGroup: (id: string, settings: Group["settings"]) => + fetchAPI(`/api/groups/${id}`, { + method: "PUT", + body: JSON.stringify(settings), + }), + bulkUpdateGroups: ( + groupIds: string[], + action: "antilink" | "welcome" | "mute", + value: boolean, + ) => + fetchAPI("/api/groups/bulk", { + method: "POST", + body: JSON.stringify({ group_ids: groupIds, action, value }), + }), + getGroups: () => fetchAPI<{ groups: Group[] }>("/api/groups"), + getGroup: (id: string) => fetchAPI(`/api/groups/${id}`), updateGroup: (id: string, settings: Group["settings"]) => fetchAPI(`/api/groups/${id}`, { method: "PUT", @@ -228,13 +387,25 @@ export const api = { }), getStats: () => fetchAPI("/api/stats"), + getStats: () => fetchAPI("/api/stats"), + sendMessage: (to: string, text: string) => + fetchAPI("/api/send-message", { + method: "POST", + body: JSON.stringify({ to, text }), + }), sendMessage: (to: string, text: string) => fetchAPI("/api/send-message", { method: "POST", body: JSON.stringify({ to, text }), }), + sendMedia: (to: string, type: string, file: File, caption: string = "") => { + const formData = new FormData(); + formData.append("to", to); + formData.append("type", type); + formData.append("caption", caption); + formData.append("file", file); sendMedia: (to: string, type: string, file: File, caption: string = "") => { const formData = new FormData(); formData.append("to", to); @@ -242,6 +413,11 @@ export const api = { formData.append("caption", caption); formData.append("file", file); + const auth = typeof window !== "undefined" ? localStorage.getItem("dashboard_auth") : null; + const headers: Record = {}; + if (auth) { + headers["Authorization"] = `Basic ${auth}`; + } const auth = typeof window !== "undefined" ? localStorage.getItem("dashboard_auth") : null; const headers: Record = {}; if (auth) { @@ -261,7 +437,28 @@ export const api = { return res.json(); }); }, + return fetch(`${API_BASE}/api/send-media`, { + method: "POST", + body: formData, + headers, + }).then((res) => { + if (res.status === 401) { + if (typeof window !== "undefined") window.location.href = "/login"; + throw new Error("Unauthorized"); + } + if (!res.ok) throw new Error(`API Error: ${res.status}`); + return res.json(); + }); + }, + getAuthStatus: () => + fetchAPI<{ + is_logged_in: boolean; + is_pairing: boolean; + pair_code: string | null; + has_qr: boolean; + login_method: string; + }>("/api/auth/status"), getAuthStatus: () => fetchAPI<{ is_logged_in: boolean; @@ -272,19 +469,35 @@ export const api = { }>("/api/auth/status"), getQR: () => fetchAPI<{ qr: string | null }>("/api/auth/qr"), + getQR: () => fetchAPI<{ qr: string | null }>("/api/auth/qr"), + startPairing: (phone: string) => + fetchAPI("/api/auth/pair", { + method: "POST", + body: JSON.stringify({ phone }), + }), startPairing: (phone: string) => fetchAPI("/api/auth/pair", { method: "POST", body: JSON.stringify({ phone }), }), + getLogs: (limit = 100, level?: string, source: string = "bot") => + fetchAPI<{ logs: LogEntry[]; source: string }>( + `/api/logs?limit=${limit}&source=${source}${level ? `&level=${level}` : ""}`, + ), getLogs: (limit = 100, level?: string, source: string = "bot") => fetchAPI<{ logs: LogEntry[]; source: string }>( `/api/logs?limit=${limit}&source=${source}${level ? `&level=${level}` : ""}`, ), getRateLimit: () => fetchAPI("/api/ratelimit"), + updateRateLimit: (config: RateLimitConfig) => + fetchAPI("/api/ratelimit", { + method: "PUT", + body: JSON.stringify(config), + }), + getRateLimit: () => fetchAPI("/api/ratelimit"), updateRateLimit: (config: RateLimitConfig) => fetchAPI("/api/ratelimit", { method: "PUT", @@ -298,6 +511,18 @@ export const api = { body: JSON.stringify(config), }), getGoodbye: (groupId: string) => fetchAPI(`/api/groups/${groupId}/goodbye`), + updateGoodbye: (groupId: string, config: WelcomeConfig) => + fetchAPI(`/api/groups/${groupId}/goodbye`, { + method: "PUT", + body: JSON.stringify(config), + }), + getWelcome: (groupId: string) => fetchAPI(`/api/groups/${groupId}/welcome`), + updateWelcome: (groupId: string, config: WelcomeConfig) => + fetchAPI(`/api/groups/${groupId}/welcome`, { + method: "PUT", + body: JSON.stringify(config), + }), + getGoodbye: (groupId: string) => fetchAPI(`/api/groups/${groupId}/goodbye`), updateGoodbye: (groupId: string, config: WelcomeConfig) => fetchAPI(`/api/groups/${groupId}/goodbye`, { method: "PUT", @@ -367,7 +592,86 @@ export const api = { }, getNoteMediaUrl: (groupId: string, noteName: string) => `${API_BASE}/api/groups/${encodeURIComponent(groupId)}/notes/${encodeURIComponent(noteName)}/media`, + getTasks: () => fetchAPI<{ tasks: ScheduledTask[]; count: number }>("/api/tasks"), + deleteTask: (taskId: string) => fetchAPI(`/api/tasks/${taskId}`, { method: "DELETE" }), + toggleTask: (taskId: string, enabled: boolean) => + fetchAPI(`/api/tasks/${taskId}/toggle?enabled=${enabled}`, { + method: "PUT", + }), + createTask: (task: { + type: "reminder" | "auto_message" | "recurring"; + chat_jid: string; + message: string; + trigger_time?: string; + interval_minutes?: number; + cron_expression?: string; + }) => + fetchAPI<{ success: boolean; task: ScheduledTask }>("/api/tasks", { + method: "POST", + body: JSON.stringify(task), + }), + getNotes: (groupId: string) => + fetchAPI<{ notes: Note[]; count: number }>( + `/api/groups/${encodeURIComponent(groupId)}/notes`, + ), + createNote: (groupId: string, name: string, content: string, mediaType?: string) => + fetchAPI(`/api/groups/${encodeURIComponent(groupId)}/notes`, { + method: "POST", + body: JSON.stringify({ name, content, media_type: mediaType || "text" }), + }), + updateNote: (groupId: string, noteName: string, content: string, mediaType?: string) => + fetchAPI( + `/api/groups/${encodeURIComponent(groupId)}/notes/${encodeURIComponent(noteName)}`, + { + method: "PUT", + body: JSON.stringify({ content, media_type: mediaType || "text" }), + }, + ), + deleteNote: (groupId: string, noteName: string) => + fetchAPI( + `/api/groups/${encodeURIComponent(groupId)}/notes/${encodeURIComponent(noteName)}`, + { + method: "DELETE", + }, + ), + uploadNoteMedia: async (groupId: string, noteName: string, file: File) => { + const formData = new FormData(); + formData.append("file", file); + const headers: Record = {}; + const username = + typeof window !== "undefined" ? localStorage.getItem("dashboard_username") || "" : ""; + const password = + typeof window !== "undefined" ? localStorage.getItem("dashboard_password") || "" : ""; + if (username && password) { + headers["Authorization"] = `Basic ${btoa(`${username}:${password}`)}`; + } + const res = await fetch( + `${API_BASE}/api/groups/${encodeURIComponent(groupId)}/notes/${encodeURIComponent(noteName)}/media`, + { method: "POST", body: formData, headers }, + ); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise<{ success: boolean; media_type: string; media_path: string }>; + }, + getNoteMediaUrl: (groupId: string, noteName: string) => + `${API_BASE}/api/groups/${encodeURIComponent(groupId)}/notes/${encodeURIComponent(noteName)}/media`, + + getFilters: (groupId: string) => + fetchAPI<{ filters: Filter[]; count: number }>( + `/api/groups/${encodeURIComponent(groupId)}/filters`, + ), + createFilter: (groupId: string, trigger: string, response: string) => + fetchAPI(`/api/groups/${encodeURIComponent(groupId)}/filters`, { + method: "POST", + body: JSON.stringify({ trigger, response }), + }), + deleteFilter: (groupId: string, trigger: string) => + fetchAPI( + `/api/groups/${encodeURIComponent(groupId)}/filters/${encodeURIComponent(trigger)}`, + { + method: "DELETE", + }, + ), getFilters: (groupId: string) => fetchAPI<{ filters: Filter[]; count: number }>( `/api/groups/${encodeURIComponent(groupId)}/filters`, @@ -385,6 +689,22 @@ export const api = { }, ), + getBlacklist: (groupId: string) => + fetchAPI<{ words: string[]; count: number }>( + `/api/groups/${encodeURIComponent(groupId)}/blacklist`, + ), + addBlacklistWord: (groupId: string, word: string) => + fetchAPI(`/api/groups/${encodeURIComponent(groupId)}/blacklist`, { + method: "POST", + body: JSON.stringify({ word }), + }), + removeBlacklistWord: (groupId: string, word: string) => + fetchAPI( + `/api/groups/${encodeURIComponent(groupId)}/blacklist/${encodeURIComponent(word)}`, + { + method: "DELETE", + }, + ), getBlacklist: (groupId: string) => fetchAPI<{ words: string[]; count: number }>( `/api/groups/${encodeURIComponent(groupId)}/blacklist`, @@ -415,6 +735,10 @@ export const api = { return fetchAPI(`/api/analytics/timeline?${query}`); }, + leaveGroup: (groupId: string) => + fetchAPI(`/api/groups/${encodeURIComponent(groupId)}/leave`, { + method: "POST", + }), leaveGroup: (groupId: string) => fetchAPI(`/api/groups/${encodeURIComponent(groupId)}/leave`, { method: "POST", @@ -426,4 +750,10 @@ export const api = { method: "PUT", body: JSON.stringify(config), }), + getAIConfig: () => fetchAPI("/api/ai-config"), + updateAIConfig: (config: Omit) => + fetchAPI("/api/ai-config", { + method: "PUT", + body: JSON.stringify(config), + }), }; diff --git a/docs/commands/downloader.md b/docs/commands/downloader.md index acc97e8..5f2a91b 100644 --- a/docs/commands/downloader.md +++ b/docs/commands/downloader.md @@ -64,6 +64,19 @@ Reply to the search results or playlist listing with a **number** to select a vi --- +### `/cancel [all]` + +**Cancel an active download.** + +- **`/cancel`**: Cancels your own active download in the current chat. +- **`/cancel all`**: Cancels **all** active downloads in the current chat (requires Admin or Owner permission). + +::: tip Partial Files +Cancelled downloads are automatically cleaned up — no partial `.part` files are left on the server. +::: + +--- + ## Supported Sites yt-dlp supports **1000+ sites** including: @@ -89,5 +102,12 @@ yt-dlp supports **1000+ sites** including: - **Selection expires**: 5 minutes after showing options - Files are automatically cleaned up after sending -> [!NOTE] -> If a video exceeds the size limit, try `/audio` instead — audio files are much smaller. +## Troubleshooting + +### "Sign in to confirm you're not a bot" + +This is common on VPS environments. To resolve it: + +1. **Use Cookies**: Follow the [Cookie Setup Guide](/getting-started/configuration#youtube-cookies). +2. **EJS Support**: The bot automatically enables **External JS Scripts (EJS)** and tries multiple player clients (`web`, `android`, `tv`) to bypass challenges. +3. **Deno/Node**: Ensure you have `Deno` or `Node.js` installed on your VPS, as `yt-dlp` uses them to solve YouTube's JavaScript challenges. diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index cfbafef..7ce1adf 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -6,7 +6,15 @@ Zero Ichi includes a web dashboard for monitoring and administration, built with ### 1. Start the Bot -The dashboard API starts automatically with the bot: +The dashboard API starts automatically with the bot if enabled in `config.json`: + +```json +"dashboard": { + "enabled": true +} +``` + +Then start the bot: ```bash uv run main.py diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 2ba1125..e00ae11 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -261,12 +261,45 @@ Globally disable specific commands by name. --- +## `dashboard` — Dashboard Settings {#dashboard} + +Configure the web dashboard API. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `enabled` | `boolean` | `false` | Enable the dashboard API server on startup | + +```json +{ + "dashboard": { + "enabled": false + } +} +``` + +::: note +The dashboard starts on port `8000` by default if enabled. +This dashboard api is required to enable if you want to use the dashboard. +::: + +--- + ## Environment Variables Store sensitive values in `.env` (never commit this file): ```bash AI_API_KEY=your_api_key_here +YOUTUBE_COOKIES_PATH=data/cookies.txt +``` + +### YouTube Cookies + +If you are running the bot on a VPS, YouTube may block your requests with "Sign in to confirm you're not a bot" errors. To fix this: + +1. Export your cookies from a logged-in browser (using an extension like [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/ccmgnabipihkhmhnoicpbihnkhnleogp)). +2. Save them to `data/cookies.txt` (or whatever path you set in `.env`). +3. Set the environment variable: `YOUTUBE_COOKIES_PATH=data/cookies.txt`. ``` Copy the example file to get started: @@ -332,6 +365,9 @@ A complete `config.json` with all sections: "downloader": { "max_file_size_mb": 180 }, - "disabled_commands": [] + "disabled_commands": [], + "dashboard": { + "enabled": false + } } ``` diff --git a/locales/en.json b/locales/en.json index 592af72..d5a86e9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -472,7 +472,8 @@ "searching": "Searching YouTube...", "search_results": "Results for: {query}", "no_results": "No results found for *{query}*.", - "choose_result_hint": "Reply with the number to select a video." + "choose_result_hint": "Reply with the number to select a video.", + "cancelled": "Download cancelled." }, "toimg": { "reply_to_sticker": "Reply to a sticker to convert it to an image.", diff --git a/locales/id.json b/locales/id.json index 8c4fb02..c55d193 100644 --- a/locales/id.json +++ b/locales/id.json @@ -472,7 +472,8 @@ "searching": "Nyari di YouTube...", "search_results": "Hasil untuk: {query}", "no_results": "Gak ketemu hasil buat *{query}*.", - "choose_result_hint": "Balas dengan nomor untuk milih video." + "choose_result_hint": "Balas dengan nomor untuk milih video.", + "cancelled": "Download dibatalkan." }, "toimg": { "reply_to_sticker": "Reply ke stiker buat convert jadi gambar.", diff --git a/main.py b/main.py index a6cc2d0..a826501 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,15 @@ +""" +Zero Ichi - WhatsApp Bot entry point. +""" + import asyncio import base64 import importlib import io +import os import signal import sys +import traceback from pathlib import Path import segno @@ -14,20 +20,25 @@ from neonize.proto.waCompanionReg.WAWebProtobufsCompanionReg_pb2 import DeviceProps from watchfiles import awatch -from config.settings import ( +load_dotenv(Path(__file__).parent / ".env") + +if os.getenv("AI_API_KEY") and not os.getenv("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = os.getenv("AI_API_KEY") + +from config.settings import ( # noqa: E402 AUTO_RELOAD, BOT_NAME, LOGIN_METHOD, PHONE_NUMBER, ) -from core.cache import message_cache -from core.client import BotClient -from core.command import command_loader -from core.env import validate_environment -from core.handlers.welcome import handle_member_join, handle_member_leave -from core.i18n import init_i18n -from core.jid_resolver import resolve_pair -from core.logger import ( +from core.cache import message_cache # noqa: E402 +from core.client import BotClient # noqa: E402 +from core.command import command_loader # noqa: E402 +from core.env import validate_environment # noqa: E402 +from core.handlers.welcome import handle_member_join, handle_member_leave # noqa: E402 +from core.i18n import init_i18n # noqa: E402 +from core.jid_resolver import resolve_pair # noqa: E402 +from core.logger import ( # noqa: E402 console, log_bullet, log_error, @@ -41,15 +52,14 @@ show_pair_help, show_qr_prompt, ) -from core.message import MessageHelper -from core.middleware import MessageContext -from core.middlewares import build_pipeline -from core.runtime_config import runtime_config -from core.scheduler import init_scheduler -from core.session import session_state -from core.shared import set_bot - -load_dotenv() +from core.message import MessageHelper # noqa: E402 +from core.middleware import MessageContext # noqa: E402 +from core.middlewares import build_pipeline # noqa: E402 +from core.runtime_config import runtime_config # noqa: E402 +from core.scheduler import init_scheduler # noqa: E402 +from core.session import session_state # noqa: E402 +from core.shared import set_bot # noqa: E402 + validate_environment() client = NewAClient( f"{BOT_NAME}.session", @@ -174,8 +184,6 @@ async def message_handler(c: NewAClient, event: MessageEv) -> None: await pipeline.execute(ctx) except Exception as e: log_error(f"Unhandled error in message handler: {e}") - import traceback - log_error(traceback.format_exc()) @@ -188,19 +196,23 @@ async def start_bot() -> None: show_banner("Zero Ichi", "WhatsApp Bot built with 💖") - try: - import uvicorn + dashboard_enabled = runtime_config.get_nested("dashboard", "enabled", default=False) + if dashboard_enabled: + try: + import uvicorn - from dashboard_api import app as api_app + from dashboard_api import app as api_app - config = uvicorn.Config(api_app, host="0.0.0.0", port=8000, log_level="warning") - server = uvicorn.Server(config) - asyncio.create_task(server.serve()) - log_success("Dashboard API starting on http://localhost:8000") - except ImportError: - log_warning("Dashboard API not available (install fastapi & uvicorn)") - except Exception as e: - log_warning(f"Dashboard API failed to start: {e}") + config = uvicorn.Config(api_app, host="0.0.0.0", port=8000, log_level="warning") + server = uvicorn.Server(config) + asyncio.create_task(server.serve()) + log_success("Dashboard API starting on http://localhost:8000") + except ImportError: + log_warning("Dashboard API not available (install fastapi & uvicorn)") + except Exception as e: + log_warning(f"Dashboard API failed to start: {e}") + else: + log_info("Dashboard API is disabled in config.json") log_step("Starting bot...") log_bullet(f"Session: {BOT_NAME}")