From 74de416903409d149ed1ff58004a6ce6720e2fe9 Mon Sep 17 00:00:00 2001 From: deadman96385 Date: Fri, 3 Apr 2026 14:31:38 -0500 Subject: [PATCH 1/3] fix: improve dump failure reporting and logging - report the failed step from error_context instead of stale progress state - record the previous successful step from progress history - attach a failure log with progress history and sanitized traceback data - queue document delivery through the message queue for retry handling - redact URL credentials and query params in failure logs - tolerate malformed progress percentages when building log output --- dumpyarabot/arq_jobs.py | 119 +++++++++++++- dumpyarabot/message_queue.py | 291 +++++++++++++++++++++-------------- 2 files changed, 288 insertions(+), 122 deletions(-) diff --git a/dumpyarabot/arq_jobs.py b/dumpyarabot/arq_jobs.py index 8e5e8b4..d028de9 100644 --- a/dumpyarabot/arq_jobs.py +++ b/dumpyarabot/arq_jobs.py @@ -11,6 +11,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Dict, Any, Optional +from urllib.parse import urlsplit, urlunsplit from rich.console import Console @@ -33,13 +34,63 @@ re.compile(r'(token[=:]\s*)\S+', re.IGNORECASE), re.compile(r'(password[=:]\s*)\S+', re.IGNORECASE), ] +_URL_PATTERN = re.compile(r'https?://[^\s<>"\']+', re.IGNORECASE) def _sanitize_traceback(tb_str: str) -> str: """Remove sensitive tokens and credentials from traceback strings.""" for pattern in _SENSITIVE_PATTERNS: tb_str = pattern.sub(r'\1[REDACTED]', tb_str) - return tb_str + return _URL_PATTERN.sub( + lambda match: _sanitize_url_for_log(match.group(0).rstrip(".,;:!?)\"]}'")), + tb_str, + ) + + +def _sanitize_text(value: Any) -> str: + """Sanitize arbitrary log text.""" + return _sanitize_traceback(str(value)) + + +def _derive_last_successful_step(progress_history: list[Dict[str, Any]], failed_step: Optional[str] = None) -> Optional[str]: + """Return the most recent successful step before a failed step, if known.""" + if not progress_history: + return None + + if failed_step: + for entry in reversed(progress_history): + message = entry.get("message") + if message and message != failed_step: + return message + return None + + latest = progress_history[-1].get("message") + return latest if latest else None + + +def _sanitize_url_for_log(url_value: Any) -> str: + """Redact credentials and query parameters from logged URLs.""" + url = str(url_value or "unknown") + try: + parts = urlsplit(url) + except ValueError: + return url + + try: + hostname = parts.hostname or "" + port = parts.port + username = parts.username + except ValueError: + return url + + netloc = hostname + if port: + netloc = f"{netloc}:{port}" + if username: + netloc = f"[REDACTED]@{netloc}" + + sanitized = urlunsplit((parts.scheme, netloc, parts.path, "", "")) + return sanitized or url class PeriodicTimerUpdate: @@ -135,6 +186,44 @@ async def _send_status_update( ) +def _build_failure_log_text(job_data: Dict[str, Any]) -> str: + """Assemble a plain-text failure log from job metadata.""" + lines = ["=== DUMPYARABOT JOB FAILURE LOG ==="] + + lines.append(f"Job ID: {job_data.get('job_id', 'unknown')}") + lines.append(f"Worker: {job_data.get('worker_id', 'unknown')}") + url = (job_data.get("dump_args") or {}).get("url", "unknown") + lines.append(f"URL: {_sanitize_url_for_log(url)}") + + metadata = job_data.get("metadata") or {} + lines.append(f"Started: {metadata.get('start_time', 'unknown')}") + lines.append(f"Failed: {metadata.get('end_time', 'unknown')}") + + lines.append("\n=== PROGRESS HISTORY ===") + for entry in metadata.get("progress_history") or []: + ts = entry.get("timestamp", "") + msg = entry.get("message", "") + try: + pct_display = f"{float(entry.get('percentage', 0) or 0):.0f}%" + except (TypeError, ValueError): + pct_display = "?%" + lines.append(f"[{ts}] ({pct_display}) {_sanitize_text(msg)}") + + error_ctx = metadata.get("error_context") or {} + if error_ctx: + lines.append("\n=== ERROR CONTEXT ===") + lines.append(f"Failed at: {_sanitize_text(error_ctx.get('current_step', 'unknown'))}") + if error_ctx.get('last_successful_step'): + lines.append(f"Last successful: {_sanitize_text(error_ctx['last_successful_step'])}") + lines.append(f"Error message: {_sanitize_text(error_ctx.get('message', 'unknown'))}") + tb = error_ctx.get("traceback") + if tb: + lines.append("\n=== TRACEBACK (sanitized) ===") + lines.append(_sanitize_traceback(tb)) + + return "\n".join(lines) + + async def _send_failure_notification(job_data: Dict[str, Any], error_details: str) -> None: """Send a failure notification using existing message queue - PRESERVING ALL TELEGRAM FEATURES.""" @@ -143,7 +232,8 @@ async def _send_failure_notification(job_data: Dict[str, Any], error_details: st metadata = job_data.get("metadata") or {} progress_history = metadata.get("progress_history") or [] last_progress = progress_history[-1] if progress_history else {} - last_step = last_progress.get("message", "Unknown step") + error_ctx = metadata.get("error_context") or {} + last_step = error_ctx.get("current_step") or last_progress.get("message", "Unknown step") last_pct = last_progress.get("percentage", 0.0) failure_progress = { @@ -201,6 +291,22 @@ async def _send_failure_notification(job_data: Dict[str, Any], error_details: st console.print(f"[green]Sent failure notification for job {job_data.get('job_id', 'unknown')}[/green]") + # Send failure log as a text file for debugging + try: + log_text = _build_failure_log_text(job_data) + log_bytes = log_text.encode("utf-8") + job_id_short = str(job_data.get("job_id", "unknown"))[:8] + filename = f"dump_failure_{job_id_short}.txt" + target_chat = primary_allowed_chat if is_moderated_request and primary_allowed_chat is not None else chat_id + await message_queue.send_document( + chat_id=target_chat, + content=log_bytes, + filename=filename, + caption="Failure log", + ) + except Exception as log_err: + console.print(f"[yellow]Could not queue failure log file: {log_err}[/yellow]") + except Exception as e: console.print(f"[red]Failed to send failure notification: {e}[/red]") console.print_exception() @@ -458,8 +564,11 @@ async def _on_download_progress(dp: DownloadProgress) -> None: "end_time": datetime.now(timezone.utc).isoformat(), "error_context": { "message": str(e), - "current_step": progress.get("current_step", "Unknown step"), - "last_successful_step": progress_history[-1]["message"] if progress_history else "None", + "current_step": progress_history[-1].get("message", "Unknown step") if progress_history else "Unknown step", + "last_successful_step": _derive_last_successful_step( + progress_history, + progress_history[-1].get("message") if progress_history else None, + ), "failure_time": datetime.now(timezone.utc).isoformat(), "traceback": _sanitize_traceback(traceback.format_exc()) } @@ -488,7 +597,7 @@ async def _on_download_progress(dp: DownloadProgress) -> None: "error_context": { "message": f"Critical error: {str(e)}", "current_step": "Critical failure", - "last_successful_step": progress_history[-1]["message"] if progress_history else "None", + "last_successful_step": _derive_last_successful_step(progress_history) or "None", "failure_time": datetime.now(timezone.utc).isoformat(), "traceback": _sanitize_traceback(traceback.format_exc()) } diff --git a/dumpyarabot/message_queue.py b/dumpyarabot/message_queue.py index ea7e96d..7eff86d 100644 --- a/dumpyarabot/message_queue.py +++ b/dumpyarabot/message_queue.py @@ -1,11 +1,12 @@ -import asyncio -import uuid +import asyncio +import base64 +import uuid from datetime import datetime, timedelta, timezone from enum import Enum -from typing import Any, Dict, Optional, List - -import redis.asyncio as redis -from pydantic import BaseModel, Field +from typing import Any, Dict, Optional, List + +import redis.asyncio as redis +from pydantic import BaseModel, Field, model_validator from rich.console import Console from telegram import Bot from telegram.error import RetryAfter, TelegramError, NetworkError @@ -17,13 +18,14 @@ console = Console() -class MessageType(str, Enum): +class MessageType(str, Enum): """Types of messages that can be queued.""" COMMAND_REPLY = "command_reply" STATUS_UPDATE = "status_update" - NOTIFICATION = "notification" - CROSS_CHAT = "cross_chat" - ERROR = "error" + NOTIFICATION = "notification" + CROSS_CHAT = "cross_chat" + ERROR = "error" + DOCUMENT = "document" class MessagePriority(str, Enum): @@ -34,14 +36,17 @@ class MessagePriority(str, Enum): LOW = "low" # Background notifications, cleanup -class QueuedMessage(BaseModel): +class QueuedMessage(BaseModel): """Schema for messages in the Redis queue.""" message_id: str type: MessageType priority: MessagePriority chat_id: int - text: str - parse_mode: str + text: Optional[str] = None + parse_mode: Optional[str] = None + document_content_b64: Optional[str] = None + document_filename: Optional[str] = None + caption: Optional[str] = None reply_to_message_id: Optional[int] = None reply_parameters: Optional[Dict[str, Any]] = None edit_message_id: Optional[int] = None @@ -54,14 +59,26 @@ class QueuedMessage(BaseModel): scheduled_for: Optional[datetime] = None context: Dict[str, Any] = Field(default_factory=dict) - def __init__(self, **data): - if "message_id" not in data: - data["message_id"] = str(uuid.uuid4()) + def __init__(self, **data): + if "message_id" not in data: + data["message_id"] = str(uuid.uuid4()) if "created_at" not in data: data["created_at"] = datetime.now(timezone.utc) - if "parse_mode" not in data or data.get("parse_mode") is None: - data["parse_mode"] = settings.DEFAULT_PARSE_MODE - super().__init__(**data) + if "parse_mode" not in data or data.get("parse_mode") is None: + data["parse_mode"] = settings.DEFAULT_PARSE_MODE + super().__init__(**data) + + @model_validator(mode="after") + def validate_type_specific_fields(self) -> "QueuedMessage": + """Enforce required fields for text and document message types.""" + if self.type == MessageType.DOCUMENT: + if not self.document_content_b64 or not self.document_filename: + raise ValueError("document messages require document_content_b64 and document_filename") + return self + + if not self.text: + raise ValueError(f"{self.type.value} messages require text") + return self # Rebuild the model to resolve any forward references @@ -294,6 +311,26 @@ async def send_immediate_status_update( ) return await self.publish_and_return_placeholder(message) + async def send_document( + self, + chat_id: int, + content: bytes, + filename: str, + caption: Optional[str] = None, + parse_mode: Optional[str] = settings.DEFAULT_PARSE_MODE, + ) -> None: + """Queue a document for delivery.""" + message = QueuedMessage( + type=MessageType.DOCUMENT, + priority=MessagePriority.URGENT, + chat_id=chat_id, + document_content_b64=base64.b64encode(content).decode("ascii"), + document_filename=filename, + caption=caption, + parse_mode=parse_mode, + ) + await self.publish(message) + def set_bot(self, bot: Bot) -> None: """Set the Telegram bot instance.""" self._bot = bot @@ -388,20 +425,40 @@ async def _consume_messages(self) -> None: console.print(f"[red]Error in message consumer: {e}[/red]") await asyncio.sleep(1) # Wait before retrying - async def _process_message(self, message: QueuedMessage) -> bool: - """Process a single message.""" - if not self._bot: - console.print("[red]Bot instance not set in MessageQueue[/red]") - return False - - try: - parse_mode_info = f" with parse_mode={message.parse_mode}" if message.parse_mode else " with NO parse_mode" - console.print(f"[blue]Processing {message.type.value} message for chat {message.chat_id}{parse_mode_info}[/blue]") - - # Prepare common parameters - kwargs = { - "chat_id": message.chat_id, - "text": message.text, + async def _process_message(self, message: QueuedMessage) -> bool: + """Process a single message.""" + if not self._bot: + console.print("[red]Bot instance not set in MessageQueue[/red]") + return False + + try: + parse_mode_info = f" with parse_mode={message.parse_mode}" if message.parse_mode else " with NO parse_mode" + console.print(f"[blue]Processing {message.type.value} message for chat {message.chat_id}{parse_mode_info}[/blue]") + + if message.type == MessageType.DOCUMENT: + import io + from telegram import InputFile + + if not message.document_content_b64 or not message.document_filename: + console.print("[red]Document message missing content or filename; dropping malformed message without retry[/red]") + return True + + await self._bot.send_document( + chat_id=message.chat_id, + document=InputFile( + io.BytesIO(base64.b64decode(message.document_content_b64)), + filename=message.document_filename, + ), + caption=message.caption, + parse_mode=message.parse_mode, + ) + console.print(f"[green]Successfully processed {message.type.value} message[/green]") + return True + + # Prepare common parameters + kwargs = { + "chat_id": message.chat_id, + "text": message.text, } if message.parse_mode: @@ -583,16 +640,16 @@ async def send_cross_chat_edit( def _arq_status_to_job_status(self, arq_status: str) -> JobStatus: """Convert ARQ status to JobStatus enum.""" - status_mapping = { - "queued": JobStatus.QUEUED, - "in_progress": JobStatus.PROCESSING, - "complete": JobStatus.COMPLETED, - "completed": JobStatus.COMPLETED, - "failed": JobStatus.FAILED, - "cancelled": JobStatus.CANCELLED, - "not_found": JobStatus.FAILED, - "deferred": JobStatus.QUEUED - } + status_mapping = { + "queued": JobStatus.QUEUED, + "in_progress": JobStatus.PROCESSING, + "complete": JobStatus.COMPLETED, + "completed": JobStatus.COMPLETED, + "failed": JobStatus.FAILED, + "cancelled": JobStatus.CANCELLED, + "not_found": JobStatus.FAILED, + "deferred": JobStatus.QUEUED + } return status_mapping.get(arq_status, JobStatus.FAILED) async def cancel_job(self, job_id: str) -> bool: @@ -632,19 +689,19 @@ async def get_job_queue_stats(self) -> Dict[str, Any]: # ========== METADATA ENHANCED METHODS ========== - async def queue_dump_job_with_metadata(self, enhanced_job_data: Dict[str, Any]) -> str: - """Queue a dump job with metadata support.""" - from dumpyarabot.arq_config import arq_pool - - job_data = enhanced_job_data - - # Enqueue to ARQ with metadata. - # Keep-result settings belong to the worker function/worker config, not enqueue kwargs. - await arq_pool.enqueue_job( - "process_firmware_dump", - job_data, - job_id=enhanced_job_data["job_id"], - ) + async def queue_dump_job_with_metadata(self, enhanced_job_data: Dict[str, Any]) -> str: + """Queue a dump job with metadata support.""" + from dumpyarabot.arq_config import arq_pool + + job_data = enhanced_job_data + + # Enqueue to ARQ with metadata. + # Keep-result settings belong to the worker function/worker config, not enqueue kwargs. + await arq_pool.enqueue_job( + "process_firmware_dump", + job_data, + job_id=enhanced_job_data["job_id"], + ) console.print(f"[green]Queued ARQ dump job {enhanced_job_data['job_id']} with metadata[/green]") return enhanced_job_data["job_id"] @@ -657,45 +714,45 @@ async def get_job_status(self, job_id: str) -> Optional[DumpJob]: if not arq_status: return None - result = arq_status.get("result") - job_payload = arq_status.get("job_data") or {} - metadata = {} - if isinstance(job_payload.get("metadata"), dict): - metadata = job_payload["metadata"] - if isinstance(result, dict) and isinstance(result.get("metadata"), dict): - metadata = result["metadata"] - - dump_args_data = job_payload.get("dump_args") or {} - telegram_context = metadata.get("telegram_context") or {} - url = telegram_context.get("url") or dump_args_data.get("url") + result = arq_status.get("result") + job_payload = arq_status.get("job_data") or {} + metadata = {} + if isinstance(job_payload.get("metadata"), dict): + metadata = job_payload["metadata"] + if isinstance(result, dict) and isinstance(result.get("metadata"), dict): + metadata = result["metadata"] + + dump_args_data = job_payload.get("dump_args") or {} + telegram_context = metadata.get("telegram_context") or {} + url = telegram_context.get("url") or dump_args_data.get("url") if not url: return None # Build enhanced DumpJob with metadata job_data = { - "job_id": job_id, - "status": self._arq_status_to_job_status(arq_status["status"]), - "dump_args": DumpArguments( - url=url, - use_alt_dumper=dump_args_data.get("use_alt_dumper", False), - force=dump_args_data.get("force", False), - use_privdump=dump_args_data.get("use_privdump", False), - initial_message_id=dump_args_data.get("initial_message_id") or telegram_context.get("message_id"), - initial_chat_id=dump_args_data.get("initial_chat_id") or telegram_context.get("chat_id"), - ).model_dump(), - "add_blacklist": False, - "created_at": arq_status.get("enqueue_time"), - "started_at": metadata.get("start_time"), - "completed_at": metadata.get("end_time"), - "worker_id": "arq_worker", - "error_details": metadata.get("error_context", {}).get("message") if metadata.get("error_context") else None, - "result_data": result if isinstance(result, dict) else None, - "progress": self._extract_current_progress(metadata), - "metadata": metadata, - "initial_message_id": job_payload.get("initial_message_id") or dump_args_data.get("initial_message_id"), - "initial_chat_id": job_payload.get("initial_chat_id") or dump_args_data.get("initial_chat_id"), - } + "job_id": job_id, + "status": self._arq_status_to_job_status(arq_status["status"]), + "dump_args": DumpArguments( + url=url, + use_alt_dumper=dump_args_data.get("use_alt_dumper", False), + force=dump_args_data.get("force", False), + use_privdump=dump_args_data.get("use_privdump", False), + initial_message_id=dump_args_data.get("initial_message_id") or telegram_context.get("message_id"), + initial_chat_id=dump_args_data.get("initial_chat_id") or telegram_context.get("chat_id"), + ).model_dump(), + "add_blacklist": False, + "created_at": arq_status.get("enqueue_time"), + "started_at": metadata.get("start_time"), + "completed_at": metadata.get("end_time"), + "worker_id": "arq_worker", + "error_details": metadata.get("error_context", {}).get("message") if metadata.get("error_context") else None, + "result_data": result if isinstance(result, dict) else None, + "progress": self._extract_current_progress(metadata), + "metadata": metadata, + "initial_message_id": job_payload.get("initial_message_id") or dump_args_data.get("initial_message_id"), + "initial_chat_id": job_payload.get("initial_chat_id") or dump_args_data.get("initial_chat_id"), + } return DumpJob.model_validate(job_data) @@ -714,34 +771,34 @@ def _extract_current_progress(self, metadata: Dict) -> Optional[Dict]: ).model_dump() return None - async def get_active_jobs_with_metadata(self) -> List[DumpJob]: - """Get active queued and running jobs with metadata.""" - from dumpyarabot.arq_config import arq_pool - - active_jobs: List[DumpJob] = [] - for job_id in await arq_pool.get_active_job_ids(): - job = await self.get_job_status(job_id) - if job and job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}: - active_jobs.append(job) - - active_jobs.sort(key=lambda job: job.created_at) - return active_jobs - - async def get_recent_jobs_with_metadata(self, limit: int = 10) -> List[DumpJob]: - """Get recent completed or failed jobs with metadata.""" - from dumpyarabot.arq_config import arq_pool - - recent_jobs: List[DumpJob] = [] - for result in await arq_pool.get_recent_job_results(limit=limit): - job = await self.get_job_status(result["job_id"]) - if job: - recent_jobs.append(job) - - recent_jobs.sort( - key=lambda job: job.completed_at or job.started_at or job.created_at, - reverse=True, - ) - return recent_jobs[:limit] + async def get_active_jobs_with_metadata(self) -> List[DumpJob]: + """Get active queued and running jobs with metadata.""" + from dumpyarabot.arq_config import arq_pool + + active_jobs: List[DumpJob] = [] + for job_id in await arq_pool.get_active_job_ids(): + job = await self.get_job_status(job_id) + if job and job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}: + active_jobs.append(job) + + active_jobs.sort(key=lambda job: job.created_at) + return active_jobs + + async def get_recent_jobs_with_metadata(self, limit: int = 10) -> List[DumpJob]: + """Get recent completed or failed jobs with metadata.""" + from dumpyarabot.arq_config import arq_pool + + recent_jobs: List[DumpJob] = [] + for result in await arq_pool.get_recent_job_results(limit=limit): + job = await self.get_job_status(result["job_id"]) + if job: + recent_jobs.append(job) + + recent_jobs.sort( + key=lambda job: job.completed_at or job.started_at or job.created_at, + reverse=True, + ) + return recent_jobs[:limit] # Legacy methods (kept for backward compatibility during transition) async def get_next_job(self, worker_id: str) -> Optional[DumpJob]: From b465b3a764fc6c60720450a930164339f2df6321 Mon Sep 17 00:00:00 2001 From: deadman96385 Date: Fri, 3 Apr 2026 15:15:08 -0500 Subject: [PATCH 2/3] chore: Remove emoji usage from telegram messages --- dumpyarabot/handlers.py | 12 ++++++------ dumpyarabot/message_formatting.py | 10 ++-------- dumpyarabot/mockup_handlers.py | 12 ++++++------ 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/dumpyarabot/handlers.py b/dumpyarabot/handlers.py index 5605920..a0a7ac6 100644 --- a/dumpyarabot/handlers.py +++ b/dumpyarabot/handlers.py @@ -103,7 +103,7 @@ async def dump( else: initial_text = f" *Firmware Dump Queued*\n\n *URL:* `{url}`\n" - initial_text += f"šŸ†” *Job ID:* `{job.job_id}`\n" + initial_text += f"*Job ID:* `{job.job_id}`\n" # Format options options_list = [] @@ -118,7 +118,7 @@ async def dump( initial_text += f"\n{generate_progress_bar(None)}\n" initial_text += " Queued for processing...\n\n" - initial_text += "ā± *Elapsed:* 0s\n" + initial_text += "*Elapsed:* 0s\n" initial_text += " *Worker:* Waiting for assignment...\n" # Send initial message directly to get real Telegram message ID @@ -231,17 +231,17 @@ async def cancel_dump(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if cancelled: escaped_job_id = escape_markdown(job_id) - response_message = f" *Job cancelled successfully*\n\nšŸ†” *Job ID:* `{escaped_job_id}`\n\nThe dump job has been removed from the queue or stopped if it was in progress." + response_message = f" *Job cancelled successfully*\n\n*Job ID:* `{escaped_job_id}`\n\nThe dump job has been removed from the queue or stopped if it was in progress." console.print(f"[green]Successfully cancelled job {job_id}[/green]") else: escaped_job_id = escape_markdown(job_id) - response_message = f" *Job not found*\n\nšŸ†” *Job ID:* `{escaped_job_id}`\n\nThe job was not found in the queue or may have already completed." + response_message = f" *Job not found*\n\n*Job ID:* `{escaped_job_id}`\n\nThe job was not found in the queue or may have already completed." except Exception as e: console.print(f"[red]Error processing cancel request: {e}[/red]") console.print_exception() escaped_job_id = escape_markdown(job_id) escaped_error = escape_markdown(str(e)) - response_message = f" *Error cancelling job*\n\nšŸ†” *Job ID:* `{escaped_job_id}`\n\nError: {escaped_error}" + response_message = f" *Error cancelling job*\n\n*Job ID:* `{escaped_job_id}`\n\nError: {escaped_error}" await message_queue.send_reply( chat_id=chat.id, @@ -407,7 +407,7 @@ async def restart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "• Reload configuration and code\n" "• Clear in-memory state\n" "• Restart with latest changes\n\n" - "ā± *This confirmation will expire in 30 seconds*" + "*This confirmation will expire in 30 seconds*" ) # Convert keyboard to dict for queue serialization diff --git a/dumpyarabot/message_formatting.py b/dumpyarabot/message_formatting.py index 872a006..719a02c 100644 --- a/dumpyarabot/message_formatting.py +++ b/dumpyarabot/message_formatting.py @@ -387,13 +387,6 @@ def format_build_summary_info( Returns: Formatted build summary """ - # Format result with emoji - result_emoji = { - "SUCCESS": "", - "FAILURE": "", - "UNSTABLE": "", - "ABORTED": "ā¹", - }.get(result, "") # Build summary parts escaped_job_name = escape_markdown(job_name) @@ -402,7 +395,7 @@ def format_build_summary_info( summary_parts = [ f"*Job:* `{escaped_job_name}`", f"*Build:* `#{escaped_build_number}`", - f"*Result:* {result_emoji} {result or 'Unknown'}" + f"*Result:* {result or 'Unknown'}" ] if timestamp_str: @@ -682,3 +675,4 @@ def format_time_ago(timestamp) -> str: return f"{seconds // 3600}h ago" else: return f"{seconds // 86400}d ago" + diff --git a/dumpyarabot/mockup_handlers.py b/dumpyarabot/mockup_handlers.py index c6f218e..1e5f1bb 100644 --- a/dumpyarabot/mockup_handlers.py +++ b/dumpyarabot/mockup_handlers.py @@ -333,7 +333,7 @@ async def _handle_mockup_back( ) await query.edit_message_text( - "⬅ Returned to Accept/Reject", + "Returned to Accept/Reject", reply_markup=_create_compact_controls_keyboard(request_id), ) elif mockup_state.current_menu == "completed": @@ -352,7 +352,7 @@ async def _handle_mockup_back( ) await query.edit_message_text( - "⬅ Returned to Options", + "Returned to Options", reply_markup=_create_compact_controls_keyboard(request_id), ) elif mockup_state.current_menu == "rejected": @@ -377,7 +377,7 @@ async def _handle_mockup_back( ) await query.edit_message_text( - "⬅ Returned to Accept/Reject", + "Returned to Accept/Reject", reply_markup=_create_compact_controls_keyboard(request_id), ) elif mockup_state.current_menu == "cancelled": @@ -402,13 +402,13 @@ async def _handle_mockup_back( ) await query.edit_message_text( - "⬅ Returned to Accept/Reject", + "Returned to Accept/Reject", reply_markup=_create_compact_controls_keyboard(request_id), ) else: # Already at initial state await query.edit_message_text( - "ℹ Already at initial state", + "Already at initial state", reply_markup=_create_compact_controls_keyboard(request_id), ) @@ -518,7 +518,7 @@ def _create_compact_controls_keyboard(request_id: str) -> "InlineKeyboardMarkup" " Reset", callback_data=f"{CALLBACK_MOCKUP_RESET}{request_id}" ), InlineKeyboardButton( - "⬅ Back", callback_data=f"{CALLBACK_MOCKUP_BACK}{request_id}" + "Back", callback_data=f"{CALLBACK_MOCKUP_BACK}{request_id}" ), InlineKeyboardButton( " Delete", callback_data=f"{CALLBACK_MOCKUP_DELETE}{request_id}" From ff4dcf750358fc2f86fc4d7f314fa67ef42d99b4 Mon Sep 17 00:00:00 2001 From: deadman96385 Date: Fri, 3 Apr 2026 15:58:59 -0500 Subject: [PATCH 3/3] fix: carry PR #42 recovery.img extraction into the rewrite - add recovery.img to boot image processing in the Python extractor - unpack recovery ramdisks in the rewrite-native path - extract recovery device trees for parity with other boot image handlers --- dumpyarabot/firmware_extractor.py | 47 +++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/dumpyarabot/firmware_extractor.py b/dumpyarabot/firmware_extractor.py index a4c9bdc..79ff1f6 100644 --- a/dumpyarabot/firmware_extractor.py +++ b/dumpyarabot/firmware_extractor.py @@ -165,9 +165,16 @@ async def _extract_fsg_partition(self): safe_remove_file(fsg_file) console.print("[green]Successfully extracted fsg.mbn[/green]") - async def process_boot_images(self) -> None: - """Process boot images (boot.img, vendor_boot.img, etc.).""" - boot_images = ["init_boot.img", "vendor_kernel_boot.img", "vendor_boot.img", "boot.img", "dtbo.img"] + async def process_boot_images(self) -> None: + """Process boot images (boot.img, vendor_boot.img, etc.).""" + boot_images = [ + "init_boot.img", + "vendor_kernel_boot.img", + "vendor_boot.img", + "boot.img", + "recovery.img", + "dtbo.img", + ] # Move boot images to work directory root if they're in subdirectories for image_name in boot_images: @@ -191,12 +198,14 @@ async def _process_single_boot_image(self, image_path: Path): console.print(f"[blue]Processing {image_name}...[/blue]") - if image_name == "boot.img": - await self._process_boot_img(image_path, output_dir) - elif image_name in ["vendor_boot.img", "vendor_kernel_boot.img", "init_boot.img"]: - await self._process_vendor_boot_img(image_path, output_dir) - elif image_name == "dtbo.img": - await self._process_dtbo_img(image_path, output_dir) + if image_name == "boot.img": + await self._process_boot_img(image_path, output_dir) + elif image_name == "recovery.img": + await self._process_recovery_img(image_path, output_dir) + elif image_name in ["vendor_boot.img", "vendor_kernel_boot.img", "init_boot.img"]: + await self._process_vendor_boot_img(image_path, output_dir) + elif image_name == "dtbo.img": + await self._process_dtbo_img(image_path, output_dir) async def _process_boot_img(self, image_path: Path, output_dir: Path): """Process boot.img with comprehensive analysis.""" @@ -218,16 +227,25 @@ async def _process_boot_img(self, image_path: Path, output_dir: Path): # Extract and process device tree blobs await self._extract_device_trees(image_path, output_dir) - async def _process_vendor_boot_img(self, image_path: Path, output_dir: Path): - """Process vendor_boot.img or similar images.""" - output_dir.mkdir(exist_ok=True) + async def _process_vendor_boot_img(self, image_path: Path, output_dir: Path): + """Process vendor_boot.img or similar images.""" + output_dir.mkdir(exist_ok=True) # Extract contents if using alternative dumper if self.firmware_extractor_path.exists(): await self._unpack_boot_image(image_path, output_dir) - # Extract device tree blobs - await self._extract_device_trees(image_path, output_dir) + # Extract device tree blobs + await self._extract_device_trees(image_path, output_dir) + + async def _process_recovery_img(self, image_path: Path, output_dir: Path): + """Process recovery.img by unpacking the image and extracting its ramdisk.""" + output_dir.mkdir(exist_ok=True) + + if self.firmware_extractor_path.exists(): + await self._unpack_boot_image(image_path, output_dir) + + await self._extract_device_trees(image_path, output_dir) async def _process_dtbo_img(self, image_path: Path, output_dir: Path): """Process dtbo.img.""" @@ -447,4 +465,3 @@ async def _process_oppo_images(self): console.print(f"[green]Extracted {img_file.name}[/green]") else: console.print(f"[yellow]Failed to extract {img_file.name}[/yellow]") -